Commit fe9ef76a33d for woocommerce

commit fe9ef76a33d93b5161c0a0ebd9580d9eacb4c43a
Author: Karol Manijak <20098064+kmanijak@users.noreply.github.com>
Date:   Thu Aug 13 16:38:33 2026 +0200

    Fix Product Gallery variation image lag (#67480)

    * Fix Product Gallery variation image lag

    * Add changelog entry for Product Gallery variation fix

    * Add classic Add to Cart Form gallery regression test

    * Fix classic variation gallery fallback

    * Clarify variation event ID validation

    * Validate both variation gallery IDs

    * Explain Product Gallery E2E retry timeouts

diff --git a/plugins/woocommerce/changelog/codex-fix-product-gallery-variation-lag b/plugins/woocommerce/changelog/codex-fix-product-gallery-variation-lag
new file mode 100644
index 00000000000..eff050e47e3
--- /dev/null
+++ b/plugins/woocommerce/changelog/codex-fix-product-gallery-variation-lag
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix Product Gallery showing the previously selected variation's image when used with the classic Add to Cart Form.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts
index a317443cecf..aa04ab4af29 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts
@@ -718,53 +718,59 @@ const productGallery = {
 			}

 			const productImageSet = getProductImageSet( context.productId );
-			const syncFormVariationGallery = withScope( () => {
-				if ( ! productImageSet ) {
-					actions.resetImageData();
-					return;
-				}
+			const syncFormVariationGallery = withScope(
+				( variationId?: number, featuredImageId?: number ) => {
+					if ( ! productImageSet ) {
+						actions.resetImageData();
+						return;
+					}

-				const $variationIdInput = $form.querySelector(
-					SELECTORS.legacyVariationIdInput
-				) as HTMLInputElement | null;
-				const hasVariationIdInput = !! $variationIdInput;
-				const currentVariationId = Number.parseInt(
-					$variationIdInput?.value || '0',
-					10
-				);
+					const $variationIdInput = $form.querySelector(
+						SELECTORS.legacyVariationIdInput
+					) as HTMLInputElement | null;
+					const hasVariationIdInput = !! $variationIdInput;
+					const currentVariationId =
+						variationId ??
+						Number.parseInt( $variationIdInput?.value || '0', 10 );
+
+					// When the form exposes a variation_id input but it's empty,
+					// the merchant cleared the variation — restore the parent
+					// gallery instead of guessing from `current-image`.
+					if ( hasVariationIdInput && ! currentVariationId ) {
+						actions.resetImageData();
+						return;
+					}

-				// When the form exposes a variation_id input but it's empty,
-				// the merchant cleared the variation — restore the parent
-				// gallery instead of guessing from `current-image`.
-				if ( hasVariationIdInput && ! currentVariationId ) {
-					actions.resetImageData();
-					return;
-				}
+					const currentImageId =
+						featuredImageId ??
+						Number.parseInt(
+							$form.getAttribute( 'current-image' ) || '0',
+							10
+						);
+					const variationImageSet = currentVariationId
+						? productImageSet.variations?.[ currentVariationId ]
+						: getVariationImageSetByCurrentImage(
+								productImageSet,
+								currentImageId
+						  );
+
+					if ( variationImageSet?.image_ids?.length ) {
+						actions.setImageData(
+							variationImageSet.image_ids,
+							currentImageId || variationImageSet.image_id
+						);
+						return;
+					}

-				const currentImageId = Number.parseInt(
-					$form.getAttribute( 'current-image' ) || '0',
-					10
-				);
-				const variationImageSet = hasVariationIdInput
-					? productImageSet.variations?.[ currentVariationId ]
-					: getVariationImageSetByCurrentImage(
-							productImageSet,
-							currentImageId
-					  );
-
-				if ( variationImageSet?.image_ids?.length ) {
-					actions.setImageData(
-						variationImageSet.image_ids,
-						currentImageId || variationImageSet.image_id
-					);
-					return;
+					actions.resetImageData();
 				}
-
-				actions.resetImageData();
-			} );
+			);

 			const teardownJQuery = subscribeLegacyJQueryFormVariations( $form, {
-				onVariationFound: () => syncFormVariationGallery(),
+				// `found_variation` fires before the classic form updates its DOM.
+				// Pass its fresh IDs into the config-based gallery sync.
+				onVariationFound: ( variationId, featuredImageId ) =>
+					syncFormVariationGallery( variationId, featuredImageId ),
 				onVariationReset: () => actions.resetImageData(),
 			} );

diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/legacy-jquery-form.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/legacy-jquery-form.ts
index 9c9097247ca..c68e5f4b516 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/legacy-jquery-form.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/legacy-jquery-form.ts
@@ -26,29 +26,10 @@ import type {
 	LegacyVariationPayload,
 } from './types';

-/** A WP attachment ID that's safe to use as a gallery slot. */
-const isValidImageId = ( id: unknown ): id is number =>
+/** A positive integer ID from the variation event payload. */
+const isValidId = ( id: unknown ): id is number =>
 	typeof id === 'number' && Number.isInteger( id ) && id > 0;

-/**
- * Coerce the variation event payload's IDs into a deduped list of
- * positive integers, with the optional featured image at position 0.
- */
-const normalizeImageData = (
-	imageIds: unknown,
-	featuredImageId?: number
-): number[] => {
-	const featured = isValidImageId( featuredImageId )
-		? [ featuredImageId ]
-		: [];
-	const others = Array.isArray( imageIds )
-		? imageIds
-				.map( ( id ) => Number.parseInt( String( id ), 10 ) )
-				.filter( isValidImageId )
-		: [];
-	return Array.from( new Set( [ ...featured, ...others ] ) );
-};
-
 /**
  * Subscribe to the legacy classic Add to Cart form's jQuery variation
  * events. Returns a teardown callable, or `null` when jQuery isn't
@@ -67,13 +48,14 @@ export const subscribeLegacyJQueryFormVariations = (

 	const handleFound = withScope(
 		( _event?: unknown, variation?: LegacyVariationPayload ) => {
-			const imageData = normalizeImageData(
-				variation?.gallery_image_ids,
-				variation?.image_id
-			);
-
-			if ( imageData.length ) {
-				handlers.onVariationFound( imageData, variation?.image_id );
+			if (
+				isValidId( variation?.variation_id ) &&
+				isValidId( variation?.image_id )
+			) {
+				handlers.onVariationFound(
+					variation.variation_id,
+					variation.image_id
+				);
 				return;
 			}

diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/types.ts
index 5f8730cf4f2..f3888880fd2 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/types.ts
@@ -29,8 +29,8 @@ export type ProductGalleryConfig = WooCommerceConfig & {
 };

 export type LegacyVariationPayload = {
+	variation_id?: number;
 	image_id?: number;
-	gallery_image_ids?: number[];
 };

 export type LegacyJQueryInstance = {
@@ -46,7 +46,10 @@ export type LegacyJQueryWindow = Window & {
 };

 export type LegacyJQueryFormHandlers = {
-	onVariationFound: ( imageIds: number[], featuredImageId?: number ) => void;
+	onVariationFound: (
+		variationId?: number,
+		featuredImageId?: number
+	) => void;
 	onVariationReset: () => void;
 };

diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-gallery/inner-blocks/product-gallery-large-image/product-gallery-large-image.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-gallery/inner-blocks/product-gallery-large-image/product-gallery-large-image.block_theme.spec.ts
index 7082cd592e7..2ff5e15301e 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-gallery/inner-blocks/product-gallery-large-image/product-gallery-large-image.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-gallery/inner-blocks/product-gallery-large-image/product-gallery-large-image.block_theme.spec.ts
@@ -168,7 +168,7 @@ test.describe( `${ blockData.name }`, () => {
 		pageObject,
 	} ) => {
 		await pageObject.addProductGalleryBlock( { cleanContent: true } );
-		await pageObject.addAddToCartWithOptionsBlock();
+		await pageObject.addClassicAddToCartFormBlock();

 		const viewerBlock = await pageObject.getViewerBlock( {
 			page: 'editor',
@@ -185,7 +185,7 @@ test.describe( `${ blockData.name }`, () => {
 		const featuredImageId = await pageObject.getViewerImageId();
 		expect( featuredImageId ).not.toBeNull();

-		const cartForm = await pageObject.getAddToCartWithOptionsBlock( {
+		const cartForm = await pageObject.getClassicAddToCartFormBlock( {
 			page: 'frontend',
 		} );
 		const colorSelect = cartForm.getByLabel( 'Color' );
@@ -209,6 +209,91 @@ test.describe( `${ blockData.name }`, () => {
 		} ).toPass( { timeout: 5_000 } );
 	} );

+	test( 'Variable product gallery: classic Add to Cart Form preserves the parent gallery between variations', async ( {
+		page,
+		editor,
+		pageObject,
+	} ) => {
+		await pageObject.addProductGalleryBlock( { cleanContent: true } );
+		await pageObject.addClassicAddToCartFormBlock();
+
+		await editor.saveSiteEditorEntities( {
+			isOnlyCurrentEntityDirty: true,
+		} );
+
+		await page.goto( blockData.productPage );
+		const featuredImageId = await pageObject.getViewerImageId();
+		expect( featuredImageId ).not.toBeNull();
+		const parentImageIds = await pageObject.getVisibleViewerImageIds();
+		const parentGalleryImageIds = parentImageIds.slice( 1 );
+		expect( parentGalleryImageIds.length ).toBeGreaterThan( 0 );
+
+		const addToCartForm = await pageObject.getClassicAddToCartFormBlock( {
+			page: 'frontend',
+		} );
+		await expect(
+			addToCartForm.locator( 'form.variations_form' )
+		).toBeVisible();
+
+		const colorSelect = addToCartForm.getByLabel( 'Color' );
+		const logoSelect = addToCartForm.getByLabel( 'Logo' );
+
+		await colorSelect.selectOption( 'Blue' );
+		await logoSelect.selectOption( 'Yes' );
+
+		await expect( async () => {
+			const variationImageId = await pageObject.getViewerImageId();
+			expect( variationImageId ).not.toEqual( featuredImageId );
+		} ).toPass( { timeout: 5_000 } );
+
+		const firstVariationImageId = await pageObject.getViewerImageId();
+		const firstVariationImageIds = Array.from(
+			new Set( [ firstVariationImageId, ...parentGalleryImageIds ] )
+		);
+		// Product Gallery blocks update reactively and may not be ready
+		// instantly hence expect().toPass with custom timeout since it's 0 by default.
+		await expect( async () => {
+			const variationImageIds =
+				await pageObject.getVisibleViewerImageIds();
+			const thumbnailImageIds =
+				await pageObject.getVisibleThumbnailImageIds();
+			const activeThumbnailImageId =
+				await pageObject.getActiveThumbnailImageId();
+
+			expect( variationImageIds ).toEqual( firstVariationImageIds );
+			expect( thumbnailImageIds ).toEqual( firstVariationImageIds );
+			expect( activeThumbnailImageId ).toEqual( firstVariationImageId );
+		} ).toPass( { timeout: 5_000 } );
+
+		await logoSelect.selectOption( 'No' );
+
+		// Product Gallery blocks update reactively and may not be ready
+		// instantly hence expect().toPass with custom timeout since it's 0 by default.
+		await expect( async () => {
+			const nextVariationImageId = await pageObject.getViewerImageId();
+			expect( nextVariationImageId ).not.toEqual( firstVariationImageId );
+		} ).toPass( { timeout: 5_000 } );
+
+		const nextVariationImageId = await pageObject.getViewerImageId();
+		const nextVariationImageIds = Array.from(
+			new Set( [ nextVariationImageId, ...parentGalleryImageIds ] )
+		);
+		// Product Gallery blocks update reactively and may not be ready
+		// instantly hence expect().toPass with custom timeout since it's 0 by default.
+		await expect( async () => {
+			const variationImageIds =
+				await pageObject.getVisibleViewerImageIds();
+			const thumbnailImageIds =
+				await pageObject.getVisibleThumbnailImageIds();
+			const activeThumbnailImageId =
+				await pageObject.getActiveThumbnailImageId();
+
+			expect( variationImageIds ).toEqual( nextVariationImageIds );
+			expect( thumbnailImageIds ).toEqual( nextVariationImageIds );
+			expect( activeThumbnailImageId ).toEqual( nextVariationImageId );
+		} ).toPass( { timeout: 5_000 } );
+	} );
+
 	test.describe( 'Swipe to navigate', () => {
 		test.use( { hasTouch: true } ); // Enable touch support

diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-gallery/product-gallery.page.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-gallery/product-gallery.page.ts
index 240a7984ac2..19a62e97aeb 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-gallery/product-gallery.page.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-gallery/product-gallery.page.ts
@@ -41,7 +41,7 @@ export class ProductGalleryPage {
 		} );
 	}

-	async addAddToCartWithOptionsBlock() {
+	async addClassicAddToCartFormBlock() {
 		await this.editor.insertBlock( {
 			name: 'woocommerce/add-to-cart-form',
 		} );
@@ -153,6 +153,33 @@ export class ProductGalleryPage {
 		return null;
 	}

+	async getVisibleViewerImageIds() {
+		const viewerBlockLocator = await this.getViewerBlock( {
+			page: 'frontend',
+		} );
+
+		return viewerBlockLocator
+			.locator(
+				'.wc-block-product-gallery-large-image__wrapper:not([hidden]) img[data-image-id]'
+			)
+			.evaluateAll( ( images ) =>
+				images
+					.map( ( image ) => ( {
+						id: image.getAttribute( 'data-image-id' ) || '',
+						order: Number.parseInt(
+							(
+								image.closest(
+									'.wc-block-product-gallery-large-image__wrapper'
+								) as HTMLElement
+							 )?.style.order || '0',
+							10
+						),
+					} ) )
+					.sort( ( first, second ) => first.order - second.order )
+					.map( ( image ) => image.id )
+			);
+	}
+
 	async getThumbnailsBlock( { page }: { page: 'frontend' | 'editor' } ) {
 		const blockName = 'woocommerce/product-gallery-thumbnails';
 		if ( page === 'frontend' ) {
@@ -165,6 +192,45 @@ export class ProductGalleryPage {
 		return this.editor.getBlockByName( blockName );
 	}

+	async getVisibleThumbnailImageIds() {
+		const thumbnailsBlockLocator = await this.getThumbnailsBlock( {
+			page: 'frontend',
+		} );
+
+		return thumbnailsBlockLocator
+			.locator(
+				'.wc-block-product-gallery-thumbnails__thumbnail:not([hidden]) [data-image-id]'
+			)
+			.evaluateAll( ( thumbnails ) =>
+				thumbnails
+					.map( ( thumbnail ) => ( {
+						id: thumbnail.getAttribute( 'data-image-id' ) || '',
+						order: Number.parseInt(
+							(
+								thumbnail.closest(
+									'.wc-block-product-gallery-thumbnails__thumbnail'
+								) as HTMLElement
+							 )?.style.order || '0',
+							10
+						),
+					} ) )
+					.sort( ( first, second ) => first.order - second.order )
+					.map( ( thumbnail ) => thumbnail.id )
+			);
+	}
+
+	async getActiveThumbnailImageId() {
+		const thumbnailsBlockLocator = await this.getThumbnailsBlock( {
+			page: 'frontend',
+		} );
+
+		return thumbnailsBlockLocator
+			.locator(
+				'.wc-block-product-gallery-thumbnails__thumbnail__image--is-active[data-image-id]'
+			)
+			.getAttribute( 'data-image-id' );
+	}
+
 	async getNextPreviousButtonsBlock( {
 		page,
 	}: {
@@ -210,7 +276,7 @@ export class ProductGalleryPage {
 		return this.editor.getBlockByName( blockName );
 	}

-	async getAddToCartWithOptionsBlock( {
+	async getClassicAddToCartFormBlock( {
 		page,
 	}: {
 		page: 'frontend' | 'editor';