Commit ca1d393393 for woocommerce

commit ca1d3933937d220a93b2312d6797ac956e25a5dd
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date:   Fri Dec 5 18:45:01 2025 +0100

    Limit ProductControl component to loading a maximum of 25 variations at once (#61853)

    * Limit ProductControl component to loading a maximum of 100 variations at once

    * Add changelog file

    * Fix button width on mobile

    * Reduce getProductVariationsWithTotal() per_page default value to 25

    * Update props name and fix types

    * Fix type mismatch

    * Add and update tests

    * Make sure we don't render the button if the number of total variations is undefined

    * Styling updates

    * Typo

    * Add correct type to button

    * Make it so SearchListControl doesn't have specific references to variations in the text

    * Fix types

    * Add missing types

    * Improve prop description

    * Make sure product ids in object keys are typed as numbers

    * Remove unnecessary 'undefined' type

diff --git a/plugins/woocommerce/changelog/fix-product-control-load-100-variations-at-once b/plugins/woocommerce/changelog/fix-product-control-load-100-variations-at-once
new file mode 100644
index 0000000000..3c23c57ea5
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-product-control-load-100-variations-at-once
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Limit ProductControl component to loading a maximum of 25 variations at once
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-control/index.tsx b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-control/index.tsx
index f86f2878dc..8c5c754088 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-control/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-control/index.tsx
@@ -45,7 +45,15 @@ interface ProductControlProps {
 	/**
 	 * The ID of the currently expanded product.
 	 */
-	expandedProduct: number | null;
+	expandedProduct?: number | null;
+	/**
+	 * Callback to load more variations.
+	 */
+	onLoadMoreVariations?: () => void;
+	/**
+	 * The total number of variations.
+	 */
+	totalVariations?: Record< number, number | null >;
 	/**
 	 * Callback to search products by their name.
 	 */
@@ -94,6 +102,8 @@ const ProductControl = (
 		isCompact = false,
 		isLoading,
 		onChange,
+		onLoadMoreVariations,
+		totalVariations,
 		onSearch,
 		products,
 		renderItem,
@@ -224,6 +234,15 @@ const ProductControl = (
 				selected.includes( Number( id ) )
 			) }
 			onChange={ onChange }
+			loadMoreChildrenText={
+				showVariations
+					? __( 'Load more variations', 'woocommerce' )
+					: undefined
+			}
+			onLoadMoreChildren={
+				showVariations ? onLoadMoreVariations : undefined
+			}
+			totalChildren={ showVariations ? totalVariations : undefined }
 			renderItem={ getRenderItemFunc() }
 			onSearch={ onSearch }
 			messages={ {
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/search-list-control.tsx b/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/search-list-control.tsx
index ee26e735da..9f8ad36109 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/search-list-control.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/search-list-control.tsx
@@ -46,6 +46,9 @@ const ListItems = ( props: ListItemsProps ): JSX.Element | null => {
 		selected,
 		renderItem,
 		depth = 0,
+		loadMoreChildrenText,
+		onLoadMoreChildren,
+		totalChildren,
 		onSelect,
 		instanceId,
 		isSingle,
@@ -61,16 +64,20 @@ const ListItems = ( props: ListItemsProps ): JSX.Element | null => {
 	return (
 		<>
 			{ list.map( ( item ) => {
+				const childrenCount = item.children?.length ?? 0;
 				const isSelected =
-					item.children?.length && ! isSingle
-						? item.children.every( ( { id } ) =>
+					childrenCount && ! isSingle
+						? item.children?.every( ( { id } ) =>
 								selected.find(
 									( selectedItem ) => selectedItem.id === id
 								)
 						  )
 						: !! selected.find( ( { id } ) => id === item.id );
-				const isExpanded =
-					item.children?.length && expandedPanelId === item.id;
+				const isExpanded = childrenCount && expandedPanelId === item.id;
+				const totalChildrenForItem = totalChildren?.[ item.id ];
+				const hasMoreChildren =
+					typeof totalChildrenForItem === 'number' &&
+					childrenCount < totalChildrenForItem;

 				return (
 					<Fragment key={ item.id }>
@@ -88,11 +95,32 @@ const ListItems = ( props: ListItemsProps ): JSX.Element | null => {
 							} ) }
 						</li>
 						{ isExpanded ? (
-							<ListItems
-								{ ...props }
-								list={ item.children as SearchListItemProps[] }
-								depth={ depth + 1 }
-							/>
+							<>
+								<ListItems
+									{ ...props }
+									list={
+										item.children as SearchListItemProps[]
+									}
+									depth={ depth + 1 }
+								/>
+								{ onLoadMoreChildren && hasMoreChildren ? (
+									<li>
+										<button
+											type="button"
+											className="woocommerce-search-list__item woocommerce-search-list__item-load-more"
+											onClick={ () =>
+												onLoadMoreChildren()
+											}
+										>
+											{ loadMoreChildrenText ||
+												__(
+													'Load more',
+													'woocommerce'
+												) }
+										</button>
+									</li>
+								) : null }
+							</>
 						) : null }
 					</Fragment>
 				);
@@ -156,7 +184,15 @@ const ListItemsContainer = < T extends object = object >( {
 	useExpandedPanelId,
 	...props
 }: SearchListItemsContainerProps< T > ) => {
-	const { messages, renderItem, selected, isSingle } = props;
+	const {
+		messages,
+		renderItem,
+		selected,
+		isSingle,
+		loadMoreChildrenText,
+		onLoadMoreChildren,
+		totalChildren,
+	} = props;
 	const renderItemCallback = renderItem || defaultRenderListItem;

 	if ( filteredList.length === 0 ) {
@@ -182,6 +218,9 @@ const ListItemsContainer = < T extends object = object >( {
 				list={ filteredList }
 				selected={ selected }
 				renderItem={ renderItemCallback }
+				loadMoreChildrenText={ loadMoreChildrenText }
+				onLoadMoreChildren={ onLoadMoreChildren }
+				totalChildren={ totalChildren }
 				onSelect={ onSelect }
 				instanceId={ instanceId }
 				isSingle={ isSingle }
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/style.scss b/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/style.scss
index a6d27b3fa4..b9476d2543 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/style.scss
@@ -306,4 +306,18 @@
 	li:last-child .woocommerce-search-list__item {
 		border-bottom: none;
 	}
+
+	.woocommerce-search-list__item-load-more {
+		border: none;
+		color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9));
+		cursor: pointer;
+		padding-left: calc($gap * 2 + 24px);
+		width: 100%;
+
+		@media screen and (max-width: 782px) {
+			box-sizing: content-box;
+			height: 25px;
+			padding-left: calc($gap * 2 + 33px);
+		}
+	}
 }
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/types.ts b/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/types.ts
index e2048cc008..df6d88410e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/types.ts
@@ -7,6 +7,13 @@ import { Require } from '@woocommerce/types';
 interface ItemProps< T extends object = object > {
 	// Depth, non-zero if the list is hierarchical.
 	depth?: number;
+	// Text to display for the load more button.
+	loadMoreChildrenText?: string | undefined;
+	// Callback for loading more children.
+	onLoadMoreChildren?: ( () => void ) | undefined;
+	// Map of parent item IDs to their total number of children.
+	// Value is null when total is unknown, and 0 when known to have no children.
+	totalChildren?: { [ key: string ]: number | null } | undefined;
 	// Callback for selecting the item.
 	onSelect: (
 		item: SearchListItem< T > | SearchListItem< T >[]
@@ -119,6 +126,13 @@ export interface SearchListControlProps< T extends object = object > {
 	list: SearchListItem< T >[];
 	// Messages displayed or read to the user. Configure these to reflect your object type.
 	messages?: Partial< SearchListMessages >;
+	// Text to display for the load more button.
+	loadMoreChildrenText?: string | undefined;
+	// Callback for loading more children.
+	onLoadMoreChildren?: ( () => void ) | undefined;
+	// Map of parent item IDs to their total number of children.
+	// Value is null when total is unknown, and 0 when known to have no children.
+	totalChildren?: { [ key: string ]: number | null } | undefined;
 	// Callback fired when selected items change, whether added, cleared, or removed. Passed an array of item objects (as passed in via props.list).
 	onChange: ( search: SearchListItem< T >[] ) => void;
 	// Callback fired when the search field is used.
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/utils/index.js b/plugins/woocommerce/client/blocks/assets/js/editor-components/utils/index.js
index 6a8f79a14a..ba1a4e8470 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/utils/index.js
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/utils/index.js
@@ -191,9 +191,42 @@ export const getCategory = ( categoryId ) => {
 	} );
 };

+/**
+ * Get a promise that resolves to a list of variation objects from the Store API
+ * and the total number of variations.
+ *
+ * @param {number} product Product ID.
+ * @param {Object} args    Query args to pass in.
+ */
+export const getProductVariationsWithTotal = ( product, args = {} ) => {
+	return apiFetch( {
+		path: addQueryArgs( `wc/store/v1/products`, {
+			type: 'variation',
+			parent: product,
+			orderby: 'title',
+			per_page: 25,
+			...args,
+		} ),
+		parse: false,
+	} ).then( ( response ) => {
+		return response.json().then( ( data ) => {
+			const totalHeader = response.headers.get( 'x-wp-total' );
+			return {
+				variations: data,
+				total: totalHeader ? Number( totalHeader ) : null,
+			};
+		} );
+	} );
+};
+
 /**
  * Get a promise that resolves to a list of variation objects from the Store API.
  *
+ * NOTE: If implementing new features, prefer using the
+ * `getProductVariationsWithTotal()` function above, as it doesn't default to
+ * `per_page: 0`.
+ * See: https://github.com/woocommerce/woocommerce/pull/61755#issuecomment-3499859585
+ *
  * @param {number} product Product ID.
  */
 export const getProductVariations = ( product ) => {
diff --git a/plugins/woocommerce/client/blocks/assets/js/hocs/test/with-product-variations.jsx b/plugins/woocommerce/client/blocks/assets/js/hocs/test/with-product-variations.jsx
index c9494dedc5..0ba3fd4a86 100644
--- a/plugins/woocommerce/client/blocks/assets/js/hocs/test/with-product-variations.jsx
+++ b/plugins/woocommerce/client/blocks/assets/js/hocs/test/with-product-variations.jsx
@@ -14,7 +14,7 @@ import withProductVariations from '../with-product-variations';
 import * as mockBaseUtils from '../../base/utils/errors';

 jest.mock( '@woocommerce/editor-components/utils', () => ( {
-	getProductVariations: jest.fn(),
+	getProductVariationsWithTotal: jest.fn(),
 } ) );

 jest.mock( '../../base/utils/errors', () => ( {
@@ -37,6 +37,8 @@ const TestComponent = withProductVariations( ( props ) => {
 			data-isLoading={ props.isLoading }
 			data-variations={ props.variations }
 			data-variationsLoading={ props.variationsLoading }
+			data-onLoadMoreVariations={ props.onLoadMoreVariations }
+			data-totalVariations={ props.totalVariations }
 		/>
 	);
 } );
@@ -55,25 +57,30 @@ const render = () => {
 describe( 'withProductVariations Component', () => {
 	let renderer;
 	afterEach( () => {
-		mockUtils.getProductVariations.mockReset();
+		mockUtils.getProductVariationsWithTotal.mockReset();
 	} );

 	describe( 'lifecycle events', () => {
 		beforeEach( () => {
-			mockUtils.getProductVariations.mockImplementation( () =>
-				Promise.resolve( mockVariations )
+			mockUtils.getProductVariationsWithTotal.mockImplementation( () =>
+				Promise.resolve( {
+					variations: mockVariations,
+					total: mockVariations.length,
+				} )
 			);
 		} );

-		it( 'getProductVariations is called on mount', () => {
+		it( 'getProductVariationsWithTotal is called on mount', () => {
 			renderer = render();
-			const { getProductVariations } = mockUtils;
+			const { getProductVariationsWithTotal } = mockUtils;

-			expect( getProductVariations ).toHaveBeenCalledWith( 1 );
-			expect( getProductVariations ).toHaveBeenCalledTimes( 1 );
+			expect( getProductVariationsWithTotal ).toHaveBeenCalledWith( 1, {
+				offset: 0,
+			} );
+			expect( getProductVariationsWithTotal ).toHaveBeenCalledTimes( 1 );
 		} );

-		it( 'getProductVariations is called on component update', () => {
+		it( 'getProductVariationsWithTotal is called on component update', () => {
 			renderer = TestRenderer.create(
 				<TestComponent
 					error={ null }
@@ -81,9 +88,9 @@ describe( 'withProductVariations Component', () => {
 					products={ mockProducts }
 				/>
 			);
-			const { getProductVariations } = mockUtils;
+			const { getProductVariationsWithTotal } = mockUtils;

-			expect( getProductVariations ).toHaveBeenCalledTimes( 0 );
+			expect( getProductVariationsWithTotal ).toHaveBeenCalledTimes( 0 );

 			renderer.update(
 				<TestComponent
@@ -95,11 +102,13 @@ describe( 'withProductVariations Component', () => {
 				/>
 			);

-			expect( getProductVariations ).toHaveBeenCalledWith( 1 );
-			expect( getProductVariations ).toHaveBeenCalledTimes( 1 );
+			expect( getProductVariationsWithTotal ).toHaveBeenCalledWith( 1, {
+				offset: 0,
+			} );
+			expect( getProductVariationsWithTotal ).toHaveBeenCalledTimes( 1 );
 		} );

-		it( 'getProductVariations is not called if selected product has no variations', () => {
+		it( 'getProductVariationsWithTotal is not called if selected product has no variations', () => {
 			TestRenderer.create(
 				<TestComponent
 					error={ null }
@@ -109,12 +118,12 @@ describe( 'withProductVariations Component', () => {
 					showVariations={ true }
 				/>
 			);
-			const { getProductVariations } = mockUtils;
+			const { getProductVariationsWithTotal } = mockUtils;

-			expect( getProductVariations ).toHaveBeenCalledTimes( 0 );
+			expect( getProductVariationsWithTotal ).toHaveBeenCalledTimes( 0 );
 		} );

-		it( 'getProductVariations is called if selected product is a variation', () => {
+		it( 'getProductVariationsWithTotal is called if selected product is a variation', () => {
 			TestRenderer.create(
 				<TestComponent
 					error={ null }
@@ -124,22 +133,27 @@ describe( 'withProductVariations Component', () => {
 					showVariations={ true }
 				/>
 			);
-			const { getProductVariations } = mockUtils;
+			const { getProductVariationsWithTotal } = mockUtils;

-			expect( getProductVariations ).toHaveBeenCalledWith( 1 );
-			expect( getProductVariations ).toHaveBeenCalledTimes( 1 );
+			expect( getProductVariationsWithTotal ).toHaveBeenCalledWith( 1, {
+				offset: 0,
+			} );
+			expect( getProductVariationsWithTotal ).toHaveBeenCalledTimes( 1 );
 		} );
 	} );

 	describe( 'when the API returns variations data', () => {
 		beforeEach( () => {
-			mockUtils.getProductVariations.mockImplementation( () =>
-				Promise.resolve( mockVariations )
+			mockUtils.getProductVariationsWithTotal.mockImplementation( () =>
+				Promise.resolve( {
+					variations: mockVariations,
+					total: mockVariations.length,
+				} )
 			);
 			renderer = render();
 		} );

-		it( 'sets the variations props', () => {
+		it( 'sets the variations props', async () => {
 			const props = renderer.root.findByType( 'div' ).props;
 			const expectedVariations = {
 				1: [
@@ -156,12 +170,12 @@ describe( 'withProductVariations Component', () => {

 	describe( 'when the API returns an error', () => {
 		const error = { message: 'There was an error.' };
-		const getProductVariationsPromise = Promise.reject( error );
+		const getProductVariationsWithTotalPromise = Promise.reject( error );
 		const formattedError = { message: 'There was an error.', type: 'api' };

 		beforeEach( () => {
-			mockUtils.getProductVariations.mockImplementation(
-				() => getProductVariationsPromise
+			mockUtils.getProductVariationsWithTotal.mockImplementation(
+				() => getProductVariationsWithTotalPromise
 			);
 			mockBaseUtils.formatError.mockImplementation(
 				() => formattedError
@@ -170,7 +184,9 @@ describe( 'withProductVariations Component', () => {
 		} );

 		test( 'sets the error prop', async () => {
-			await expect( () => getProductVariationsPromise() ).toThrow();
+			await TestRenderer.act( async () => {
+				await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+			} );

 			const { formatError } = mockBaseUtils;
 			const props = renderer.root.findByType( 'div' ).props;
@@ -182,4 +198,190 @@ describe( 'withProductVariations Component', () => {
 			expect( props[ 'data-variations' ] ).toEqual( { 1: null } );
 		} );
 	} );
+
+	describe( 'when a product has more than 25 variations', () => {
+		const totalVariations = 60;
+		const mockManyVariations = Array.from(
+			{ length: totalVariations },
+			( _, i ) => ( {
+				id: i + 1,
+				name: `Variation ${ i + 1 }`,
+			} )
+		);
+
+		const productWithManyVariations = [
+			{
+				id: 1,
+				name: 'Hoodie',
+				variations: mockManyVariations.map( ( v ) => ( { id: v.id } ) ),
+			},
+		];
+
+		beforeEach( () => {
+			mockUtils.getProductVariationsWithTotal.mockImplementation(
+				( productId, { offset = 0 } ) => {
+					const start = offset;
+					const end = Math.min( start + 25, totalVariations );
+					const variations = mockManyVariations.slice( start, end );
+
+					return Promise.resolve( {
+						variations,
+						total: totalVariations,
+					} );
+				}
+			);
+		} );
+
+		it( 'loads the first 25 variations by default and provides onLoadMoreVariations', async () => {
+			renderer = TestRenderer.create(
+				<TestComponent
+					error={ null }
+					isLoading={ false }
+					products={ productWithManyVariations }
+					selected={ [ 1 ] }
+					showVariations={ true }
+				/>
+			);
+
+			await TestRenderer.act( async () => {
+				await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+			} );
+
+			const props = renderer.root.findByType( 'div' ).props;
+			const { getProductVariationsWithTotal } = mockUtils;
+
+			// Should have been called once with offset 0
+			expect( getProductVariationsWithTotal ).toHaveBeenCalledWith( 1, {
+				offset: 0,
+			} );
+			expect( getProductVariationsWithTotal ).toHaveBeenCalledTimes( 1 );
+
+			// Should have first 25 variations
+			expect( props[ 'data-variations' ][ 1 ] ).toHaveLength( 25 );
+			expect( props[ 'data-variations' ][ 1 ][ 0 ] ).toEqual( {
+				id: 1,
+				name: 'Variation 1',
+				parent: 1,
+			} );
+			expect( props[ 'data-variations' ][ 1 ][ 24 ] ).toEqual( {
+				id: 25,
+				name: 'Variation 25',
+				parent: 1,
+			} );
+
+			// Should have total variations count
+			expect( props[ 'data-totalVariations' ][ 1 ] ).toBe(
+				totalVariations
+			);
+
+			// Should provide onLoadMoreVariations function
+			expect( typeof props[ 'data-onLoadMoreVariations' ] ).toBe(
+				'function'
+			);
+		} );
+
+		it( 'loads the next 25 variations when onLoadMoreVariations is called', async () => {
+			renderer = TestRenderer.create(
+				<TestComponent
+					error={ null }
+					isLoading={ false }
+					products={ productWithManyVariations }
+					selected={ [ 1 ] }
+					showVariations={ true }
+				/>
+			);
+
+			// Wait for initial load
+			await TestRenderer.act( async () => {
+				await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+			} );
+
+			let props = renderer.root.findByType( 'div' ).props;
+
+			// Verify initial 25 variations are loaded
+			expect( props[ 'data-variations' ][ 1 ] ).toHaveLength( 25 );
+
+			// Call onLoadMoreVariations to load next batch
+			await TestRenderer.act( async () => {
+				props[ 'data-onLoadMoreVariations' ]();
+				await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+			} );
+
+			props = renderer.root.findByType( 'div' ).props;
+			const { getProductVariationsWithTotal } = mockUtils;
+
+			// Should have been called again with offset 25
+			expect( getProductVariationsWithTotal ).toHaveBeenCalledWith( 1, {
+				offset: 25,
+			} );
+			expect( getProductVariationsWithTotal ).toHaveBeenCalledTimes( 2 );
+
+			// Should now have 50 variations (25 + 25)
+			expect( props[ 'data-variations' ][ 1 ] ).toHaveLength( 50 );
+			expect( props[ 'data-variations' ][ 1 ][ 25 ] ).toEqual( {
+				id: 26,
+				name: 'Variation 26',
+				parent: 1,
+			} );
+			expect( props[ 'data-variations' ][ 1 ][ 49 ] ).toEqual( {
+				id: 50,
+				name: 'Variation 50',
+				parent: 1,
+			} );
+		} );
+
+		it( 'loads all variations when onLoadMoreVariations is called multiple times', async () => {
+			renderer = TestRenderer.create(
+				<TestComponent
+					error={ null }
+					isLoading={ false }
+					products={ productWithManyVariations }
+					selected={ [ 1 ] }
+					showVariations={ true }
+				/>
+			);
+
+			// Wait for initial load
+			await TestRenderer.act( async () => {
+				await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+			} );
+
+			let props = renderer.root.findByType( 'div' ).props;
+
+			// Load second batch
+			await TestRenderer.act( async () => {
+				props[ 'data-onLoadMoreVariations' ]();
+				await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+			} );
+
+			props = renderer.root.findByType( 'div' ).props;
+
+			// Load third batch (final 10 variations)
+			await TestRenderer.act( async () => {
+				props[ 'data-onLoadMoreVariations' ]();
+				await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+			} );
+
+			props = renderer.root.findByType( 'div' ).props;
+			const { getProductVariationsWithTotal } = mockUtils;
+
+			// Should have been called 3 times total
+			expect( getProductVariationsWithTotal ).toHaveBeenCalledTimes( 3 );
+			expect( getProductVariationsWithTotal ).toHaveBeenNthCalledWith(
+				3,
+				1,
+				{
+					offset: 50,
+				}
+			);
+
+			// Should now have all 60 variations
+			expect( props[ 'data-variations' ][ 1 ] ).toHaveLength( 60 );
+			expect( props[ 'data-variations' ][ 1 ][ 59 ] ).toEqual( {
+				id: 60,
+				name: 'Variation 60',
+				parent: 1,
+			} );
+		} );
+	} );
 } );
diff --git a/plugins/woocommerce/client/blocks/assets/js/hocs/with-product-variations.tsx b/plugins/woocommerce/client/blocks/assets/js/hocs/with-product-variations.tsx
index 918591eace..22d25f83cb 100644
--- a/plugins/woocommerce/client/blocks/assets/js/hocs/with-product-variations.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/hocs/with-product-variations.tsx
@@ -4,7 +4,7 @@
 import { Component } from '@wordpress/element';
 import { createHigherOrderComponent } from '@wordpress/compose';
 import isShallowEqual from '@wordpress/is-shallow-equal';
-import { getProductVariations } from '@woocommerce/editor-components/utils';
+import { getProductVariationsWithTotal } from '@woocommerce/editor-components/utils';
 import { ErrorObject } from '@woocommerce/editor-components/error-placeholder';
 import {
 	ProductResponseItem,
@@ -27,7 +27,8 @@ interface WithProductVariationsProps {
 interface State {
 	error: ErrorObject | null;
 	loading: boolean;
-	variations: { [ key: string ]: ProductResponseVariationsItem[] | null };
+	variations: { [ key: number ]: ProductResponseVariationsItem[] | null };
+	totalVariations: { [ key: number ]: number | null };
 }

 /**
@@ -47,6 +48,7 @@ const withProductVariations = createHigherOrderComponent(
 				error: null,
 				loading: false,
 				variations: {},
+				totalVariations: {},
 			};

 			private prevSelectedItem?: number;
@@ -71,9 +73,9 @@ const withProductVariations = createHigherOrderComponent(
 				}
 			}

-			loadVariations = () => {
+			loadVariations = ( { offset = 0 }: { offset?: number } = {} ) => {
 				const { products } = this.props;
-				const { loading, variations } = this.state;
+				const { loading, variations, totalVariations } = this.state;

 				if ( loading ) {
 					return;
@@ -81,7 +83,20 @@ const withProductVariations = createHigherOrderComponent(

 				const expandedProduct = this.getExpandedProduct();

-				if ( ! expandedProduct || variations[ expandedProduct ] ) {
+				if ( ! expandedProduct ) {
+					return;
+				}
+
+				if ( ! offset && variations?.[ expandedProduct ] ) {
+					return;
+				}
+
+				if (
+					variations?.[ expandedProduct ] &&
+					totalVariations?.[ expandedProduct ] &&
+					variations[ expandedProduct ].length >=
+						totalVariations[ expandedProduct ]
+				) {
 					return;
 				}

@@ -106,13 +121,19 @@ const withProductVariations = createHigherOrderComponent(

 				this.setState( { loading: true } );

+				const alreadyLoadedVariations =
+					this.state.variations[ expandedProduct ] || [];
+
 				(
-					getProductVariations( expandedProduct ) as Promise<
-						ProductResponseVariationsItem[]
-					>
+					getProductVariationsWithTotal( expandedProduct, {
+						offset,
+					} ) as Promise< {
+						variations: ProductResponseVariationsItem[];
+						total: number;
+					} >
 				 )
-					.then( ( expandedProductVariations ) => {
-						const newVariations = expandedProductVariations.map(
+					.then( ( { variations: variationsData, total } ) => {
+						const newVariations = variationsData.map(
 							( variation ) => ( {
 								...variation,
 								parent: expandedProduct,
@@ -121,7 +142,14 @@ const withProductVariations = createHigherOrderComponent(
 						this.setState( {
 							variations: {
 								...this.state.variations,
-								[ expandedProduct ]: newVariations,
+								[ expandedProduct ]: [
+									...alreadyLoadedVariations,
+									...newVariations,
+								],
+							},
+							totalVariations: {
+								...this.state.totalVariations,
+								[ expandedProduct ]: total,
 							},
 							loading: false,
 							error: null,
@@ -135,6 +163,10 @@ const withProductVariations = createHigherOrderComponent(
 								...this.state.variations,
 								[ expandedProduct ]: null,
 							},
+							totalVariations: {
+								...this.state.totalVariations,
+								[ expandedProduct ]: null,
+							},
 							loading: false,
 							error,
 						} );
@@ -190,7 +222,12 @@ const withProductVariations = createHigherOrderComponent(

 			render() {
 				const { error: propsError, isLoading } = this.props;
-				const { error, loading, variations } = this.state;
+				const { error, loading, variations, totalVariations } =
+					this.state;
+				const expandedProduct = this.getExpandedProduct();
+				const offset = expandedProduct
+					? variations[ expandedProduct ]?.length || 0
+					: 0;

 				return (
 					// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -198,8 +235,14 @@ const withProductVariations = createHigherOrderComponent(
 					<OriginalComponent
 						{ ...this.props }
 						error={ error || propsError }
+						onLoadMoreVariations={ () =>
+							this.loadVariations( {
+								offset,
+							} )
+						}
 						expandedProduct={ this.getExpandedProduct() }
 						isLoading={ isLoading }
+						totalVariations={ totalVariations }
 						variations={ variations }
 						variationsLoading={ loading }
 					/>
diff --git a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/hocs.ts b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/hocs.ts
index 1518bc9b74..56fe7d5065 100644
--- a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/hocs.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/hocs.ts
@@ -15,6 +15,8 @@ export interface WithInjectedProductVariations {
 	expandedProduct: number | null;
 	variations: Record< number, ProductResponseItem[] >;
 	variationsLoading: boolean;
+	onLoadMoreVariations?: () => void;
+	totalVariations?: Record< number, number | null >;
 }

 export interface WithInjectedSearchedProducts {