Commit f10568df880 for woocommerce

commit f10568df88085d530606483ab0a9887c896aa133
Author: Ismael Martín Alabarce <info@ismaeld.com>
Date:   Tue Aug 25 10:06:10 2026 +0200

    Move the stale task redirect to the homescreen layout (#67811)

    * Revert stale task redirect in TaskLists component

    * Move stale task redirect to the homescreen layout

    * Add changelog entry for stale task redirect fix

diff --git a/plugins/woocommerce/changelog/fix-stale-task-redirect-scope b/plugins/woocommerce/changelog/fix-stale-task-redirect-scope
new file mode 100644
index 00000000000..e7d561c79e1
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-stale-task-redirect-scope
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Scope the stale task redirect to the Home screen and skip it when task lists have not loaded, so failed fetches or stray task params no longer navigate away.
diff --git a/plugins/woocommerce/client/admin/client/homescreen/layout.js b/plugins/woocommerce/client/admin/client/homescreen/layout.js
index 3ba8a0e8ab2..f731d799fcc 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/layout.js
+++ b/plugins/woocommerce/client/admin/client/homescreen/layout.js
@@ -19,6 +19,7 @@ import {
 	onboardingStore,
 	optionsStore,
 } from '@woocommerce/data';
+import { getHistory, getNewPath } from '@woocommerce/navigation';
 import { __ } from '@wordpress/i18n';

 /**
@@ -58,6 +59,7 @@ export const Layout = ( {
 	defaultHomescreenLayout,
 	query,
 	hasTaskList,
+	hasStaleTask,
 	showingProgressHeader,
 	isLoadingTaskLists,
 } ) => {
@@ -108,6 +110,13 @@ export const Layout = ( {
 		}
 	}, [ query?.nox, createInfoNotice ] );

+	// A stale task would render a blank screen, so send it to the homescreen instead
+	useEffect( () => {
+		if ( hasStaleTask ) {
+			getHistory().replace( getNewPath( {}, '/', {} ) );
+		}
+	}, [ hasStaleTask ] );
+
 	const shouldStickColumns = isWideViewport.current && twoColumns;
 	const shouldShowMobileAppModal = query.mobileAppModal ?? false;
 	const shouldShowEmailImprovementsModal =
@@ -204,7 +213,7 @@ Layout.propTypes = {
 };

 export default compose(
-	withSelect( ( select ) => {
+	withSelect( ( select, { query } ) => {
 		const { isNotesRequesting } = select( notesStore );
 		const { getOption } = select( optionsStore );
 		const defaultHomescreenLayout =
@@ -212,6 +221,7 @@ export default compose(
 			'single_column';

 		const {
+			getTask,
 			getTaskLists,
 			hasFinishedResolution: taskListFinishResolution,
 		} = select( onboardingStore );
@@ -220,18 +230,28 @@ export default compose(
 		const hasTaskList = visibleTaskListIds.length > 0;

 		// Only fetch task lists if there are any visible task lists to avoid unnecessary API calls
+		// The task screen renders even when no task list is visible, so the stale task check below needs the fetch too
 		let isLoadingTaskLists = false;
 		let taskLists = [];
-		if ( hasTaskList ) {
+		if ( hasTaskList || query.task ) {
 			isLoadingTaskLists = ! taskListFinishResolution( 'getTaskLists' );
 			taskLists = getTaskLists();
 		}

+		// A task param is stale when it matches no fetched task: it was removed, or its list is hidden and the endpoint strips those tasks
+		// Empty task lists mean a failed or unfinished fetch, never a stale task
+		const hasStaleTask =
+			!! query.task &&
+			! isLoadingTaskLists &&
+			taskLists.length > 0 &&
+			! getTask( query.task );
+
 		return {
 			defaultHomescreenLayout,
 			isBatchUpdating: isNotesRequesting( 'batchUpdateNotes' ),
 			isLoadingTaskLists,
 			hasTaskList,
+			hasStaleTask,
 			showingProgressHeader: !! taskLists.find(
 				( list ) => list.isVisible && list.displayProgressHeader
 			),
diff --git a/plugins/woocommerce/client/admin/client/homescreen/test/layout-stale-task.test.js b/plugins/woocommerce/client/admin/client/homescreen/test/layout-stale-task.test.js
new file mode 100644
index 00000000000..eb67bc5070a
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/homescreen/test/layout-stale-task.test.js
@@ -0,0 +1,163 @@
+/**
+ * External dependencies
+ */
+import { render, waitFor } from '@testing-library/react';
+import { dispatch, select } from '@wordpress/data';
+import { onboardingStore } from '@woocommerce/data';
+import { getHistory, getNewPath } from '@woocommerce/navigation';
+
+/**
+ * Internal dependencies
+ */
+import ConnectedLayout from '../layout';
+import { getAdminSetting } from '~/utils/admin-settings';
+
+jest.mock( '@woocommerce/navigation', () => ( {
+	...jest.requireActual( '@woocommerce/navigation' ),
+	getHistory: jest.fn(),
+	getNewPath: jest.fn().mockReturnValue( 'home-path' ),
+} ) );
+
+jest.mock( '~/utils/admin-settings', () => ( {
+	...jest.requireActual( '~/utils/admin-settings' ),
+	getAdminSetting: jest.fn(),
+} ) );
+
+jest.mock( '../stats-overview', () =>
+	jest.fn().mockReturnValue( <div>[StatsOverview]</div> )
+);
+
+jest.mock( '../../inbox-panel', () =>
+	jest.fn().mockReturnValue( <div>[InboxPanel]</div> )
+);
+
+jest.mock( '../../store-management-links', () => ( {
+	StoreManagementLinks: jest
+		.fn()
+		.mockReturnValue( <div>[StoreManagementLinks]</div> ),
+} ) );
+
+jest.mock( '../activity-panel', () => ( {
+	ActivityPanel: jest.fn().mockReturnValue( <div>[ActivityPanel]</div> ),
+} ) );
+
+jest.mock( '@wordpress/element', () => {
+	return {
+		...jest.requireActual( '@wordpress/element' ),
+		Suspense: ( { children } ) => <div>{ children }</div>,
+		lazy: () => () => <div>[TaskList]</div>,
+	};
+} );
+
+const TASK_LISTS = [
+	{
+		id: 'setup',
+		isVisible: true,
+		tasks: [ { id: 'products' }, { id: 'payments' } ],
+	},
+	{ id: 'extended', isVisible: true, tasks: [] },
+];
+
+// What /onboarding/tasks returns when every list is hidden: the lists are
+// still present, but their tasks are stripped server-side.
+const HIDDEN_TASK_LISTS = [
+	{ id: 'setup', isVisible: false, tasks: [] },
+	{ id: 'extended', isVisible: false, tasks: [] },
+];
+
+// The onboarding resolvers reach the network through their own copy of
+// @wordpress/api-fetch, so mock the fetch layer underneath instead of the module.
+global.fetch = jest.fn();
+
+const jsonResponse = ( data ) => ( {
+	status: 200,
+	ok: true,
+	headers: {
+		get: ( name ) =>
+			name.toLowerCase() === 'content-type' ? 'application/json' : null,
+	},
+	json: () => Promise.resolve( data ),
+	text: () => Promise.resolve( JSON.stringify( data ) ),
+} );
+
+// Answers every request the connected Layout triggers; `tasks` is the
+// /onboarding/tasks response — pass 'fail' to simulate a network failure.
+const mockRequests = ( tasks ) => {
+	global.fetch.mockImplementation( ( url = '' ) => {
+		if ( url.includes( '/onboarding/tasks' ) ) {
+			return tasks === 'fail'
+				? Promise.reject( new TypeError( 'Failed to fetch' ) )
+				: Promise.resolve( jsonResponse( tasks ) );
+		}
+		if ( url.includes( 'users/me' ) ) {
+			return Promise.resolve(
+				jsonResponse( { capabilities: { manage_woocommerce: true } } )
+			);
+		}
+		return Promise.resolve( jsonResponse( {} ) );
+	} );
+};
+
+const waitForTaskListsResolution = async () => {
+	await waitFor( () =>
+		expect(
+			select( onboardingStore ).hasFinishedResolution( 'getTaskLists' )
+		).toBe( true )
+	);
+};
+
+describe( 'Homescreen Layout stale task redirect (connected)', () => {
+	const historyReplace = jest.fn();
+
+	beforeEach( () => {
+		jest.clearAllMocks();
+		getHistory.mockReturnValue( {
+			replace: historyReplace,
+		} );
+		getNewPath.mockReturnValue( 'home-path' );
+		getAdminSetting.mockImplementation( ( name, fallback ) =>
+			name === 'visibleTaskListIds' ? [ 'setup' ] : fallback
+		);
+		dispatch( onboardingStore ).invalidateResolution( 'getTaskLists', [] );
+	} );
+
+	// This test runs first on purpose: it needs the store in its cold state,
+	// before any successful fetch has populated the task lists.
+	it( 'does not redirect and keeps the task URL when the fetch fails', async () => {
+		mockRequests( 'fail' );
+		render( <ConnectedLayout query={ { task: 'payments' } } /> );
+
+		await waitForTaskListsResolution();
+		expect( historyReplace ).not.toHaveBeenCalled();
+	} );
+
+	it( 'redirects home when the task matches no fetched task', async () => {
+		mockRequests( TASK_LISTS );
+		render( <ConnectedLayout query={ { task: 'shipping' } } /> );
+
+		await waitFor( () =>
+			expect( historyReplace ).toHaveBeenCalledWith( 'home-path' )
+		);
+		expect( getNewPath ).toHaveBeenCalledWith( {}, '/', {} );
+	} );
+
+	it( 'does not redirect when the task exists', async () => {
+		mockRequests( TASK_LISTS );
+		render( <ConnectedLayout query={ { task: 'payments' } } /> );
+
+		await waitForTaskListsResolution();
+		expect( historyReplace ).not.toHaveBeenCalled();
+	} );
+
+	it( 'redirects home for a stale task when no task list is visible', async () => {
+		getAdminSetting.mockImplementation( ( name, fallback ) =>
+			name === 'visibleTaskListIds' ? [] : fallback
+		);
+		mockRequests( HIDDEN_TASK_LISTS );
+		render( <ConnectedLayout query={ { task: 'payments' } } /> );
+
+		await waitFor( () =>
+			expect( historyReplace ).toHaveBeenCalledWith( 'home-path' )
+		);
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/task-lists/task-lists.tsx b/plugins/woocommerce/client/admin/client/task-lists/task-lists.tsx
index 5f2bda48bc3..21efb55468e 100644
--- a/plugins/woocommerce/client/admin/client/task-lists/task-lists.tsx
+++ b/plugins/woocommerce/client/admin/client/task-lists/task-lists.tsx
@@ -4,10 +4,9 @@
 import { __ } from '@wordpress/i18n';
 import { MenuGroup, MenuItem } from '@wordpress/components';
 import { check } from '@wordpress/icons';
-import { Fragment, useEffect } from '@wordpress/element';
+import { Fragment } from '@wordpress/element';
 import { useDispatch, useSelect } from '@wordpress/data';
 import { onboardingStore, TaskListType, TaskType } from '@woocommerce/data';
-import { getHistory, getNewPath } from '@woocommerce/navigation';
 import { recordEvent } from '@woocommerce/tracks';

 /**
@@ -80,12 +79,6 @@ export const TaskLists = ( { query }: TaskListsProps ) => {

 	const currentTask = getCurrentTask();

-	useEffect( () => {
-		if ( task && ! currentTask && ! isResolving ) {
-			getHistory().replace( getNewPath( {}, '/', {} ) );
-		}
-	}, [ currentTask, isResolving, task ] );
-
 	if ( task && ! currentTask ) {
 		return null;
 	}
diff --git a/plugins/woocommerce/client/admin/client/task-lists/test/tasks.test.tsx b/plugins/woocommerce/client/admin/client/task-lists/test/tasks.test.tsx
index d2dd57d3175..f7247e69815 100644
--- a/plugins/woocommerce/client/admin/client/task-lists/test/tasks.test.tsx
+++ b/plugins/woocommerce/client/admin/client/task-lists/test/tasks.test.tsx
@@ -4,7 +4,6 @@
 import { render, act, cleanup, waitFor } from '@testing-library/react';
 import { useDispatch, useSelect } from '@wordpress/data';
 import { recordEvent } from '@woocommerce/tracks';
-import { getHistory, getNewPath } from '@woocommerce/navigation';
 import userEvent from '@testing-library/user-event';

 /**
@@ -29,11 +28,6 @@ jest.mock( '@wordpress/data', () => {

 jest.mock( '@woocommerce/explat' );
 jest.mock( '@woocommerce/tracks' );
-jest.mock( '@woocommerce/navigation', () => ( {
-	...jest.requireActual( '@woocommerce/navigation' ),
-	getHistory: jest.fn(),
-	getNewPath: jest.fn().mockReturnValue( 'home-path' ),
-} ) );

 jest.mock( '../components/task-list', () => ( {
 	TaskList: ( { id }: TaskListProps ) => <div>task-list:{ id }</div>,
@@ -62,12 +56,8 @@ jest.mock( '~/activity-panel/display-options', () => ( {
 describe( 'Task', () => {
 	const hideTaskList = jest.fn();
 	const updateOptions = jest.fn();
-	const historyReplace = jest.fn();
 	beforeEach( () => {
 		jest.clearAllMocks();
-		( getHistory as jest.Mock ).mockReturnValue( {
-			replace: historyReplace,
-		} );
 		( useDispatch as jest.Mock ).mockImplementation( () => ( {
 			hideTaskList,
 			updateOptions,
@@ -112,10 +102,9 @@ describe( 'Task', () => {
 			</div>
 		);
 		expect( queryByText( 'task:main-task-1' ) ).toBeInTheDocument();
-		expect( historyReplace ).not.toHaveBeenCalled();
 	} );

-	it( 'should redirect home if query has task, but task does not exist', async () => {
+	it( 'should not render anything if query has task, but task does not exist', () => {
 		const { queryByText } = render(
 			<div>
 				<TaskLists query={ { task: 'main-task-random' } } />
@@ -125,23 +114,6 @@ describe( 'Task', () => {
 			queryByText( 'task:main-task-random' )
 		).not.toBeInTheDocument();
 		expect( queryByText( 'task-list:main' ) ).not.toBeInTheDocument();
-		await waitFor( () => {
-			expect( historyReplace ).toHaveBeenCalledWith( 'home-path' );
-		} );
-		expect( getNewPath ).toHaveBeenCalledWith( {}, '/', {} );
-	} );
-
-	it( 'should not redirect if task lists are still resolving', () => {
-		( useSelect as jest.Mock ).mockImplementation( () => ( {
-			isResolving: true,
-			taskLists: [],
-		} ) );
-		render(
-			<div>
-				<TaskLists query={ { task: 'main-task-random' } } />
-			</div>
-		);
-		expect( historyReplace ).not.toHaveBeenCalled();
 	} );

 	it( 'should render the placeholder if isResolving is true', () => {