Commit c26c2a30809 for woocommerce

commit c26c2a3080972d9ac26ff7730a23479f08a92d68
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date:   Wed Oct 21 09:02:58 2026 +0200

    Show one spinner on the clicked product type in the add products task (#69891)

    * Show one spinner on the clicked product type in the add products task

    Clicking a product type used to dim both card groups and show a spinner on
    each, and the busy state was reset right after navigateTo(), so the page
    looked idle while the product editor loaded. Now only the clicked row shows
    a spinner, the other product type rows ignore clicks, the footer cards are
    left alone, and the spinner stays until the browser leaves the page. On
    failure it clears and the error notice shows.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Skip completion time on ignored product type clicks and announce loading

    Record the task completion time through the product types hook, after its
    busy check, so clicks ignored during a request no longer send events. Also
    announce "Creating your product…" to screen readers when a type is chosen.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Clear the add products spinner when the page comes back from bfcache

    Reset the requested product type on a persisted pageshow, so going back
    from the editor never leaves a stuck spinner. Also check the editor URL
    passed to navigateTo, and move the cast comment next to the cast.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/wooprd-3788-single-spinner-add-products b/plugins/woocommerce/changelog/wooprd-3788-single-spinner-add-products
new file mode 100644
index 00000000000..c515ee72d7e
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooprd-3788-single-spinner-add-products
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show a single loading indicator on the selected product type when adding a product from the onboarding task.
diff --git a/plugins/woocommerce/client/admin/client/task-lists/fills/products/index.tsx b/plugins/woocommerce/client/admin/client/task-lists/fills/products/index.tsx
index 5653d30faba..cff8132bbd6 100644
--- a/plugins/woocommerce/client/admin/client/task-lists/fills/products/index.tsx
+++ b/plugins/woocommerce/client/admin/client/task-lists/fills/products/index.tsx
@@ -84,29 +84,18 @@ export const Products = () => {
 		getOnboardingProductType()
 	);

-	const { productTypes, isRequesting } = useProductTypeListItems(
+	const { recordCompletionTime } = useRecordCompletionTime( 'products' );
+	const { productTypes } = useProductTypeListItems(
 		getProductTypes(),
-		surfacedProductTypeKeys
+		surfacedProductTypeKeys,
+		{ onClick: recordCompletionTime }
 	);
-	const { recordCompletionTime } = useRecordCompletionTime( 'products' );

 	const recommendations: ProductRecommendation[] = getAdminSetting(
 		'productsListEmptySuggestions',
 		[]
 	);

-	const productTypesWithTimeRecord = useMemo(
-		() =>
-			productTypes.map( ( productType ) => ( {
-				...productType,
-				onClick: (): void => {
-					productType.onClick();
-					recordCompletionTime();
-				},
-			} ) ),
-		[ recordCompletionTime, productTypes ]
-	);
-
 	const { loadSampleProduct, isLoadingSampleProducts } =
 		useLoadSampleProducts( {
 			redirectUrlAfterSuccess: getAdminLink(
@@ -115,13 +104,12 @@ export const Products = () => {
 		} );

 	const visibleProductTypes = useMemo( () => {
-		const surfacedProductTypes = productTypesWithTimeRecord.filter(
-			( productType ) =>
-				surfacedProductTypeKeys.includes( productType.key )
+		const surfacedProductTypes = productTypes.filter( ( productType ) =>
+			surfacedProductTypeKeys.includes( productType.key )
 		);
 		if ( isExpanded ) {
 			// To show product types in same order, we need to push the other product types to the end.
-			productTypesWithTimeRecord.forEach(
+			productTypes.forEach(
 				( productType ) =>
 					! surfacedProductTypes.includes( productType ) &&
 					surfacedProductTypes.push( productType )
@@ -138,7 +126,7 @@ export const Products = () => {
 			surfacedProductTypes
 		) as typeof surfacedProductTypes;
 		return surfacedProductTypesAndAppendedProducts;
-	}, [ surfacedProductTypeKeys, isExpanded, productTypesWithTimeRecord ] );
+	}, [ surfacedProductTypeKeys, isExpanded, productTypes ] );

 	const footerStack = useMemo( () => {
 		const importCSVItemWithTimeRecord = {
@@ -179,7 +167,6 @@ export const Products = () => {
 						setIsConfirmingLoadSampleProducts( true )
 					}
 					showOtherOptions={ isExpanded }
-					isTaskListItemClicked={ isRequesting }
 				/>
 				<ViewControlButton
 					isExpanded={ isExpanded }
@@ -192,11 +179,7 @@ export const Products = () => {
 						setIsExpanded( ! isExpanded );
 					} }
 				/>
-				<Stack
-					items={ footerStack }
-					showOtherOptions={ false }
-					isTaskListItemClicked={ isRequesting }
-				/>
+				<Stack items={ footerStack } showOtherOptions={ false } />
 				{ Array.isArray( recommendations ) &&
 				recommendations.length > 0 ? (
 					<ProductRecommendations items={ recommendations } />
diff --git a/plugins/woocommerce/client/admin/client/task-lists/fills/products/stack.scss b/plugins/woocommerce/client/admin/client/task-lists/fills/products/stack.scss
index 653c16a40e0..e7006119f82 100644
--- a/plugins/woocommerce/client/admin/client/task-lists/fills/products/stack.scss
+++ b/plugins/woocommerce/client/admin/client/task-lists/fills/products/stack.scss
@@ -1,6 +1,5 @@
 .woocommerce-products-stack {
 	width: 550px;
-	position: relative;

 	@media (max-width: 550px ) {
 		& {
@@ -103,6 +102,10 @@
 		svg {
 			fill: $gray-600;
 		}
+
+		.components-spinner {
+			margin: 4px;
+		}
 	}

 	.woocommerce-stack__other-options {
@@ -112,22 +115,4 @@
 		color: $gray-700;
 		line-height: 16px;
 	}
-
-	.woocommerce-stack__overlay-spinner {
-		position: absolute;
-		top: 0;
-		left: 0;
-		right: 0;
-		bottom: 0;
-		background-color: rgba(255, 255, 255, 0.8);
-		display: flex;
-		justify-content: center;
-		align-items: center;
-		z-index: 999;
-
-		.list-overlay {
-			position: relative;
-		}
-	}
-
 }
diff --git a/plugins/woocommerce/client/admin/client/task-lists/fills/products/stack.tsx b/plugins/woocommerce/client/admin/client/task-lists/fills/products/stack.tsx
index 1c0905d9015..f07ee2dd014 100644
--- a/plugins/woocommerce/client/admin/client/task-lists/fills/products/stack.tsx
+++ b/plugins/woocommerce/client/admin/client/task-lists/fills/products/stack.tsx
@@ -2,7 +2,7 @@
  * External dependencies
  */
 import { __ } from '@wordpress/i18n';
-import { List, Link, Spinner } from '@woocommerce/components';
+import { List, Link } from '@woocommerce/components';
 import { Text } from '@woocommerce/experimental';
 import interpolateComponents from '@automattic/interpolate-components';
 import { getAdminLink } from '@woocommerce/settings';
@@ -25,31 +25,23 @@ type StackWithLoadSampleBlurb = {
 	items: StackItem[];
 	onClickLoadSampleProduct: () => void;
 	showOtherOptions?: boolean;
-	isTaskListItemClicked?: boolean;
 };

 type StackWithoutText = {
 	items: StackItem[];
 	showOtherOptions: false;
 	onClickLoadSampleProduct?: () => void;
-	isTaskListItemClicked?: boolean;
 };

 const Stack = ( {
 	items,
 	onClickLoadSampleProduct = () => {},
 	showOtherOptions = true,
-	isTaskListItemClicked = false,
 }: StackProps ) => {
 	const { recordCompletionTime } = useRecordCompletionTime( 'products' );

 	return (
 		<div className="woocommerce-products-stack">
-			{ isTaskListItemClicked && (
-				<div className="woocommerce-stack__overlay-spinner">
-					<Spinner className="list-overlay" />
-				</div>
-			) }
 			<List items={ items } />
 			{ showOtherOptions && (
 				<Text className="woocommerce-stack__other-options">
diff --git a/plugins/woocommerce/client/admin/client/task-lists/fills/products/test/index.tsx b/plugins/woocommerce/client/admin/client/task-lists/fills/products/test/index.tsx
index 0587de38a36..110b7ad6d24 100644
--- a/plugins/woocommerce/client/admin/client/task-lists/fills/products/test/index.tsx
+++ b/plugins/woocommerce/client/admin/client/task-lists/fills/products/test/index.tsx
@@ -17,6 +17,7 @@ import {
 	productTypes,
 } from '../constants';
 import { getAdminSetting } from '~/utils/admin-settings';
+import { useCreateProductByType } from '../use-create-product-by-type';

 // Mock window.location
 const mockLocation = {
@@ -47,9 +48,7 @@ jest.mock( '~/utils/admin-settings', () => ( {
 } ) );

 jest.mock( '../use-create-product-by-type', () => ( {
-	useCreateProductByType: jest
-		.fn()
-		.mockReturnValue( { createProductByType: jest.fn() } ),
+	useCreateProductByType: jest.fn(),
 } ) );

 global.fetch = jest.fn().mockImplementation( () =>
@@ -67,6 +66,10 @@ const confirmModalText =
 describe( 'Products', () => {
 	beforeEach( () => {
 		jest.clearAllMocks();
+		( useCreateProductByType as jest.Mock ).mockReturnValue( {
+			createProductByType: jest.fn(),
+			requestingType: null,
+		} );
 		// @ts-expect-error -- outdated type definition
 		removeAllFilters( SETUP_TASKLIST_PRODUCTS_AFTER_FILTER );

@@ -326,6 +329,33 @@ describe( 'Products', () => {
 		);
 	} );

+	it( 'should show one spinner on the requested product type and ignore clicks on the others', async () => {
+		( getAdminSetting as jest.Mock ).mockImplementation( () => ( {
+			profile: { product_types: null },
+		} ) );
+		const createProductByType = jest.fn();
+		( useCreateProductByType as jest.Mock ).mockReturnValue( {
+			createProductByType,
+			requestingType: 'physical',
+		} );
+		const { container, getByRole } = render( <Products /> );
+
+		expect(
+			container.querySelectorAll( '.components-spinner' )
+		).toHaveLength( 1 );
+		expect(
+			getByRole( 'menuitem', { name: /Physical product/ } ).querySelector(
+				'.components-spinner'
+			)
+		).toBeInTheDocument();
+
+		await userEvent.click(
+			getByRole( 'menuitem', { name: /Variable product/ } )
+		);
+		expect( createProductByType ).not.toHaveBeenCalled();
+		expect( recordEvent ).not.toHaveBeenCalled();
+	} );
+
 	describe( 'Printful banner visibility', () => {
 		it( 'should show Printful banner when feature is enabled and plugin is not installed', async () => {
 			( useSelect as jest.Mock ).mockImplementation( ( callback ) =>
diff --git a/plugins/woocommerce/client/admin/client/task-lists/fills/products/test/use-create-product-by-type.ts b/plugins/woocommerce/client/admin/client/task-lists/fills/products/test/use-create-product-by-type.ts
new file mode 100644
index 00000000000..a1c2dc54ebf
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/task-lists/fills/products/test/use-create-product-by-type.ts
@@ -0,0 +1,72 @@
+/**
+ * External dependencies
+ */
+import { act, renderHook } from '@testing-library/react';
+import { useDispatch } from '@wordpress/data';
+import { navigateTo } from '@woocommerce/navigation';
+
+/**
+ * Internal dependencies
+ */
+import { useCreateProductByType } from '../use-create-product-by-type';
+import { createNoticesFromResponse } from '../../../../lib/notices';
+
+jest.mock( '@wordpress/data', () => ( {
+	...jest.requireActual( '@wordpress/data' ),
+	useDispatch: jest.fn(),
+} ) );
+
+jest.mock( '@woocommerce/navigation', () => ( {
+	...jest.requireActual( '@woocommerce/navigation' ),
+	navigateTo: jest.fn(),
+} ) );
+
+jest.mock( '../../../../lib/notices', () => ( {
+	createNoticesFromResponse: jest.fn(),
+} ) );
+
+describe( 'useCreateProductByType', () => {
+	it( 'should stay busy after navigating to the editor', async () => {
+		( useDispatch as jest.Mock ).mockReturnValue( {
+			createProductFromTemplate: jest.fn().mockResolvedValue( { id: 7 } ),
+		} );
+		const { result } = renderHook( () => useCreateProductByType() );
+
+		await act( () => result.current.createProductByType( 'physical' ) );
+
+		expect( navigateTo ).toHaveBeenCalledWith( {
+			url: expect.stringContaining( 'post.php?post=7&action=edit' ),
+		} );
+		expect( result.current.requestingType ).toBe( 'physical' );
+	} );
+
+	it( 'should clear the busy state when the page is restored from the back/forward cache', async () => {
+		( useDispatch as jest.Mock ).mockReturnValue( {
+			createProductFromTemplate: jest.fn().mockResolvedValue( { id: 7 } ),
+		} );
+		const { result } = renderHook( () => useCreateProductByType() );
+		await act( () => result.current.createProductByType( 'physical' ) );
+
+		const pageShow = new Event( 'pageshow' );
+		Object.defineProperty( pageShow, 'persisted', { value: true } );
+		act( () => {
+			window.dispatchEvent( pageShow );
+		} );
+
+		expect( result.current.requestingType ).toBeNull();
+	} );
+
+	it( 'should clear the busy state when the request fails', async () => {
+		( useDispatch as jest.Mock ).mockReturnValue( {
+			createProductFromTemplate: jest
+				.fn()
+				.mockRejectedValue( new Error( 'failed' ) ),
+		} );
+		const { result } = renderHook( () => useCreateProductByType() );
+
+		await act( () => result.current.createProductByType( 'physical' ) );
+
+		expect( result.current.requestingType ).toBeNull();
+		expect( createNoticesFromResponse ).toHaveBeenCalled();
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/task-lists/fills/products/use-create-product-by-type.ts b/plugins/woocommerce/client/admin/client/task-lists/fills/products/use-create-product-by-type.ts
index 00633daf2b8..2cde9df9d1a 100644
--- a/plugins/woocommerce/client/admin/client/task-lists/fills/products/use-create-product-by-type.ts
+++ b/plugins/woocommerce/client/admin/client/task-lists/fills/products/use-create-product-by-type.ts
@@ -1,11 +1,13 @@
 /**
  * External dependencies
  */
+import { speak } from '@wordpress/a11y';
 import { useDispatch } from '@wordpress/data';
+import { __ } from '@wordpress/i18n';
 import { itemsStore } from '@woocommerce/data';
 import { navigateTo } from '@woocommerce/navigation';
 import { getAdminLink } from '@woocommerce/settings';
-import { useState } from '@wordpress/element';
+import { useEffect, useState } from '@wordpress/element';

 /**
  * Internal dependencies
@@ -15,7 +17,19 @@ import { createNoticesFromResponse } from '../../../lib/notices';

 export const useCreateProductByType = () => {
 	const { createProductFromTemplate } = useDispatch( itemsStore );
-	const [ isRequesting, setIsRequesting ] = useState< boolean >( false );
+	const [ requestingType, setRequestingType ] =
+		useState< ProductTypeKey | null >( null );
+
+	// A page restored from the back/forward cache keeps its last state, so clear the spinner.
+	useEffect( () => {
+		const onPageShow = ( event: PageTransitionEvent ) => {
+			if ( event.persisted ) {
+				setRequestingType( null );
+			}
+		};
+		window.addEventListener( 'pageshow', onPageShow );
+		return () => window.removeEventListener( 'pageshow', onPageShow );
+	}, [] );

 	const getProductEditPageLink = async ( type: ProductTypeKey ) => {
 		try {
@@ -40,17 +54,20 @@ export const useCreateProductByType = () => {
 	};

 	const createProductByType = async ( type: ProductTypeKey ) => {
-		setIsRequesting( true );
+		setRequestingType( type );
+		speak( __( 'Creating your product…', 'woocommerce' ) );

 		const url = await getProductEditPageLink( type );
 		if ( url ) {
+			// No reset on success: the page stays busy until the editor loads.
 			navigateTo( { url } );
+		} else {
+			setRequestingType( null );
 		}
-		setIsRequesting( false );
 	};

 	return {
 		createProductByType,
-		isRequesting,
+		requestingType,
 	};
 };
diff --git a/plugins/woocommerce/client/admin/client/task-lists/fills/products/use-product-types-list-items.tsx b/plugins/woocommerce/client/admin/client/task-lists/fills/products/use-product-types-list-items.tsx
index 260f232b238..231026f3ac0 100644
--- a/plugins/woocommerce/client/admin/client/task-lists/fills/products/use-product-types-list-items.tsx
+++ b/plugins/woocommerce/client/admin/client/task-lists/fills/products/use-product-types-list-items.tsx
@@ -3,6 +3,7 @@
  */
 import { useMemo } from '@wordpress/element';
 import { recordEvent } from '@woocommerce/tracks';
+import { Spinner } from '@wordpress/components';

 /**
  * Internal dependencies
@@ -23,13 +24,19 @@ const useProductTypeListItems = (
 		onClick?: () => void;
 	} = {}
 ) => {
-	const { createProductByType, isRequesting } = useCreateProductByType();
+	const { createProductByType, requestingType } = useCreateProductByType();

 	const productTypes = useMemo(
 		() =>
 			_productTypes.map( ( productType: ProductTypeWithOnclick ) => ( {
 				...productType,
+				...( requestingType === productType.key && {
+					after: <Spinner />,
+				} ),
 				onClick: () => {
+					if ( requestingType ) {
+						return;
+					}
 					if ( typeof productType?.onClick === 'function' ) {
 						productType.onClick();
 					} else {
@@ -48,11 +55,18 @@ const useProductTypeListItems = (
 						onClick();
 					}
 				},
-			} ) ),
-		[ _productTypes, createProductByType, onClick, suggestedProductTypes ]
+				// The cast lets `after` hold the spinner; the literal item types only allow their own `after`.
+			} ) ) as Array< ProductType & { onClick: () => void } >,
+		[
+			_productTypes,
+			createProductByType,
+			onClick,
+			suggestedProductTypes,
+			requestingType,
+		]
 	);

-	return { productTypes, isRequesting };
+	return { productTypes };
 };

 export default useProductTypeListItems;