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', () => {