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';