Commit b53bea4fd7e for woocommerce

commit b53bea4fd7ed21784e9b47ac230c6ba536c91c36
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Fri May 8 11:54:36 2026 +0200

    Fix variation edits in products app (#64688)

    * Update variation

    * lint code

    * improve variation save logic

    * improve logic

    * simplify logic

diff --git a/packages/js/experimental-products-app/changelog/fix-edit-variations b/packages/js/experimental-products-app/changelog/fix-edit-variations
new file mode 100644
index 00000000000..f57e5b7c679
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/fix-edit-variations
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Update variations through the variation endpoint in the experimental products app
diff --git a/packages/js/experimental-products-app/src/product-edit/index.tsx b/packages/js/experimental-products-app/src/product-edit/index.tsx
index b4289708952..a534d05e5d7 100644
--- a/packages/js/experimental-products-app/src/product-edit/index.tsx
+++ b/packages/js/experimental-products-app/src/product-edit/index.tsx
@@ -3,7 +3,7 @@
  */
 import { Button, Spinner } from '@wordpress/components';
 import { store as coreStore } from '@wordpress/core-data';
-import { useDispatch, useSelect } from '@wordpress/data';
+import { select as wpSelect, useDispatch, useSelect } from '@wordpress/data';
 import { DataForm } from '@wordpress/dataviews';
 import { useCallback, useEffect, useState } from '@wordpress/element';
 import { __, sprintf } from '@wordpress/i18n';
@@ -23,9 +23,13 @@ import type { ProductEntityRecord } from '../fields/types';
 import { unlock } from '../lock-unlock';
 import {
 	buildMergedProductEditData,
+	findProductInList,
+	getProductWithUpdatedVariation,
 	getProductEditFields,
 	getVisibleProductEditFields,
+	isProductVariation,
 } from './utils';
+import { saveSelectedProducts } from './save';

 const { useHistory, useLocation } = unlock( routerPrivateApis );

@@ -35,6 +39,10 @@ type ProductEditFormProps = {
 	selectedProducts: ProductEntityRecord[];
 };

+type ProductEditProps = {
+	products: ProductEntityRecord[];
+};
+
 function getSaveNoticeMessage( successCount: number, failedCount: number ) {
 	if ( failedCount === 0 ) {
 		if ( successCount === 1 ) {
@@ -100,7 +108,7 @@ function ProductEditForm( {
 	);
 }

-export default function ProductEdit() {
+export default function ProductEdit( { products }: ProductEditProps ) {
 	const { navigate } = useHistory();
 	const { path, query = {} } = useLocation();
 	const requestedProductIdsFromRoute = getSelectionFromPostId( query.postId )
@@ -115,7 +123,6 @@ export default function ProductEdit() {
 	const editableFields = getProductEditFields( productFields );
 	const {
 		selectedProducts,
-		selectedProductIds,
 		isResolving,
 		hasResolved,
 		hasMissingProducts,
@@ -125,7 +132,6 @@ export default function ProductEdit() {
 			if ( requestedProductIds.length === 0 ) {
 				return {
 					selectedProducts: [],
-					selectedProductIds: [],
 					isResolving: false,
 					hasResolved: true,
 					hasMissingProducts: false,
@@ -136,37 +142,73 @@ export default function ProductEdit() {
 			const coreSelect = select( coreStore );
 			const productResults = requestedProductIds.map( ( productId ) => {
 				const resolutionArgs = [ 'root', 'product', productId ];
+				const rootRecord = coreSelect.getEditedEntityRecord(
+					'root',
+					'product',
+					productId
+				) as unknown as ProductEntityRecord | false | undefined;
+				const listedProduct = findProductInList( products, productId );
+				const product =
+					listedProduct ??
+					( rootRecord !== false ? rootRecord : undefined );
+				let record: ProductEntityRecord | false | undefined =
+					product ?? rootRecord;
+
+				if (
+					product &&
+					isProductVariation( product ) &&
+					product.parent_id
+				) {
+					const parentProduct = coreSelect.getEditedEntityRecord(
+						'root',
+						'product',
+						product.parent_id
+					) as unknown as ProductEntityRecord | false | undefined;
+					const editedParentProduct =
+						parentProduct !== false ? parentProduct : undefined;
+					const editedVariation =
+						editedParentProduct?._embedded?.variations?.find(
+							( variation ) => variation.id === product.id
+						);
+
+					record = editedVariation || product;
+				}

 				return {
 					productId,
-					record: coreSelect.getEditedEntityRecord(
-						'root',
-						'product',
-						productId
-					) as unknown as ProductEntityRecord | false | undefined,
-					isResolving: coreSelect.isResolving(
-						'getEditedEntityRecord',
-						resolutionArgs
-					),
-					hasFinishedResolution: coreSelect.hasFinishedResolution(
-						'getEditedEntityRecord',
-						resolutionArgs
-					),
+					record,
+					isResolving: listedProduct
+						? false
+						: coreSelect.isResolving(
+								'getEditedEntityRecord',
+								resolutionArgs
+						  ),
+					hasFinishedResolution: listedProduct
+						? true
+						: coreSelect.hasFinishedResolution(
+								'getEditedEntityRecord',
+								resolutionArgs
+						  ),
 				};
 			} );
-			const products = productResults
+			const resolvedProducts = productResults
 				.map( ( { record } ) => record )
 				.filter(
 					( product ): product is ProductEntityRecord =>
 						product !== undefined && product !== false
 				);
-			const validSelectedProductIds = products.map(
-				( product ) => product.id
+			const editedProductIds = Array.from(
+				new Set(
+					resolvedProducts.map( ( product ) =>
+						isProductVariation( product ) && product.parent_id
+							? product.parent_id
+							: product.id
+					)
+				)
 			);

 			return {
-				selectedProducts: products,
-				selectedProductIds: validSelectedProductIds,
+				selectedProducts: resolvedProducts,
 				isResolving: productResults.some(
 					( result ) =>
 						result.isResolving || ! result.hasFinishedResolution
@@ -178,7 +220,7 @@ export default function ProductEdit() {
 					( result ) =>
 						result.hasFinishedResolution && result.record === false
 				),
-				hasEdits: validSelectedProductIds.some( ( productId ) =>
+				hasEdits: editedProductIds.some( ( productId ) =>
 					coreSelect.hasEditsForEntityRecord(
 						'root',
 						'product',
@@ -187,10 +229,10 @@ export default function ProductEdit() {
 				),
 			};
 		},
-		[ requestedProductIds ]
+		[ products, requestedProductIds ]
 	);

-	const { editEntityRecord, saveEditedEntityRecord } =
+	const { clearEntityRecordEdits, editEntityRecord, saveEditedEntityRecord } =
 		useDispatch( coreStore );

 	const { createSuccessNotice, createErrorNotice } =
@@ -220,38 +262,86 @@ export default function ProductEdit() {

 	const onChange = useCallback(
 		( changes: Partial< ProductEntityRecord > ) => {
-			selectedProductIds.forEach( ( productId ) => {
-				editEntityRecord( 'root', 'product', productId, changes );
+			const updatedParentProductsById = new Map<
+				number,
+				ProductEntityRecord
+			>();
+
+			selectedProducts.forEach( ( product ) => {
+				if ( ! isProductVariation( product ) ) {
+					editEntityRecord( 'root', 'product', product.id, changes );
+					return;
+				}
+
+				if ( ! product.parent_id ) {
+					return;
+				}
+
+				const parentProduct =
+					updatedParentProductsById.get( product.parent_id ) ??
+					( wpSelect( coreStore ).getEditedEntityRecord(
+						'root',
+						'product',
+						product.parent_id
+					) as ProductEntityRecord | false | undefined );
+
+				if ( ! parentProduct ) {
+					return;
+				}
+
+				updatedParentProductsById.set(
+					product.parent_id,
+					getProductWithUpdatedVariation( parentProduct, {
+						...product,
+						...changes,
+					} )
+				);
+			} );
+
+			updatedParentProductsById.forEach( ( parentProduct ) => {
+				editEntityRecord( 'root', 'product', parentProduct.id, {
+					_embedded: parentProduct._embedded,
+				} );
 			} );
 		},
-		[ editEntityRecord, selectedProductIds ]
+		[ editEntityRecord, selectedProducts ]
 	);

 	const closeDrawer = useCallback( () => {
+		const editedProductIds = new Set(
+			selectedProducts.map( ( product ) =>
+				isProductVariation( product ) && product.parent_id
+					? product.parent_id
+					: product.id
+			)
+		);
 		const nextQuery = {
 			...query,
 		} as Record< string, string >;

+		editedProductIds.forEach( ( productId ) => {
+			clearEntityRecordEdits( 'root', 'product', productId );
+		} );
+
 		delete nextQuery.quickEdit;

 		navigate( getProductListNavigationPath( path, nextQuery ) );
-	}, [ navigate, path, query ] );
+	}, [ clearEntityRecordEdits, navigate, path, query, selectedProducts ] );

 	const onSave = useCallback( async () => {
-		if ( selectedProductIds.length === 0 || isSaving ) {
+		if ( selectedProducts.length === 0 || isSaving ) {
 			return;
 		}

 		setIsSaving( true );

 		try {
-			const results = await Promise.allSettled(
-				selectedProductIds.map( ( productId ) =>
-					saveEditedEntityRecord( 'root', 'product', productId, {
-						throwOnError: true,
-					} )
-				)
-			);
+			const results = await saveSelectedProducts( {
+				selectedProducts,
+				editEntityRecord,
+				saveEditedEntityRecord,
+			} );
+
 			const successfulCount = results.filter(
 				( result ) => result.status === 'fulfilled'
 			).length;
@@ -279,9 +369,10 @@ export default function ProductEdit() {
 	}, [
 		createErrorNotice,
 		createSuccessNotice,
+		editEntityRecord,
 		isSaving,
 		saveEditedEntityRecord,
-		selectedProductIds,
+		selectedProducts,
 	] );

 	useEffect( () => {
diff --git a/packages/js/experimental-products-app/src/product-edit/save.test.ts b/packages/js/experimental-products-app/src/product-edit/save.test.ts
new file mode 100644
index 00000000000..085e99782c8
--- /dev/null
+++ b/packages/js/experimental-products-app/src/product-edit/save.test.ts
@@ -0,0 +1,157 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../fields/types';
+import { saveSelectedProducts } from './save';
+
+const mockGetEditedEntityRecord = jest.fn();
+const mockGetEntityRecord = jest.fn();
+
+jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+
+jest.mock( '@wordpress/core-data', () => ( {
+	store: {},
+} ) );
+
+jest.mock( '@wordpress/data', () => ( {
+	select: jest.fn( () => ( {
+		getEditedEntityRecord: mockGetEditedEntityRecord,
+		getEntityRecord: mockGetEntityRecord,
+	} ) ),
+} ) );
+
+describe( 'saveSelectedProducts', () => {
+	const buildProduct = (
+		overrides: Partial< ProductEntityRecord > = {}
+	): ProductEntityRecord =>
+		( {
+			id: 10,
+			name: 'Hoodie',
+			status: 'draft',
+			type: 'simple',
+			virtual: false,
+			downloadable: false,
+			on_sale: false,
+			categories: [],
+			tags: [],
+			images: [],
+			...overrides,
+		} as unknown as ProductEntityRecord );
+
+	const buildVariation = (
+		overrides: Partial< ProductEntityRecord > = {}
+	): ProductEntityRecord =>
+		buildProduct( {
+			id: 100,
+			parent_id: 10,
+			name: 'Blue',
+			type: 'variation',
+			...overrides,
+		} );
+
+	beforeEach( () => {
+		jest.clearAllMocks();
+	} );
+
+	it( 'keeps edits for selected variations that failed after another variation saved', async () => {
+		const originalSavedVariation = buildVariation( {
+			id: 101,
+			name: 'Blue original',
+		} );
+		const originalFailedVariation = buildVariation( {
+			id: 102,
+			name: 'Green original',
+		} );
+		const originalUnselectedVariation = buildVariation( {
+			id: 103,
+			name: 'Red original',
+		} );
+		const editedSavedVariation = {
+			...originalSavedVariation,
+			name: 'Blue edited',
+		};
+		const editedFailedVariation = {
+			...originalFailedVariation,
+			name: 'Green edited',
+		};
+		const editedParent = buildProduct( {
+			id: 10,
+			type: 'variable',
+			_embedded: {
+				variations: [
+					editedSavedVariation,
+					editedFailedVariation,
+					originalUnselectedVariation,
+				],
+			},
+		} );
+		const saveError = new Error( 'Variation save failed.' );
+		const editEntityRecord = jest.fn(
+			(
+				_kind,
+				_name,
+				_recordId,
+				edits: Partial< ProductEntityRecord >
+			) => {
+				Object.assign( editedParent, edits );
+			}
+		);
+		const saveEditedEntityRecord = jest.fn( async () => editedParent );
+
+		mockGetEditedEntityRecord.mockImplementation( ( _kind, _name, id ) =>
+			id === editedParent.id ? editedParent : undefined
+		);
+		mockGetEntityRecord.mockReturnValue( undefined );
+		( apiFetch as unknown as jest.Mock )
+			.mockResolvedValueOnce( {
+				id: 101,
+				parent_id: 10,
+				name: 'Blue saved',
+				manage_stock: false,
+			} )
+			.mockRejectedValueOnce( saveError );
+
+		const results = await saveSelectedProducts( {
+			selectedProducts: [ editedSavedVariation, editedFailedVariation ],
+			editEntityRecord,
+			saveEditedEntityRecord,
+		} );
+
+		expect( saveEditedEntityRecord ).toHaveBeenCalledWith(
+			'root',
+			'product',
+			10,
+			{
+				throwOnError: true,
+			}
+		);
+		expect( editedParent._embedded?.variations ).toEqual( [
+			expect.objectContaining( {
+				id: 101,
+				name: 'Blue saved',
+			} ),
+			expect.objectContaining( {
+				id: 102,
+				name: 'Green edited',
+			} ),
+			expect.objectContaining( {
+				id: 103,
+				name: 'Red original',
+			} ),
+		] );
+		expect( results ).toEqual( [
+			expect.objectContaining( {
+				status: 'fulfilled',
+			} ),
+			{
+				status: 'rejected',
+				reason: saveError,
+			},
+		] );
+	} );
+} );
diff --git a/packages/js/experimental-products-app/src/product-edit/save.ts b/packages/js/experimental-products-app/src/product-edit/save.ts
new file mode 100644
index 00000000000..68053e9c1c6
--- /dev/null
+++ b/packages/js/experimental-products-app/src/product-edit/save.ts
@@ -0,0 +1,204 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+import { store as coreStore } from '@wordpress/core-data';
+import { select } from '@wordpress/data';
+import type { ProductVariation } from '@woocommerce/data';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../fields/types';
+import { normalizeVariation } from '../variation-view/normalization';
+import {
+	getProductWithUpdatedVariation,
+	getProductVariationUpdatePath,
+	isProductVariation,
+} from './utils';
+
+type ProductVariationEntityRecord = ProductEntityRecord & {
+	parent_id: number;
+};
+
+type ProductSaveResult = PromiseSettledResult<
+	ProductEntityRecord | ProductVariation
+>;
+
+type EditProductRecord = (
+	kind: 'root',
+	name: 'product',
+	recordId: number,
+	edits: Partial< ProductEntityRecord >,
+	options?: { undoIgnore?: boolean }
+) => void;
+
+type SaveEditedProductRecord = (
+	kind: 'root',
+	name: 'product',
+	recordId: number,
+	options: { throwOnError: true }
+) => Promise< ProductEntityRecord >;
+
+function getEditedProduct( productId: number ) {
+	const product = select( coreStore ).getEditedEntityRecord(
+		'root',
+		'product',
+		productId
+	) as ProductEntityRecord | false | undefined;
+
+	return product !== false ? product : undefined;
+}
+
+async function saveVariation(
+	product: ProductVariationEntityRecord,
+	editEntityRecord: EditProductRecord
+) {
+	const parentProduct = getEditedProduct( product.parent_id );
+	const editedVariation =
+		parentProduct?._embedded?.variations?.find(
+			( variation ) => variation.id === product.id
+		) ?? product;
+	const savedVariation = await apiFetch< ProductVariation >( {
+		path: getProductVariationUpdatePath( product ),
+		method: 'PUT',
+		data: editedVariation,
+	} );
+
+	if ( parentProduct ) {
+		const updatedParentProduct = getProductWithUpdatedVariation(
+			parentProduct,
+			normalizeVariation(
+				savedVariation
+			) as unknown as ProductEntityRecord
+		);
+
+		editEntityRecord(
+			'root',
+			'product',
+			parentProduct.id,
+			{
+				_embedded: updatedParentProduct._embedded,
+			},
+			{
+				undoIgnore: true,
+			}
+		);
+	}
+
+	return savedVariation;
+}
+
+async function saveVariationsSequentially(
+	selectedVariations: ProductVariationEntityRecord[],
+	editEntityRecord: EditProductRecord
+) {
+	const variationResults: ProductSaveResult[] = [];
+
+	for ( const product of selectedVariations ) {
+		try {
+			// Save variations one at a time because saveVariation merges each
+			// saved variation into the parent product's current embedded
+			// variations. Concurrent saves can merge against stale parent
+			// snapshots and overwrite another variation's just-saved update.
+			variationResults.push( {
+				status: 'fulfilled',
+				value: await saveVariation( product, editEntityRecord ),
+			} );
+		} catch ( error ) {
+			variationResults.push( {
+				status: 'rejected',
+				reason: error,
+			} );
+		}
+	}
+
+	return variationResults;
+}
+
+function getSelectedProductSaveResults(
+	selectedProducts: ProductEntityRecord[],
+	selectedVariations: ProductVariationEntityRecord[],
+	variationResults: ProductSaveResult[],
+	productSaveIds: number[],
+	productSaveResults: ProductSaveResult[]
+) {
+	const missingSaveResult: PromiseRejectedResult = {
+		status: 'rejected',
+		reason: new Error( 'Product save result is missing.' ),
+	};
+	const productSaveResultsById = new Map(
+		productSaveIds.map( ( productId, index ) => [
+			productId,
+			productSaveResults[ index ] ?? missingSaveResult,
+		] )
+	);
+	const variationResultsById = new Map(
+		selectedVariations.map( ( product, index ) => [
+			product.id,
+			variationResults[ index ] ?? missingSaveResult,
+		] )
+	);
+
+	return selectedProducts.map( ( product ) => {
+		if ( ! isProductVariation( product ) ) {
+			return (
+				productSaveResultsById.get( product.id ) ?? missingSaveResult
+			);
+		}
+
+		const variationResult = variationResultsById.get( product.id );
+
+		if ( variationResult?.status === 'rejected' ) {
+			return variationResult;
+		}
+
+		return (
+			productSaveResultsById.get( product.parent_id ) ?? missingSaveResult
+		);
+	} );
+}
+
+export async function saveSelectedProducts( {
+	selectedProducts,
+	editEntityRecord,
+	saveEditedEntityRecord,
+}: {
+	selectedProducts: ProductEntityRecord[];
+	editEntityRecord: EditProductRecord;
+	saveEditedEntityRecord: SaveEditedProductRecord;
+} ) {
+	const selectedVariations = selectedProducts.filter( isProductVariation );
+	const productIdsToSave = new Set(
+		selectedProducts
+			.filter( ( product ) => ! isProductVariation( product ) )
+			.map( ( product ) => product.id )
+	);
+	const variationResults = await saveVariationsSequentially(
+		selectedVariations,
+		editEntityRecord
+	);
+
+	variationResults.forEach( ( result, index ) => {
+		if ( result.status === 'fulfilled' ) {
+			productIdsToSave.add( selectedVariations[ index ].parent_id );
+		}
+	} );
+
+	const productSaveIds = Array.from( productIdsToSave );
+	const productSaveResults = await Promise.allSettled(
+		productSaveIds.map( ( productId ) =>
+			saveEditedEntityRecord( 'root', 'product', productId, {
+				throwOnError: true,
+			} )
+		)
+	);
+
+	return getSelectedProductSaveResults(
+		selectedProducts,
+		selectedVariations,
+		variationResults,
+		productSaveIds,
+		productSaveResults
+	);
+}
diff --git a/packages/js/experimental-products-app/src/product-edit/utils.test.ts b/packages/js/experimental-products-app/src/product-edit/utils.test.ts
index d3233b03270..dbc66826061 100644
--- a/packages/js/experimental-products-app/src/product-edit/utils.test.ts
+++ b/packages/js/experimental-products-app/src/product-edit/utils.test.ts
@@ -11,8 +11,11 @@ import { productFields } from '../product-list/fields';
 import {
 	buildMergedProductEditData,
 	EXCLUDED_PRODUCT_EDIT_FIELD_IDS,
+	getProductWithUpdatedVariation,
 	getProductEditFields,
+	getProductVariationUpdatePath,
 	getVisibleProductEditFields,
+	isProductVariation,
 } from './utils';

 jest.mock( '@woocommerce/settings', () => ( {
@@ -123,6 +126,76 @@ describe( 'product edit utils', () => {
 		);
 	} );

+	it( 'identifies variations and builds their update endpoint path', () => {
+		const variation = buildProduct( {
+			id: 34,
+			parent_id: 12,
+			type: 'variation',
+		} );
+
+		expect( isProductVariation( variation ) ).toBe( true );
+
+		if ( isProductVariation( variation ) ) {
+			expect( getProductVariationUpdatePath( variation ) ).toBe(
+				'/wc/v3/products/12/variations/34'
+			);
+		}
+
+		expect(
+			isProductVariation( buildProduct( { id: 12, parent_id: 0 } ) )
+		).toBe( false );
+		expect(
+			isProductVariation(
+				buildProduct( {
+					id: 34,
+					parent_id: 0,
+					type: 'variation',
+				} )
+			)
+		).toBe( true );
+		const orphanVariation = buildProduct( {
+			id: 34,
+			parent_id: 0,
+			type: 'variation',
+		} );
+
+		if ( isProductVariation( orphanVariation ) ) {
+			expect( () =>
+				getProductVariationUpdatePath( orphanVariation )
+			).toThrow( 'Variation parent ID is required' );
+		}
+	} );
+
+	it( 'updates an embedded variation in a product record', () => {
+		const variation = buildProduct( {
+			id: 34,
+			parent_id: 12,
+			name: 'Blue',
+			type: 'variation',
+		} );
+		const updatedVariation = {
+			...variation,
+			name: 'Green',
+		};
+		const parent = buildProduct( {
+			id: 12,
+			_embedded: {
+				variations: [ variation ],
+			},
+		} );
+
+		expect(
+			getProductWithUpdatedVariation( parent, updatedVariation )
+		).toEqual(
+			expect.objectContaining( {
+				id: 12,
+				_embedded: {
+					variations: [ updatedVariation ],
+				},
+			} )
+		);
+	} );
+
 	describe( 'getVisibleProductEditFields', () => {
 		const getVisibleFieldIds = ( products: ProductEntityRecord[] ) =>
 			getVisibleProductEditFields(
diff --git a/packages/js/experimental-products-app/src/product-edit/utils.ts b/packages/js/experimental-products-app/src/product-edit/utils.ts
index 19f58f3f2f1..dc1a4daffd7 100644
--- a/packages/js/experimental-products-app/src/product-edit/utils.ts
+++ b/packages/js/experimental-products-app/src/product-edit/utils.ts
@@ -25,6 +25,9 @@ const EXCLUDED_PRODUCT_EDIT_FIELD_ID_SET = new Set(

 type ProductField = Field< ProductEntityRecord >;
 type ProductEditFieldId = ( typeof PRODUCT_EDIT_FIELD_IDS )[ number ];
+type ProductVariationEntityRecord = ProductEntityRecord & {
+	parent_id: number;
+};

 const PRODUCT_EDIT_FIELD_IDS = [
 	'name',
@@ -185,6 +188,67 @@ function getProductTypeCompatibleFieldIds( product: ProductEntityRecord ) {
 	return COMMON_PRODUCT_EDIT_FIELD_IDS;
 }

+export function isProductVariation(
+	product: ProductEntityRecord
+): product is ProductVariationEntityRecord {
+	return product.type === 'variation' || Boolean( product.parent_id );
+}
+
+export function getProductVariationUpdatePath(
+	product: ProductVariationEntityRecord
+) {
+	if ( ! product.parent_id ) {
+		throw new Error(
+			'Variation parent ID is required to update a variation.'
+		);
+	}
+
+	return `/wc/v3/products/${ product.parent_id }/variations/${ product.id }`;
+}
+
+export function getProductWithUpdatedVariation(
+	product: ProductEntityRecord,
+	variation: ProductEntityRecord
+): ProductEntityRecord {
+	const embeddedVariations = product._embedded?.variations ?? [];
+	const hasEmbeddedVariation = embeddedVariations.some(
+		( embeddedVariation ) => embeddedVariation.id === variation.id
+	);
+
+	return {
+		...product,
+		_embedded: {
+			...product._embedded,
+			variations: hasEmbeddedVariation
+				? embeddedVariations.map( ( embeddedVariation ) =>
+						embeddedVariation.id === variation.id
+							? variation
+							: embeddedVariation
+				  )
+				: [ ...embeddedVariations, variation ],
+		},
+	};
+}
+
+export function findProductInList(
+	products: ProductEntityRecord[],
+	productId: number
+) {
+	for ( const product of products ) {
+		if ( product.id === productId ) {
+			return product;
+		}
+
+		const variation = product._embedded?.variations?.find(
+			( embeddedVariation ) => embeddedVariation.id === productId
+		);
+
+		if ( variation ) {
+			return variation;
+		}
+	}
+}
+
 function getCommonProductTypeCompatibleFieldIds(
 	products: ProductEntityRecord[]
 ) {
diff --git a/packages/js/experimental-products-app/src/product-list/index.tsx b/packages/js/experimental-products-app/src/product-list/index.tsx
index 92ad56435ac..1b94e33492f 100644
--- a/packages/js/experimental-products-app/src/product-list/index.tsx
+++ b/packages/js/experimental-products-app/src/product-list/index.tsx
@@ -21,20 +21,18 @@ import { unlock } from '../lock-unlock';
 import type { ProductEntityRecord } from '../fields/types';
 import {
 	DEFAULT_LAYOUTS,
-	DEFAULT_VIEW,
 	EMPTY_ARRAY,
 	PAGE_SIZE,
 	PRODUCT_LIST_TABS,
+	type StatusTab,
 } from './constants';
 import { productFields } from './fields';
-import { buildProductListQuery } from './query';
 import {
 	getItemId,
 	getProductListNavigationPath,
 	getProductListTab,
 	getProductsWithEmbeddedVariations,
 	getSelectionFromPostId,
-	getStatusForProductListTab,
 	isProductEditorAccessible,
 } from './utils';
 import { useProductActions } from '../dataviews-actions';
@@ -47,30 +45,28 @@ export type ProductListProps = {
 	className?: string;
 	hideTitleFromUI?: boolean;
 	postType?: string;
+	hasResolved: boolean;
+	isLoading: boolean;
+	records?: ProductEntityRecord[] | null;
+	selectedTab: StatusTab;
+	setSelectedTab: ( selectedTab: StatusTab ) => void;
+	setView: ( view: View ) => void;
+	totalCount?: number | null;
+	view: View;
 };

-/**
- * This function abstracts working with default & custom views by
- * providing a [ state, setState ] tuple based on the URL parameters.
- *
- * Consumers use the provided tuple to work with state
- * and don't have to deal with the specifics of default & custom views.
- *
- * @return {Array} The [ state, setState ] tuple.
- */
-function useView(): [ View, ( view: View ) => void ] {
-	const { query: { activeView = 'all' } = {} } = useLocation();
-	const [ view, setView ] = useState< View >( DEFAULT_VIEW );
-
-	// When activeView URL parameter changes, reset the view.
-	useEffect( () => {
-		setView( DEFAULT_VIEW );
-	}, [ activeView ] );
-
-	return [ view, setView ];
-}
-
-export default function ProductList( { className }: ProductListProps ) {
+export default function ProductList( {
+	className,
+	hasResolved,
+	isLoading,
+	records,
+	selectedTab,
+	setSelectedTab,
+	setView,
+	totalCount,
+	view,
+	postType = 'product',
+}: ProductListProps ) {
 	const { navigate } = useHistory();
 	const location = useLocation();
 	const currentQuery = useMemo(
@@ -82,33 +78,15 @@ export default function ProductList( { className }: ProductListProps ) {
 			},
 		[ location.query ]
 	);
-	const { postId, postType = 'product', activeView = 'all' } = currentQuery;
-	const selectedTabFromLocation = getProductListTab( activeView );
-	const [ selectedTab, setSelectedTab ] = useState( selectedTabFromLocation );
+	const { postId, activeView = 'all' } = currentQuery;
 	const [ selection, setSelection ] = useState( () =>
 		getSelectionFromPostId( postId )
 	);
-	const [ view, setView ] = useView();
-
-	useEffect( () => {
-		setSelectedTab( selectedTabFromLocation );
-	}, [ selectedTabFromLocation ] );

 	useEffect( () => {
 		setSelection( getSelectionFromPostId( postId ) );
 	}, [ postId ] );

-	const queryParams = useMemo( () => {
-		const query = buildProductListQuery( view );
-		const productStatus = getStatusForProductListTab( selectedTab );
-
-		if ( productStatus ) {
-			query.status = productStatus;
-		}
-
-		return query;
-	}, [ selectedTab, view ] );
-
 	const onChangeSelection = useCallback(
 		( items: string[] ) => {
 			setSelection( items );
@@ -154,44 +132,7 @@ export default function ProductList( { className }: ProductListProps ) {
 				getProductListNavigationPath( location.path, nextParams )
 			);
 		},
-		[ currentQuery, navigate, location.path, selectedTab ]
-	);
-
-	const {
-		records,
-		totalItems: totalCount,
-		isResolving: isLoading,
-		hasResolved,
-	} = useSelect(
-		( select ) => {
-			const {
-				getEntityRecords,
-				isResolving,
-				hasFinishedResolution,
-				getEntityRecordsTotalItems,
-			} = select( coreStore );
-			return {
-				records: getEntityRecords< ProductEntityRecord >(
-					'root',
-					'product',
-					queryParams
-				),
-				totalItems: getEntityRecordsTotalItems( 'root', 'product', {
-					...queryParams,
-				} ),
-				isResolving: isResolving( 'getEntityRecords', [
-					'root',
-					'product',
-					queryParams,
-				] ),
-				hasResolved: hasFinishedResolution( 'getEntityRecords', [
-					'root',
-					'product',
-					queryParams,
-				] ),
-			};
-		},
-		[ queryParams ]
+		[ currentQuery, navigate, location.path, selectedTab, setSelectedTab ]
 	);

 	const paginationInfo = useMemo(
diff --git a/packages/js/experimental-products-app/src/router.tsx b/packages/js/experimental-products-app/src/router.tsx
index 2b5dd9fb273..c4d472dcc51 100644
--- a/packages/js/experimental-products-app/src/router.tsx
+++ b/packages/js/experimental-products-app/src/router.tsx
@@ -2,6 +2,10 @@
  * External dependencies
  */
 import { privateApis as routerPrivateApis } from '@wordpress/router';
+import { store as coreStore } from '@wordpress/core-data';
+import { useSelect } from '@wordpress/data';
+import { useEffect, useMemo, useState } from '@wordpress/element';
+import type { View } from '@wordpress/dataviews';

 /**
  * Internal dependencies
@@ -9,6 +13,13 @@ import { privateApis as routerPrivateApis } from '@wordpress/router';
 import { unlock } from './lock-unlock';
 import ProductList from './product-list';
 import ProductEdit from './product-edit';
+import type { ProductEntityRecord } from './fields/types';
+import { DEFAULT_VIEW } from './product-list/constants';
+import { buildProductListQuery } from './product-list/query';
+import {
+	getProductListTab,
+	getStatusForProductListTab,
+} from './product-list/utils';

 const { useLocation } = unlock( routerPrivateApis );

@@ -31,31 +42,98 @@ export type Route = {
 export default function useLayoutAreas() {
 	const { params = {}, query = {} } = useLocation();
 	const postType = params.postType ?? query.postType ?? 'product';
-	const canvas = params.canvas ?? query.canvas;
+	const activeView = query.activeView as string | undefined;
+	const selectedTabFromLocation = getProductListTab( activeView );
+	const [ selectedTab, setSelectedTab ] = useState( selectedTabFromLocation );
+	const [ view, setView ] = useState< View >( DEFAULT_VIEW );
 	const showQuickEdit =
 		params.quickEdit === 'true' ||
 		query.quickEdit === 'true' ||
 		params.quickEdit === true ||
 		query.quickEdit === true;
-	// Products list.
-	if ( [ 'product' ].includes( postType ) ) {
-		return {
-			key: 'products-list',
-			areas: {
-				content: <ProductList />,
-				edit: showQuickEdit ? <ProductEdit /> : undefined,
-				preview: false,
-				mobile: <ProductList postType={ postType } />,
-			},
-		};
-	}
-
-	// Fallback shows the home page preview
+
+	useEffect( () => {
+		setSelectedTab( selectedTabFromLocation );
+	}, [ selectedTabFromLocation ] );
+
+	useEffect( () => {
+		setView( DEFAULT_VIEW );
+	}, [ activeView ] );
+
+	const queryParams = useMemo( () => {
+		const productListQuery = buildProductListQuery( view );
+		const productStatus = getStatusForProductListTab( selectedTab );
+
+		if ( productStatus ) {
+			productListQuery.status = productStatus;
+		}
+
+		return productListQuery;
+	}, [ selectedTab, view ] );
+
+	const {
+		records,
+		totalItems: totalCount,
+		isResolving: isLoading,
+		hasResolved,
+	} = useSelect(
+		( select ) => {
+			const {
+				getEntityRecords,
+				isResolving,
+				hasFinishedResolution,
+				getEntityRecordsTotalItems,
+			} = select( coreStore );
+
+			return {
+				records: getEntityRecords< ProductEntityRecord >(
+					'root',
+					'product',
+					queryParams
+				),
+				totalItems: getEntityRecordsTotalItems( 'root', 'product', {
+					...queryParams,
+				} ),
+				isResolving: isResolving( 'getEntityRecords', [
+					'root',
+					'product',
+					queryParams,
+				] ),
+				hasResolved: hasFinishedResolution( 'getEntityRecords', [
+					'root',
+					'product',
+					queryParams,
+				] ),
+			};
+		},
+		[ queryParams ]
+	);
+
+	const productListProps = {
+		hasResolved,
+		isLoading,
+		records,
+		selectedTab,
+		setSelectedTab,
+		setView,
+		totalCount,
+		view,
+	};
+
 	return {
-		key: 'default',
+		key: 'products-list',
 		areas: {
+			content: <ProductList { ...productListProps } />,
+			edit: showQuickEdit ? (
+				<ProductEdit products={ records ?? [] } />
+			) : undefined,
 			preview: false,
-			mobile: canvas === 'edit',
+			mobile: (
+				<ProductList postType={ postType } { ...productListProps } />
+			),
+		},
+		widths: {
+			edit: showQuickEdit ? 380 : undefined,
 		},
 	};
 }