Commit 5017f7fcc47 for woocommerce

commit 5017f7fcc47ee62a355b75f31d450d892d541fca
Author: Karol Manijak <20098064+kmanijak@users.noreply.github.com>
Date:   Thu Oct 1 08:29:15 2026 +0200

    Improve product gallery scrolling with native scroll snap (#69066)

    * Improve product gallery scrolling with native scroll snap

    * Add changelog entry for native product gallery scrolling

    * Refactor product gallery observer setup

    * Separate product gallery navigation test scenarios

    * Simplify product gallery alignment resets

    * Hide thumbnail focus outlines for pointer input

    * Remove product gallery navigation interruption tests

    * Restore one-second gallery swipe assertion timeout

    * Update native product gallery swiping changelog

diff --git a/plugins/woocommerce/changelog/fix-native-product-gallery-scrolling b/plugins/woocommerce/changelog/fix-native-product-gallery-scrolling
new file mode 100644
index 00000000000..2d7f36388f5
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-native-product-gallery-scrolling
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Introduce native swiping for the Product Gallery block.
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 aa04ab4af29..778e4cf9d27 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
@@ -37,6 +37,7 @@ const getArrowsState = ( imageIndex: number, totalImages: number ) => ( {
 } );

 const DIALOG_VIDEO_INTERSECTION_HEIGHT_RATIO = 0.25;
+const pendingViewerImages = new WeakMap< HTMLElement, number >();

 const getVerticalIntersectionRatio = ( rect: DOMRect, rootRect: DOMRect ) => {
 	const height =
@@ -163,20 +164,74 @@ const computeArrowsState = ( imageData: number[], selectedImageId: number ) => {
 	return getArrowsState( index, imageData.length );
 };

-/** Scroll both the large viewer and the thumbnail strip to the given image. */
-const scrollImageEverywhereIntoView = (
-	imageId: number,
-	behavior: ScrollBehavior = 'smooth'
+/** Update the selected image and arrow states without scrolling. */
+const updateSelectedImage = ( imageId: number ) => {
+	const context = getContext();
+	Object.assign( context, {
+		selectedImageId: imageId,
+		...computeArrowsState( context.imageData, imageId ),
+	} );
+};
+
+/** Observe every slide and return a function to refresh their visibility. */
+const initSlidesObservers = (
+	container: HTMLElement,
+	observer: IntersectionObserver
+) => {
+	const slides = container.querySelectorAll( SELECTORS.largeImageWrapper );
+	const resetSlidesObservers = () => {
+		observer.disconnect();
+		slides.forEach( ( slide ) => observer.observe( slide ) );
+	};
+	resetSlidesObservers();
+	return resetSlidesObservers;
+};
+
+/** Resume visible-slide selection when user input interrupts navigation. */
+const initInterruptionEvents = (
+	container: HTMLElement,
+	resetSlidesObservers: () => void
+) => {
+	const onUserInput = () => {
+		if ( pendingViewerImages.delete( container ) ) {
+			// The visible slide may have crossed the threshold before the interruption.
+			resetSlidesObservers();
+		}
+	};
+	const events = [ 'pointerdown', 'wheel', 'keydown' ];
+	for ( const event of events ) {
+		container.addEventListener( event, onUserInput, {
+			capture: true,
+			passive: true,
+		} );
+	}
+	return () => {
+		for ( const event of events ) {
+			container.removeEventListener( event, onUserInput, true );
+		}
+	};
+};
+
+/** Reset viewer alignment when its width changes. */
+const initViewerResizeObserver = (
+	container: HTMLElement,
+	resetAlignment: () => void
 ) => {
-	scrollImageIntoView( imageId, behavior );
-	scrollThumbnailIntoView( imageId );
+	let width = container.clientWidth;
+	const observer = new ResizeObserver( () => {
+		if ( width !== container.clientWidth ) {
+			width = container.clientWidth;
+			resetAlignment();
+		}
+	} );
+	observer.observe( container );
+	return () => observer.disconnect();
 };

 /**
- * Mutate the gallery's reactive context to reflect a new visible image
- * set. Empty input restores the parent product's gallery from the iAPI
- * config. Also recomputes arrow states and scrolls the active slot into
- * view.
+ * Update the image set, selection, arrows and thumbnail position.
+ * Empty input restores the parent gallery from the iAPI config.
+ * The viewer watcher resets alignment after the slide layout updates.
  */
 const updateVisibleImageSet = (
 	imageIds: number[],
@@ -190,22 +245,16 @@ const updateVisibleImageSet = (
 		nextImageData,
 		selectedImageId
 	);
-	const arrowsState = computeArrowsState(
-		nextImageData,
-		nextSelectedImageId
-	);
-
-	context.imageData = nextImageData;
-	context.selectedImageId = nextSelectedImageId;
+	// A reset must rerun the viewer watcher even when the image list is unchanged.
+	context.imageData = [ ...nextImageData ];
 	context.hideNextPreviousButtons = nextImageData.length <= 1;
-	context.isDisabledPrevious = arrowsState.isDisabledPrevious;
-	context.isDisabledNext = arrowsState.isDisabledNext;
+	updateSelectedImage( nextSelectedImageId );

 	if ( nextSelectedImageId === -1 ) {
 		return;
 	}

-	scrollImageEverywhereIntoView( nextSelectedImageId, 'instant' );
+	scrollThumbnailIntoView( nextSelectedImageId );
 };

 /**
@@ -289,9 +338,21 @@ const scrollImageIntoView = (
 		return;
 	}

+	const direction =
+		getComputedStyle( scrollableContainer ).direction === 'rtl' ? -1 : 1;
+	const left = imageIndex * scrollableContainer.clientWidth * direction;
+	if ( Math.abs( scrollableContainer.scrollLeft - left ) > 1 ) {
+		pendingViewerImages.set( scrollableContainer, imageId );
+	} else {
+		pendingViewerImages.delete( scrollableContainer );
+	}
+
 	scrollableContainer.scrollTo( {
-		left: imageIndex * scrollableContainer.clientWidth,
-		behavior,
+		left,
+		behavior: window.matchMedia( '(prefers-reduced-motion: reduce)' )
+			.matches
+			? 'instant'
+			: behavior,
 	} );
 };

@@ -393,14 +454,7 @@ const productGallery = {
 			}

 			const imageId = imageData[ newImageIndex ];
-			const { isDisabledPrevious, isDisabledNext } = getArrowsState(
-				newImageIndex,
-				imageData.length
-			);
-
-			context.isDisabledPrevious = isDisabledPrevious;
-			context.isDisabledNext = isDisabledNext;
-			context.selectedImageId = imageId;
+			updateSelectedImage( imageId );

 			if ( imageId !== -1 ) {
 				scrollImageIntoView( imageId );
@@ -430,19 +484,7 @@ const productGallery = {
 			if ( Number.isNaN( imageId ) ) {
 				return;
 			}
-			const context = getContext();
-			const newImageIndex = context.imageData.indexOf( imageId );
-
-			context.selectedImageId = imageId;
-
-			if ( newImageIndex >= 0 ) {
-				const arrowsState = getArrowsState(
-					newImageIndex,
-					context.imageData.length
-				);
-				context.isDisabledPrevious = arrowsState.isDisabledPrevious;
-				context.isDisabledNext = arrowsState.isDisabledNext;
-			}
+			updateSelectedImage( imageId );

 			scrollImageIntoView( imageId );
 			scrollThumbnailIntoView( imageId );
@@ -472,7 +514,7 @@ const productGallery = {

 			actions.selectImage( newImageIndex );
 		},
-		onViewerImageKeyDown: ( event: KeyboardEvent ) => {
+		onViewerImageKeyDown: withSyncEvent( ( event: KeyboardEvent ) => {
 			if ( event.key === 'Enter' || event.key === ' ' ) {
 				if ( event.key === ' ' ) {
 					event.preventDefault();
@@ -481,13 +523,15 @@ const productGallery = {
 			}

 			if ( event.key === 'ArrowRight' ) {
+				event.preventDefault();
 				actions.selectNextImage();
 			}

 			if ( event.key === 'ArrowLeft' ) {
+				event.preventDefault();
 				actions.selectPreviousImage();
 			}
-		},
+		} ),
 		onDialogKeyDown: ( event: KeyboardEvent ) => {
 			if ( event.key === 'Escape' ) {
 				actions.closeDialog();
@@ -547,52 +591,6 @@ const productGallery = {
 			context.isDialogOpen = false;
 			document.body.classList.remove( CLASSES.dialogOpenBody );
 		},
-		onTouchStart: ( event: TouchEvent ) => {
-			const context = getContext();
-			const { clientX } = event.touches[ 0 ];
-			context.touchStartX = clientX;
-			context.touchCurrentX = clientX;
-			context.isDragging = true;
-		},
-		onTouchMove: ( event: TouchEvent ) => {
-			const context = getContext();
-			if ( ! context.isDragging ) {
-				return;
-			}
-			const { clientX } = event.touches[ 0 ];
-			context.touchCurrentX = clientX;
-
-			// Only prevent default if there's significant horizontal movement
-			const delta = clientX - context.touchStartX;
-			if ( Math.abs( delta ) > 10 ) {
-				event.preventDefault();
-			}
-		},
-		onTouchEnd: () => {
-			const context = getContext();
-			if ( ! context.isDragging ) {
-				return;
-			}
-
-			const SNAP_THRESHOLD = 0.2;
-			const delta = context.touchCurrentX - context.touchStartX;
-			const element = getElement()?.ref as HTMLElement;
-			const imageWidth = element?.offsetWidth || 0;
-
-			// Only trigger swipe actions if there was significant movement
-			if ( Math.abs( delta ) > imageWidth * SNAP_THRESHOLD ) {
-				if ( delta > 0 && ! context.isDisabledPrevious ) {
-					actions.selectPreviousImage();
-				} else if ( delta < 0 && ! context.isDisabledNext ) {
-					actions.selectNextImage();
-				}
-			}
-
-			// Reset touch state
-			context.isDragging = false;
-			context.touchStartX = 0;
-			context.touchCurrentX = 0;
-		},
 		onScroll: () => {
 			const scrollableElement = getElement()?.ref;
 			if ( ! scrollableElement ) {
@@ -650,6 +648,92 @@ const productGallery = {
 		},
 	},
 	callbacks: {
+		/**
+		 * Keep thumbnails and arrows in sync with the visible slide:
+		 * - Update thumbnail selection during native swipes and trackpad scrolling.
+		 * - Keep the thumbnail clicked by the user selected unless interrupted by another user input.
+		 * - Reset the selected image's alignment after variation or gallery width changes.
+		 */
+		watchViewerScroll: () => {
+			const container = getElement()?.ref;
+			if ( ! ( container instanceof HTMLElement ) ) {
+				return;
+			}
+
+			// Reset alignment after variation changes, once slide visibility and order have updated.
+			const { imageData } = getContext();
+			let frame = 0;
+			const alignSelectedImage = withScope( () => {
+				scrollImageIntoView( getContext().selectedImageId, 'instant' );
+				frame = 0;
+			} );
+			const resetAlignment = () => {
+				cancelAnimationFrame( frame );
+				frame = requestAnimationFrame( alignSelectedImage );
+			};
+			// Avoid two slides qualifying when both are exactly half visible.
+			const visibilityThreshold = 0.51;
+			const observer = new IntersectionObserver(
+				withScope( ( entries: IntersectionObserverEntry[] ) => {
+					const context = getContext();
+					if ( frame || context.imageData !== imageData ) {
+						return;
+					}
+
+					const visibleSlide = entries.find(
+						( entry ) =>
+							entry.intersectionRatio >= visibilityThreshold
+					);
+					const image =
+						visibleSlide?.target.querySelector< HTMLElement >(
+							'[data-image-id]'
+						);
+					const imageId = Number( image?.dataset.imageId );
+					const index = imageData.indexOf( imageId );
+					if ( index < 0 ) {
+						return;
+					}
+
+					const pendingImage = pendingViewerImages.get( container );
+					// Keep the clicked thumbnail selected while scrolling past intermediate images.
+					if (
+						pendingImage !== undefined &&
+						imageId !== pendingImage
+					) {
+						return;
+					}
+					pendingViewerImages.delete( container );
+					if ( imageId === context.selectedImageId ) {
+						return;
+					}
+
+					updateSelectedImage( imageId );
+					scrollThumbnailIntoView( imageId );
+				} ),
+				{ root: container, threshold: visibilityThreshold }
+			);
+			const resetSlidesObservers = initSlidesObservers(
+				container,
+				observer
+			);
+			const removeInterruptionEvents = initInterruptionEvents(
+				container,
+				resetSlidesObservers
+			);
+			const disconnectResizeObserver = initViewerResizeObserver(
+				container,
+				resetAlignment
+			);
+			resetAlignment();
+
+			return () => {
+				observer.disconnect();
+				removeInterruptionEvents();
+				pendingViewerImages.delete( container );
+				disconnectResizeObserver();
+				cancelAnimationFrame( frame );
+			};
+		},
 		/**
 		 * Sync the gallery to the blockified Add to Cart + Options block's
 		 * variation state. Bound via `data-wp-watch`, so it re-runs whenever
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/style.scss
index a6b3c7335c4..3cf157bca46 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/style.scss
@@ -16,16 +16,18 @@ $dialog-padding: 20px;

 	:where(.wc-block-product-gallery-large-image__container) {
 		display: flex;
-		overflow: hidden;
+		overflow-x: auto;
+		overflow-y: hidden;
+		scroll-snap-type: x mandatory;
+		scrollbar-width: none;
 		align-items: center;
 		margin: 0;
 		padding: 0;
-		// Chrome drops `scrollTo({behavior:'smooth'})` on `overflow:hidden`
-		// unless `scroll-behavior` is set on the element.
-		scroll-behavior: smooth;
 	}

 	:where(.wc-block-product-gallery-large-image__wrapper) {
+		scroll-snap-align: start;
+		scroll-snap-stop: always;
 		flex-shrink: 0;
 		min-width: 100%;
 		max-width: 100%;
@@ -284,6 +286,10 @@ $dialog-padding: 20px;
 	outline-offset: -2px;
 }

+:where(.wc-block-product-gallery-thumbnails__thumbnail__image):focus:not(:focus-visible) {
+	outline: none;
+}
+
 :where(.wc-block-product-gallery-thumbnails__thumbnail__image--is-active) {
 	pointer-events: none;
 	cursor: default;
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 eed6ceaa8c0..5c822416cc4 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
@@ -58,9 +58,6 @@ export interface ProductGalleryContext {
 	isDialogOpen: boolean;
 	videoLocation?: 'dialog' | 'gallery';
 	productId: string;
-	touchStartX: number;
-	touchCurrentX: number;
-	isDragging: boolean;
 	imageData: number[];
 	thumbnailsOverflow: {
 		top: boolean;
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php
index dc0133ffb9d..c0cf46ca8ef 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php
@@ -277,9 +277,6 @@ class ProductGallery extends AbstractBlock {
 					array(
 						'imageData'               => $default_media_ids,
 						'isDialogOpen'            => false,
-						'isDragging'              => false,
-						'touchStartX'             => 0,
-						'touchCurrentX'           => 0,
 						'productId'               => $product_id,
 						'selectedImageId'         => $initial_media_id,
 						'thumbnailsOverflow'      => [
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php
index 7b7489b5b3f..1e174ffd4a0 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php
@@ -147,9 +147,6 @@ class ProductGalleryLargeImage extends AbstractBlock {
 		$p->set_attribute( 'draggable', 'false' );
 		$p->set_attribute( 'data-wp-watch', 'callbacks.toggleImageVisibility' );
 		$p->set_attribute( 'data-wp-on--click', 'actions.onViewerClick' );
-		$p->set_attribute( 'data-wp-on--touchstart', 'actions.onTouchStart' );
-		$p->set_attribute( 'data-wp-on--touchmove', 'actions.onTouchMove' );
-		$p->set_attribute( 'data-wp-on--touchend', 'actions.onTouchEnd' );

 		if ( 0 === $index ) {
 			$p->set_attribute( 'fetchpriority', 'high' );
@@ -195,6 +192,7 @@ class ProductGalleryLargeImage extends AbstractBlock {
 				class="wc-block-product-gallery-large-image__container"
 				data-wp-interactive="woocommerce/product-gallery"
 				data-wp-on--keydown="actions.onViewerImageKeyDown"
+				data-wp-watch="callbacks.watchViewerScroll"
 				aria-label="<?php esc_attr_e( 'Product gallery', 'woocommerce' ); ?>"
 				tabindex="0"
 				aria-roledescription="carousel"
@@ -300,12 +298,9 @@ class ProductGalleryLargeImage extends AbstractBlock {
 		$attrs = array_merge(
 			$attrs,
 			array(
-				'class'                  => $video_classes,
-				'data-wp-on--touchend'   => 'actions.onTouchEnd',
-				'data-wp-on--touchmove'  => 'actions.onTouchMove',
-				'data-wp-on--touchstart' => 'actions.onTouchStart',
-				'draggable'              => 'false',
-				'tabindex'               => '-1',
+				'class'     => $video_classes,
+				'draggable' => 'false',
+				'tabindex'  => '-1',
 			)
 		);

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 2ff5e15301e..bcff7952ebc 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
@@ -73,6 +73,7 @@ test.describe( `${ blockData.name }`, () => {

 			await expect( zoomWhileHoveringSetting ).toBeChecked();
 		} );
+
 		test( 'should work on frontend when is enabled', async ( {
 			pageObject,
 			editor,
@@ -126,6 +127,7 @@ test.describe( `${ blockData.name }`, () => {
 				expect( styleOnHover.transform ).toBe( '' );
 			} );
 		} );
+
 		test( 'should not work on frontend when is disabled', async ( {
 			pageObject,
 			editor,
@@ -294,14 +296,13 @@ test.describe( `${ blockData.name }`, () => {
 		} ).toPass( { timeout: 5_000 } );
 	} );

-	test.describe( 'Swipe to navigate', () => {
-		test.use( { hasTouch: true } ); // Enable touch support
+	test.describe( 'Frontend navigation', () => {
+		test.use( {
+			hasTouch: true,
+			contextOptions: { reducedMotion: 'no-preference' },
+		} );

-		test( 'should work on frontend when is enabled', async ( {
-			pageObject,
-			editor,
-			page,
-		} ) => {
+		test.beforeEach( async ( { pageObject, editor, page } ) => {
 			await pageObject.addProductGalleryBlock( { cleanContent: true } );
 			await editor.saveSiteEditorEntities( {
 				isOnlyCurrentEntityDirty: true,
@@ -313,7 +314,12 @@ test.describe( `${ blockData.name }`, () => {
 				height: 667,
 				width: 390, // iPhone 12 Pro
 			} );
+		} );

+		test( 'updates thumbnail selection during a native swipe', async ( {
+			pageObject,
+			page,
+		} ) => {
 			const viewerBlock = await pageObject.getViewerBlock( {
 				page: 'frontend',
 			} );
@@ -321,64 +327,44 @@ test.describe( `${ blockData.name }`, () => {

 			const initialImageId = await pageObject.getViewerImageId();

-			// Get the element's bounding box
-			const box = await viewerImage.boundingBox();
-			if ( ! box ) {
-				return;
+			await viewerImage.scrollIntoViewIfNeeded();
+			const box = ( await viewerImage.boundingBox() )!;
+			expect( box ).not.toBeNull();
+
+			// DOM-dispatched touch events cannot trigger the browser's native scrolling.
+			const session = await page.context().newCDPSession( page );
+			const startX = box.x + box.width * 0.8;
+			const y = box.y + box.height / 2;
+			await session.send( 'Input.dispatchTouchEvent', {
+				type: 'touchStart',
+				touchPoints: [ { x: startX, y } ],
+			} );
+			for ( let step = 1; step <= 8; step++ ) {
+				await session.send( 'Input.dispatchTouchEvent', {
+					type: 'touchMove',
+					touchPoints: [
+						{ x: startX - box.width * 0.075 * step, y },
+					],
+				} );
 			}

-			// Calculate start and end points for the swipe
-			const swipeStartX = box.x + box.width / 2; // middle of element
-			const swipeStartY = box.y + box.height / 2;
-			const swipeEndX = swipeStartX - 200; // swipe left by 200px
-			const swipeEndY = swipeStartY;
-
-			// Dispatch touch events to simulate swipe
-			await viewerImage.evaluate(
-				( element, { startX, startY, endX, endY } ) => {
-					const touchStart = new TouchEvent( 'touchstart', {
-						bubbles: true,
-						cancelable: true,
-						touches: [
-							new Touch( {
-								identifier: 0,
-								target: element,
-								clientX: startX,
-								clientY: startY,
-							} ),
-						],
-					} );
-
-					const touchMove = new TouchEvent( 'touchmove', {
-						bubbles: true,
-						cancelable: true,
-						touches: [
-							new Touch( {
-								identifier: 0,
-								target: element,
-								clientX: endX,
-								clientY: endY,
-							} ),
-						],
-					} );
-
-					const touchEnd = new TouchEvent( 'touchend', {
-						bubbles: true,
-						cancelable: true,
-						touches: [],
-					} );
-
-					element.dispatchEvent( touchStart );
-					element.dispatchEvent( touchMove );
-					element.dispatchEvent( touchEnd );
-				},
-				{
-					startX: swipeStartX,
-					startY: swipeStartY,
-					endX: swipeEndX,
-					endY: swipeEndY,
-				}
+			const scroller = viewerBlock.locator(
+				'.wc-block-product-gallery-large-image__container'
 			);
+			await expect
+				.poll( () =>
+					scroller.evaluate( ( element ) => element.scrollLeft )
+				)
+				.toBeGreaterThan( 0 );
+			const imageIds = await pageObject.getVisibleViewerImageIds();
+			await expect
+				.poll( () => pageObject.getActiveThumbnailImageId() )
+				.toEqual( imageIds[ 1 ] );
+			await session.send( 'Input.dispatchTouchEvent', {
+				type: 'touchEnd',
+				touchPoints: [],
+			} );
+			await session.detach();

 			// Verify dialog is not opened
 			const dialog = page.locator( '.wc-block-product-gallery-dialog' );
@@ -389,7 +375,18 @@ test.describe( `${ blockData.name }`, () => {
 				const nextImageId = await pageObject.getViewerImageId();

 				expect( nextImageId ).not.toEqual( initialImageId );
+				expect( await pageObject.getActiveThumbnailImageId() ).toEqual(
+					nextImageId
+				);
 			} ).toPass( { timeout: 1_000 } );
+
+			await expect
+				.poll( () =>
+					scroller.evaluate( ( element ) =>
+						Math.abs( element.scrollLeft - element.clientWidth )
+					)
+				)
+				.toBeLessThan( 1 );
 		} );
 	} );
 } );