Commit 0a3a4a581d9 for woocommerce

commit 0a3a4a581d91812043aec85559aa2af24df400ac
Author: Faisal Ahammad <faisalahammad24@gmail.com>
Date:   Fri Jul 10 12:03:17 2026 +0600

    Combine Activity Panel count requests into one endpoint (#66276)

    * perf(admin): combine activity panel count requests

    - Add /wc-analytics/activity-panel/counts endpoint returning orders,
      reviews, and low stock counts in one response
    - Add activityPanelStore with getActivityPanelCounts selector
    - Migrate header bell, abbreviated notifications, and homescreen
      panels to the shared selector so they resolve to a single request
    - Remove now-unused count helpers from orders/reviews utils

    Opening a wp-admin page fired 6 separate wc-analytics requests just
    to populate the Activity Panel. This combines them into one request
    and gives every consumer the same cached resolution.

    Fixes #60393

    * fix(activity-panel): return null instead of 0 on sub-request failure

    - get_count_via() returns null (not 0) when a sub-request errors, so a
      merchant can't mistake "sub-request failed" for a genuine zero count
    - get_item_schema() marks the three count fields nullable
    - widen ActivityPanelCounts TS type to number | null
    - add regression test forcing a sub-request failure

    Addresses CodeRabbit feedback on PR #66276

    * fix(activity-panel): address review feedback from chihsuan

    - Add activityPanelStore invalidation in reviews/stock update flows
    - Remove try/catch from resolver to allow retry on failure
    - Remove unnecessary _embed param from reviews sub-request
    - Remove dead is_wp_error() check (rest_do_request wraps errors)
    - Fix schema nullable type to use union array instead of nullable flag
    - Remove dead schema fields (context, readonly) not applied by endpoint
    - Fix final fallback to return null instead of 0 for missing total

    Refs #66276

    * fix(activity-panel): resolve CI failures on PR #66276

    - Add missing declare(strict_types=1) to ActivityPanelCounts.php and its test
    - Fix equals-sign alignment in ActivityPanelCountsTest.php (PHPCS)
    - Mock invalidateActivityPanel dispatch in StockPanel jest test (was crashing
      after invalidateActivityPanel was wired into updateStock)
    - Restore try/catch in getActivityPanelCounts resolver per chihsuan's review
      request, with matching test coverage for the error dispatch path

    Refs #66276

    * fix(activity-panel): rename stock_status param to product_status for clarity

    The stock_status param was misleading - it controls the product post
    status (publish/draft), not WooCommerce stock status (instock/outofstock).
    Rename to product_status to avoid confusing API consumers.

    Addresses CodeRabbit review feedback.

    Refs #66276

diff --git a/packages/js/data/changelog/add-60393-activity-panel-store b/packages/js/data/changelog/add-60393-activity-panel-store
new file mode 100644
index 00000000000..f2ffe2fb2b6
--- /dev/null
+++ b/packages/js/data/changelog/add-60393-activity-panel-store
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add activityPanelStore with a getActivityPanelCounts selector, backed by a single combined REST request.
diff --git a/packages/js/data/src/activity-panel/action-types.ts b/packages/js/data/src/activity-panel/action-types.ts
new file mode 100644
index 00000000000..b2e5b4d7d46
--- /dev/null
+++ b/packages/js/data/src/activity-panel/action-types.ts
@@ -0,0 +1,6 @@
+enum TYPES {
+	GET_ACTIVITY_PANEL_COUNTS_SUCCESS = 'GET_ACTIVITY_PANEL_COUNTS_SUCCESS',
+	GET_ACTIVITY_PANEL_COUNTS_ERROR = 'GET_ACTIVITY_PANEL_COUNTS_ERROR',
+}
+
+export default TYPES;
diff --git a/packages/js/data/src/activity-panel/actions.ts b/packages/js/data/src/activity-panel/actions.ts
new file mode 100644
index 00000000000..f294e44b879
--- /dev/null
+++ b/packages/js/data/src/activity-panel/actions.ts
@@ -0,0 +1,23 @@
+/**
+ * Internal dependencies
+ */
+import TYPES from './action-types';
+import { ActivityPanelCounts } from './types';
+
+export function getActivityPanelCountsSuccess( counts: ActivityPanelCounts ) {
+	return {
+		type: TYPES.GET_ACTIVITY_PANEL_COUNTS_SUCCESS as const,
+		counts,
+	};
+}
+
+export function getActivityPanelCountsError( error: unknown ) {
+	return {
+		type: TYPES.GET_ACTIVITY_PANEL_COUNTS_ERROR as const,
+		error,
+	};
+}
+
+export type Actions = ReturnType<
+	typeof getActivityPanelCountsSuccess | typeof getActivityPanelCountsError
+>;
diff --git a/packages/js/data/src/activity-panel/constants.ts b/packages/js/data/src/activity-panel/constants.ts
new file mode 100644
index 00000000000..8194dc5bdf9
--- /dev/null
+++ b/packages/js/data/src/activity-panel/constants.ts
@@ -0,0 +1 @@
+export const STORE_NAME = 'wc/admin/activity-panel' as const;
diff --git a/packages/js/data/src/activity-panel/index.ts b/packages/js/data/src/activity-panel/index.ts
new file mode 100644
index 00000000000..b844c07b395
--- /dev/null
+++ b/packages/js/data/src/activity-panel/index.ts
@@ -0,0 +1,35 @@
+/**
+ * External dependencies
+ */
+import { createReduxStore, register } from '@wordpress/data';
+import { controls } from '@wordpress/data-controls';
+
+/**
+ * Internal dependencies
+ */
+import { STORE_NAME } from './constants';
+import * as selectors from './selectors';
+import * as actions from './actions';
+import * as resolvers from './resolvers';
+import reducer, { type State } from './reducer';
+
+export * from './types';
+export type { State };
+
+export const store = createReduxStore( STORE_NAME, {
+	reducer,
+	actions,
+	controls,
+	selectors,
+	resolvers,
+} );
+
+register( store );
+
+export const ACTIVITY_PANEL_STORE_NAME = STORE_NAME;
+
+declare module '@wordpress/data' {
+	interface StoreRegistry {
+		[ STORE_NAME ]: typeof store;
+	}
+}
diff --git a/packages/js/data/src/activity-panel/reducer.ts b/packages/js/data/src/activity-panel/reducer.ts
new file mode 100644
index 00000000000..915655b00cc
--- /dev/null
+++ b/packages/js/data/src/activity-panel/reducer.ts
@@ -0,0 +1,37 @@
+/**
+ * External dependencies
+ */
+import { Reducer } from 'redux';
+
+/**
+ * Internal dependencies
+ */
+import TYPES from './action-types';
+import { Actions } from './actions';
+import { ActivityPanelState } from './types';
+
+const reducer: Reducer< ActivityPanelState, Actions > = (
+	state = {},
+	payload
+) => {
+	if ( payload && 'type' in payload ) {
+		switch ( payload.type ) {
+			case TYPES.GET_ACTIVITY_PANEL_COUNTS_SUCCESS:
+				return {
+					...state,
+					counts: payload.counts,
+				};
+			case TYPES.GET_ACTIVITY_PANEL_COUNTS_ERROR:
+				return {
+					...state,
+					error: payload.error,
+				};
+			default:
+				return state;
+		}
+	}
+	return state;
+};
+
+export type State = ReturnType< typeof reducer >;
+export default reducer;
diff --git a/packages/js/data/src/activity-panel/resolvers.ts b/packages/js/data/src/activity-panel/resolvers.ts
new file mode 100644
index 00000000000..f472e279a35
--- /dev/null
+++ b/packages/js/data/src/activity-panel/resolvers.ts
@@ -0,0 +1,29 @@
+/**
+ * External dependencies
+ */
+import { apiFetch } from '@wordpress/data-controls';
+
+/**
+ * Internal dependencies
+ */
+import { NAMESPACE } from '../constants';
+import {
+	getActivityPanelCountsSuccess,
+	getActivityPanelCountsError,
+} from './actions';
+import { ActivityPanelCounts } from './types';
+
+/**
+ * Request the Activity Panel counts (orders to fulfill, reviews to moderate,
+ * low stock products) in a single request instead of one per count.
+ */
+export function* getActivityPanelCounts() {
+	try {
+		const counts: ActivityPanelCounts = yield apiFetch( {
+			path: `${ NAMESPACE }/activity-panel/counts`,
+		} );
+		yield getActivityPanelCountsSuccess( counts );
+	} catch ( error ) {
+		yield getActivityPanelCountsError( error );
+	}
+}
diff --git a/packages/js/data/src/activity-panel/selectors.ts b/packages/js/data/src/activity-panel/selectors.ts
new file mode 100644
index 00000000000..1e1c858c448
--- /dev/null
+++ b/packages/js/data/src/activity-panel/selectors.ts
@@ -0,0 +1,22 @@
+/**
+ * Internal dependencies
+ */
+import { ActivityPanelState } from './types';
+
+/**
+ * Get the Activity Panel counts from state tree.
+ *
+ * @param {Object} state - Reducer state
+ */
+export const getActivityPanelCounts = ( state: ActivityPanelState ) => {
+	return state.counts;
+};
+
+/**
+ * Determine if fetching the Activity Panel counts resulted in an error.
+ *
+ * @param {Object} state - Reducer state
+ */
+export const getActivityPanelCountsError = ( state: ActivityPanelState ) => {
+	return state.error;
+};
diff --git a/packages/js/data/src/activity-panel/test/reducer.ts b/packages/js/data/src/activity-panel/test/reducer.ts
new file mode 100644
index 00000000000..2a8e061350d
--- /dev/null
+++ b/packages/js/data/src/activity-panel/test/reducer.ts
@@ -0,0 +1,46 @@
+/**
+ * @jest-environment node
+ */
+
+/**
+ * Internal dependencies
+ */
+import reducer from '../reducer';
+import TYPES from '../action-types';
+
+describe( 'activity-panel reducer', () => {
+	it( 'should return a default state', () => {
+		// @ts-expect-error reducer action should not be empty but it is
+		const state = reducer( undefined, {} );
+		expect( state ).toEqual( {} );
+	} );
+
+	it( 'should handle GET_ACTIVITY_PANEL_COUNTS_SUCCESS', () => {
+		const counts = {
+			orders_to_fulfill_count: 2,
+			reviews_to_moderate_count: 1,
+			products_low_in_stock_count: 3,
+		};
+		const state = reducer(
+			{},
+			{
+				type: TYPES.GET_ACTIVITY_PANEL_COUNTS_SUCCESS,
+				counts,
+			}
+		);
+
+		expect( state.counts ).toEqual( counts );
+	} );
+
+	it( 'should handle GET_ACTIVITY_PANEL_COUNTS_ERROR', () => {
+		const state = reducer(
+			{},
+			{
+				type: TYPES.GET_ACTIVITY_PANEL_COUNTS_ERROR,
+				error: 'Something went wrong',
+			}
+		);
+
+		expect( state.error ).toBe( 'Something went wrong' );
+	} );
+} );
diff --git a/packages/js/data/src/activity-panel/test/resolvers.ts b/packages/js/data/src/activity-panel/test/resolvers.ts
new file mode 100644
index 00000000000..3b70daaeacc
--- /dev/null
+++ b/packages/js/data/src/activity-panel/test/resolvers.ts
@@ -0,0 +1,51 @@
+/**
+ * @jest-environment node
+ */
+
+/**
+ * External dependencies
+ */
+import { apiFetch } from '@wordpress/data-controls';
+
+/**
+ * Internal dependencies
+ */
+import { getActivityPanelCounts } from '../resolvers';
+import {
+	getActivityPanelCountsSuccess,
+	getActivityPanelCountsError,
+} from '../actions';
+import { NAMESPACE } from '../../constants';
+
+describe( 'getActivityPanelCounts resolver', () => {
+	it( 'fetches the combined counts and dispatches success', () => {
+		const generator = getActivityPanelCounts();
+
+		expect( generator.next().value ).toEqual(
+			apiFetch( { path: `${ NAMESPACE }/activity-panel/counts` } )
+		);
+
+		const counts = {
+			orders_to_fulfill_count: 2,
+			reviews_to_moderate_count: 1,
+			products_low_in_stock_count: 3,
+		};
+
+		expect( generator.next( counts ).value ).toEqual(
+			getActivityPanelCountsSuccess( counts )
+		);
+		expect( generator.next().done ).toBe( true );
+	} );
+
+	it( 'dispatches an error when the fetch fails', () => {
+		const generator = getActivityPanelCounts();
+		generator.next();
+
+		const error = new Error( 'Request failed' );
+
+		expect( generator.throw( error ).value ).toEqual(
+			getActivityPanelCountsError( error )
+		);
+		expect( generator.next().done ).toBe( true );
+	} );
+} );
diff --git a/packages/js/data/src/activity-panel/test/selectors.ts b/packages/js/data/src/activity-panel/test/selectors.ts
new file mode 100644
index 00000000000..e872189145f
--- /dev/null
+++ b/packages/js/data/src/activity-panel/test/selectors.ts
@@ -0,0 +1,33 @@
+/**
+ * @jest-environment node
+ */
+
+/**
+ * Internal dependencies
+ */
+import {
+	getActivityPanelCounts,
+	getActivityPanelCountsError,
+} from '../selectors';
+
+describe( 'activity-panel selectors', () => {
+	it( 'getActivityPanelCounts returns the counts from state', () => {
+		const counts = {
+			orders_to_fulfill_count: 2,
+			reviews_to_moderate_count: 1,
+			products_low_in_stock_count: 3,
+		};
+
+		expect( getActivityPanelCounts( { counts } ) ).toEqual( counts );
+	} );
+
+	it( 'getActivityPanelCounts returns undefined when not yet resolved', () => {
+		expect( getActivityPanelCounts( {} ) ).toBeUndefined();
+	} );
+
+	it( 'getActivityPanelCountsError returns the error from state', () => {
+		expect( getActivityPanelCountsError( { error: 'boom' } ) ).toBe(
+			'boom'
+		);
+	} );
+} );
diff --git a/packages/js/data/src/activity-panel/types.ts b/packages/js/data/src/activity-panel/types.ts
new file mode 100644
index 00000000000..e9417db6506
--- /dev/null
+++ b/packages/js/data/src/activity-panel/types.ts
@@ -0,0 +1,27 @@
+/**
+ * Internal dependencies
+ */
+import { WPDataSelector, WPDataSelectors } from '../types';
+import {
+	getActivityPanelCounts,
+	getActivityPanelCountsError,
+} from './selectors';
+
+export type ActivityPanelCounts = {
+	// Null when the endpoint's underlying sub-request for this count failed.
+	orders_to_fulfill_count: number | null;
+	reviews_to_moderate_count: number | null;
+	products_low_in_stock_count: number | null;
+};
+
+export type ActivityPanelState = {
+	counts?: ActivityPanelCounts;
+	error?: unknown;
+};
+
+export type ActivityPanelSelectors = {
+	getActivityPanelCounts: WPDataSelector< typeof getActivityPanelCounts >;
+	getActivityPanelCountsError: WPDataSelector<
+		typeof getActivityPanelCountsError
+	>;
+} & WPDataSelectors;
diff --git a/packages/js/data/src/index.ts b/packages/js/data/src/index.ts
index c0cc818a869..2e5fac1bd55 100644
--- a/packages/js/data/src/index.ts
+++ b/packages/js/data/src/index.ts
@@ -28,6 +28,7 @@ export { EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME } from './product-categories
 export { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms';
 export { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from './product-variations';
 export { EXPERIMENTAL_TAX_CLASSES_STORE_NAME } from './tax-classes';
+export { ACTIVITY_PANEL_STORE_NAME } from './activity-panel';
 export type { PaymentGateway } from './payment-gateways/types';
 export type {
 	PaymentsEntity,
@@ -79,6 +80,7 @@ export { store as woopaymentsOnboardingStore } from './woopayments-onboarding';
 export { store as reportsStore } from './reports';
 export { store as itemsStore } from './items';
 export { store as experimentalSettingOptionsStore } from './setting-options';
+export { store as activityPanelStore } from './activity-panel';

 // Export hooks
 export { withSettingsHydration } from './settings/with-settings-hydration';
@@ -205,6 +207,7 @@ import type { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-
 import type { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from './product-variations';
 import type { EXPERIMENTAL_TAX_CLASSES_STORE_NAME } from './tax-classes';
 import type { EXPERIMENTAL_PRODUCT_FORM_STORE_NAME } from './product-form';
+import type { ACTIVITY_PANEL_STORE_NAME } from './activity-panel';

 export type WCDataStoreName =
 	| typeof REVIEWS_STORE_NAME
@@ -232,7 +235,8 @@ export type WCDataStoreName =
 	| typeof EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME
 	| typeof EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
 	| typeof EXPERIMENTAL_TAX_CLASSES_STORE_NAME
-	| typeof EXPERIMENTAL_PRODUCT_FORM_STORE_NAME;
+	| typeof EXPERIMENTAL_PRODUCT_FORM_STORE_NAME
+	| typeof ACTIVITY_PANEL_STORE_NAME;

 /**
  * Internal dependencies
@@ -256,6 +260,7 @@ import { ProductVariationSelectors } from './product-variations/types';
 import { TaxClassSelectors } from './tax-classes/types';
 import { ProductFormSelectors } from './product-form/selectors';
 import { WooPaymentsOnboardingSelectors } from './woopayments-onboarding/selectors';
+import { ActivityPanelSelectors } from './activity-panel/types';

 // As we add types to all the package selectors we can fill out these unknown types with real ones. See one
 // of the already typed selectors for an example of how you can do this.
@@ -311,6 +316,8 @@ export type WCSelectorType< T > = T extends typeof REVIEWS_STORE_NAME
 	? TaxClassSelectors
 	: T extends typeof EXPERIMENTAL_PRODUCT_FORM_STORE_NAME
 	? ProductFormSelectors
+	: T extends typeof ACTIVITY_PANEL_STORE_NAME
+	? ActivityPanelSelectors
 	: never;

 export interface WCDataSelector {
diff --git a/plugins/woocommerce/changelog/fix-60393-combine-activity-panel-count-requests b/plugins/woocommerce/changelog/fix-60393-combine-activity-panel-count-requests
new file mode 100644
index 00000000000..06dfb0066e9
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-60393-combine-activity-panel-count-requests
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Combine the Activity Panel's orders, reviews, and low stock count requests into a single wc-analytics/activity-panel/counts endpoint.
diff --git a/plugins/woocommerce/client/admin/client/activity-panel/activity-panel.js b/plugins/woocommerce/client/admin/client/activity-panel/activity-panel.js
index 651a274a1a7..b4f3e2eac8d 100644
--- a/plugins/woocommerce/client/admin/client/activity-panel/activity-panel.js
+++ b/plugins/woocommerce/client/admin/client/activity-panel/activity-panel.js
@@ -17,7 +17,12 @@ import {
 } from '@wordpress/icons';
 import { STORE_KEY as CES_STORE_KEY } from '@woocommerce/customer-effort-score';
 import { H, Section } from '@woocommerce/components';
-import { onboardingStore, optionsStore, useUser } from '@woocommerce/data';
+import {
+	activityPanelStore,
+	onboardingStore,
+	optionsStore,
+	useUser,
+} from '@woocommerce/data';
 import { addHistoryListener } from '@woocommerce/navigation';
 import { recordEvent } from '@woocommerce/tracks';
 import { useSlot } from '@woocommerce/experimental';
@@ -34,12 +39,6 @@ import { hasUnreadNotes as checkIfHasUnreadNotes } from './unread-indicators';
 import { Tabs } from './tabs';
 import { DisplayOptions } from './display-options';
 import { Panel } from './panel';
-import {
-	getLowStockCount as getLowStockProducts,
-	getOrderStatuses,
-	getUnreadOrders,
-} from '../homescreen/activity-panel/orders/utils';
-import { getUnapprovedReviews } from '../homescreen/activity-panel/reviews/utils';
 import { ABBREVIATED_NOTIFICATION_SLOT_NAME } from './panels/inbox/abbreviated-notifications-panel';
 import { getAdminSetting } from '~/utils/admin-settings';
 import { getUrlParams } from '~/utils';
@@ -129,16 +128,17 @@ export const ActivityPanel = ( { isEmbedded, query } ) => {

 	const checkIfHasAbbreviatedNotifications = useCallback(
 		( select, setupTaskListHidden, thingsToDoNextCount ) => {
-			const orderStatuses = getOrderStatuses( select );
+			const counts =
+				select( activityPanelStore ).getActivityPanelCounts();

 			const isOrdersCardVisible = setupTaskListHidden
-				? getUnreadOrders( select, orderStatuses ) > 0
+				? ( counts?.orders_to_fulfill_count ?? 0 ) > 0
 				: false;
 			const isReviewsCardVisible = setupTaskListHidden
-				? getUnapprovedReviews( select )
+				? ( counts?.reviews_to_moderate_count ?? 0 ) > 0
 				: false;
 			const isLowStockCardVisible = setupTaskListHidden
-				? getLowStockProducts( select )
+				? ( counts?.products_low_in_stock_count ?? 0 ) > 0
 				: false;

 			return (
diff --git a/plugins/woocommerce/client/admin/client/activity-panel/panels/inbox/abbreviated-notifications-panel.js b/plugins/woocommerce/client/admin/client/activity-panel/panels/inbox/abbreviated-notifications-panel.js
index e7f7d12d955..0f1ee4e12fe 100644
--- a/plugins/woocommerce/client/admin/client/activity-panel/panels/inbox/abbreviated-notifications-panel.js
+++ b/plugins/woocommerce/client/admin/client/activity-panel/panels/inbox/abbreviated-notifications-panel.js
@@ -9,16 +9,11 @@ import { useSelect } from '@wordpress/data';
 import { box, comment, page } from '@wordpress/icons';
 import { createSlotFill } from '@wordpress/components';
 import { isWCAdmin } from '@woocommerce/navigation';
+import { activityPanelStore } from '@woocommerce/data';

 /**
  * Internal dependencies
  */
-import {
-	getLowStockCount,
-	getOrderStatuses,
-	getUnreadOrders,
-} from '~/homescreen/activity-panel/orders/utils';
-import { getUnapprovedReviews } from '~/homescreen/activity-panel/reviews/utils';
 import { Bell } from './icons/bell';
 import { isTaskListVisible } from '~/hooks/use-tasklists-state';

@@ -40,12 +35,12 @@ export const AbbreviatedNotificationsPanel = ( { thingsToDoNextCount } ) => {
 		isSetupTaskListHidden,
 		isExtendedTaskListHidden,
 	} = useSelect( ( select ) => {
-		const orderStatuses = getOrderStatuses( select );
+		const counts = select( activityPanelStore ).getActivityPanelCounts();

 		return {
-			ordersToProcessCount: getUnreadOrders( select, orderStatuses ),
-			reviewsToModerateCount: getUnapprovedReviews( select ),
-			stockNoticesCount: getLowStockCount( select ),
+			ordersToProcessCount: counts?.orders_to_fulfill_count ?? 0,
+			reviewsToModerateCount: counts?.reviews_to_moderate_count ?? 0,
+			stockNoticesCount: counts?.products_low_in_stock_count ?? 0,
 			isSetupTaskListHidden: ! isTaskListVisible( 'setup' ),
 			isExtendedTaskListHidden: ! isTaskListVisible( 'extended' ),
 		};
diff --git a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/index.js b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/index.js
index 71588d34aa0..a424e946277 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/index.js
+++ b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/index.js
@@ -10,7 +10,11 @@ import {
 	PanelRow,
 	__experimentalText as Text,
 } from '@wordpress/components';
-import { ordersStore, productsStore } from '@woocommerce/data';
+import {
+	activityPanelStore,
+	ordersStore,
+	productsStore,
+} from '@woocommerce/data';
 import { recordEvent } from '@woocommerce/tracks';
 import { useEffect } from '@wordpress/element';
 import { snakeCase } from 'lodash';
@@ -19,13 +23,8 @@ import { snakeCase } from 'lodash';
  * Internal dependencies
  */
 import './style.scss';
-import {
-	getLowStockCount,
-	getOrderStatuses,
-	getUnreadOrders,
-} from './orders/utils';
+import { getOrderStatuses } from './orders/utils';
 import { getAllPanels } from './panels';
-import { getUnapprovedReviews } from './reviews/utils';
 import { getUrlParams } from '../../utils';
 import { getAdminSetting } from '~/utils/admin-settings';
 import { isTaskListVisible } from '~/hooks/use-tasklists-state';
@@ -49,10 +48,13 @@ export const ActivityPanel = () => {
 		const totalOrderCount = getOrdersTotalCount( ORDERS_QUERY_PARAMS, 0 );
 		const orderStatuses = getOrderStatuses( select );
 		const reviewsEnabled = getAdminSetting( 'reviewsEnabled', 'no' );
-		const unreadOrdersCount = getUnreadOrders( select, orderStatuses );
 		const manageStock = getAdminSetting( 'manageStock', 'no' );
-		const lowStockProductsCount = getLowStockCount( select );
-		const unapprovedReviewsCount = getUnapprovedReviews( select );
+		const counts = select( activityPanelStore ).getActivityPanelCounts();
+		const unreadOrdersCount = counts?.orders_to_fulfill_count ?? null;
+		const lowStockProductsCount =
+			counts?.products_low_in_stock_count ?? null;
+		const unapprovedReviewsCount =
+			counts?.reviews_to_moderate_count ?? null;
 		const publishedProductCount = getProductsTotalCount(
 			PUBLISHED_PRODUCTS_QUERY_PARAMS,
 			0
diff --git a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/orders/utils.js b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/orders/utils.js
index add25bdb2d9..16b477b555c 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/orders/utils.js
+++ b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/orders/utils.js
@@ -1,52 +1,13 @@
 /**
  * External dependencies
  */
-import { settingsStore, itemsStore } from '@woocommerce/data';
+import { settingsStore } from '@woocommerce/data';

 /**
  * Internal dependencies
  */
 import { DEFAULT_ACTIONABLE_STATUSES } from '../../../analytics/settings/config';

-export function getUnreadOrders( select, orderStatuses ) {
-	if ( ! orderStatuses.length ) {
-		return 0;
-	}
-
-	const ordersQuery = {
-		page: 1,
-		per_page: 1, // Core endpoint requires per_page > 0.
-		status: orderStatuses,
-		_fields: [ 'id' ],
-	};
-
-	const defaultValue = null;
-
-	const { getItemsTotalCount, getItemsError, isResolving } =
-		select( itemsStore );
-
-	// Disable eslint rule requiring `totalOrders` to be defined below because the next two statements
-	// depend on `getItemsTotalCount` to have been called.
-	// eslint-disable-next-line @wordpress/no-unused-vars-before-return
-	const totalOrders = getItemsTotalCount(
-		'orders',
-		ordersQuery,
-		defaultValue
-	);
-	const isError = Boolean( getItemsError( 'orders', ordersQuery ) );
-	const isRequesting = isResolving( 'getItemsTotalCount', [
-		'orders',
-		ordersQuery,
-		defaultValue,
-	] );
-
-	if ( isError || isRequesting ) {
-		return null;
-	}
-
-	return totalOrders;
-}
-
 export function getOrderStatuses( select ) {
 	const { getSetting: getMutableSetting } = select( settingsStore );
 	const {
@@ -56,40 +17,8 @@ export function getOrderStatuses( select ) {
 	return orderStatuses;
 }

+// Still used by the Stock panel's own low-stock list fetch — the count
+// itself now comes from activityPanelStore's getActivityPanelCounts().
 export const getLowStockCountQuery = {
 	status: 'publish',
 };
-
-export function getLowStockCount( select ) {
-	const { getItemsTotalCount, getItemsError, isResolving } =
-		select( itemsStore );
-
-	const defaultValue = null;
-
-	// Disable eslint rule requiring `totalLowStockProducts` to be defined below because the next two statements
-	// depend on `getItemsTotalCount` to have been called.
-	// eslint-disable-next-line @wordpress/no-unused-vars-before-return
-	const totalLowStockProducts = getItemsTotalCount(
-		'products/count-low-in-stock',
-		getLowStockCountQuery,
-		defaultValue
-	);
-
-	const isError = Boolean(
-		getItemsError( 'products/count-low-in-stock', getLowStockCountQuery )
-	);
-	const isRequesting = isResolving( 'getItemsTotalCount', [
-		'products/count-low-in-stock',
-		getLowStockCountQuery,
-		defaultValue,
-	] );
-
-	if (
-		isError ||
-		( isRequesting && totalLowStockProducts === defaultValue )
-	) {
-		return null;
-	}
-
-	return totalLowStockProducts;
-}
diff --git a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/reviews/index.js b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/reviews/index.js
index 4f6ca2db57e..6504793b2f5 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/reviews/index.js
+++ b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/reviews/index.js
@@ -19,7 +19,7 @@ import {
 } from '@woocommerce/components';
 import { getAdminLink } from '@woocommerce/settings';
 import { get, isNull } from 'lodash';
-import { reviewsStore } from '@woocommerce/data';
+import { activityPanelStore, reviewsStore } from '@woocommerce/data';
 import { recordEvent } from '@woocommerce/tracks';
 import { CurrencyContext } from '@woocommerce/currency';

@@ -362,16 +362,21 @@ export default compose( [
 			isRequesting,
 		};
 	} ),
-	withDispatch( ( dispatch ) => {
+	withDispatch( ( dispatch, props ) => {
 		const { deleteReview, updateReview, invalidateResolution } =
 			dispatch( reviewsStore );
+		const { invalidateResolution: invalidateActivityPanel } =
+			dispatch( activityPanelStore );
 		const { createNotice } = dispatch( 'core/notices' );

 		const clearReviewsCache = () => {
 			invalidateResolution( 'getReviews', [ reviewsQuery ] );
-			invalidateResolution( 'getReviewsTotalCount', [
-				unapprovedReviewsQuery,
-			] );
+			if ( props.reviews && props.reviews.length < 2 ) {
+				invalidateResolution( 'getReviewsTotalCount', [
+					unapprovedReviewsQuery,
+				] );
+			}
+			invalidateActivityPanel( 'getActivityPanelCounts', [] );
 		};

 		return {
diff --git a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/reviews/utils.js b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/reviews/utils.js
index 558c10f63ad..307fcb4b373 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/reviews/utils.js
+++ b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/reviews/utils.js
@@ -1,10 +1,7 @@
-/**
- * External dependencies
- */
-import { reviewsStore } from '@woocommerce/data';
-
 export const REVIEW_PAGE_LIMIT = 5;

+// Still used for the Reviews panel's own cache invalidation — the count
+// itself now comes from activityPanelStore's getActivityPanelCounts().
 export const unapprovedReviewsQuery = {
 	page: 1,
 	per_page: 1,
@@ -12,20 +9,3 @@ export const unapprovedReviewsQuery = {
 	_embed: 1,
 	_fields: [ 'id' ],
 };
-export function getUnapprovedReviews( select ) {
-	const { getReviewsTotalCount, getReviewsError, isResolving } =
-		select( reviewsStore );
-
-	// eslint-disable-next-line @wordpress/no-unused-vars-before-return
-	const totalReviews = getReviewsTotalCount( unapprovedReviewsQuery );
-	const isError = Boolean( getReviewsError( unapprovedReviewsQuery ) );
-	const isRequesting = isResolving( 'getReviewsTotalCount', [
-		unapprovedReviewsQuery,
-	] );
-
-	if ( isError || ( isRequesting && totalReviews === undefined ) ) {
-		return null;
-	}
-
-	return totalReviews;
-}
diff --git a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/stock/index.js b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/stock/index.js
index 50c75b06f97..ab20770c2e9 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/stock/index.js
+++ b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/stock/index.js
@@ -6,7 +6,7 @@ import { compose } from '@wordpress/compose';
 import { withDispatch, withSelect } from '@wordpress/data';
 import PropTypes from 'prop-types';
 import { Section } from '@woocommerce/components';
-import { itemsStore } from '@woocommerce/data';
+import { activityPanelStore, itemsStore } from '@woocommerce/data';

 /**
  * Internal dependencies
@@ -39,7 +39,11 @@ export class StockPanel extends Component {
 	}

 	async updateStock( product, quantity ) {
-		const { invalidateResolution, updateProductStock } = this.props;
+		const {
+			invalidateResolution,
+			invalidateActivityPanel,
+			updateProductStock,
+		} = this.props;
 		const success = await updateProductStock( product, quantity );

 		if ( success ) {
@@ -53,6 +57,7 @@ export class StockPanel extends Component {
 				getLowStockCountQuery,
 				null,
 			] );
+			invalidateActivityPanel( 'getActivityPanelCounts', [] );
 		}

 		return success;
@@ -135,11 +140,14 @@ export default compose(
 	withDispatch( ( dispatch ) => {
 		const { invalidateResolution, updateProductStock } =
 			dispatch( itemsStore );
+		const { invalidateResolution: invalidateActivityPanel } =
+			dispatch( activityPanelStore );
 		const { createNotice } = dispatch( 'core/notices' );

 		return {
 			createNotice,
 			invalidateResolution,
+			invalidateActivityPanel,
 			updateProductStock,
 		};
 	} )
diff --git a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/stock/test/index.js b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/stock/test/index.js
index 81b5dc11bc9..c01a2b62fef 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/stock/test/index.js
+++ b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/stock/test/index.js
@@ -32,6 +32,7 @@ describe( 'StockPanel', () => {
 	it( 'should request more products when one is updated', async () => {
 		const createNotice = jest.fn();
 		const invalidateResolution = jest.fn();
+		const invalidateActivityPanel = jest.fn();
 		const updateProductStock = jest.fn().mockResolvedValue( true );

 		const { getByRole } = render(
@@ -49,6 +50,7 @@ describe( 'StockPanel', () => {
 					},
 				] }
 				invalidateResolution={ invalidateResolution }
+				invalidateActivityPanel={ invalidateActivityPanel }
 				updateProductStock={ updateProductStock }
 				createNotice={ createNotice }
 			/>
@@ -62,6 +64,10 @@ describe( 'StockPanel', () => {
 		await waitFor( () => {
 			expect( invalidateResolution ).toHaveBeenCalled();
 		} );
+		expect( invalidateActivityPanel ).toHaveBeenCalledWith(
+			'getActivityPanelCounts',
+			[]
+		);
 	} );
 	it( 'should record activity_panel_stock_update_stock Tracks event when Update stock is clicked', async () => {
 		const createNotice = jest.fn();
diff --git a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/test/index.js b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/test/index.js
index b71d85e3701..bfbafa936c7 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/test/index.js
+++ b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/test/index.js
@@ -47,11 +47,9 @@ jest.mock( '../panels', () => {
 	};
 } );

-// Mock the orders and order statuses.
+// Mock the order statuses.
 jest.mock( '../orders/utils', () => {
 	return {
-		getLowStockCount: jest.fn().mockImplementation( () => 0 ),
-		getUnreadOrders: jest.fn().mockImplementation( () => 100 ),
 		getOrderStatuses: jest.fn().mockImplementation( () => [ 'status' ] ),
 	};
 } );
diff --git a/plugins/woocommerce/src/Admin/API/ActivityPanelCounts.php b/plugins/woocommerce/src/Admin/API/ActivityPanelCounts.php
new file mode 100644
index 00000000000..519d6f07924
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/API/ActivityPanelCounts.php
@@ -0,0 +1,196 @@
+<?php
+/**
+ * REST API ActivityPanelCounts Controller
+ *
+ * Handles requests to /activity-panel/counts.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Admin\API;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * ActivityPanelCounts controller.
+ *
+ * @internal
+ */
+class ActivityPanelCounts extends \WC_REST_Data_Controller {
+
+	/**
+	 * Endpoint namespace.
+	 *
+	 * @var string
+	 */
+	protected $namespace = 'wc-analytics';
+
+	/**
+	 * Route base.
+	 *
+	 * @var string
+	 */
+	protected $rest_base = 'activity-panel/counts';
+
+	/**
+	 * Register routes.
+	 */
+	public function register_routes(): void {
+		register_rest_route(
+			$this->namespace,
+			'/' . $this->rest_base,
+			array(
+				array(
+					'methods'             => \WP_REST_Server::READABLE,
+					'callback'            => array( $this, 'get_counts' ),
+					'permission_callback' => array( $this, 'get_items_permissions_check' ),
+					'args'                => $this->get_counts_params(),
+				),
+				'schema' => array( $this, 'get_item_schema' ),
+			)
+		);
+	}
+
+	/**
+	 * Return the orders/reviews/low-stock counts used by the Activity Panel in one response,
+	 * instead of one request per count.
+	 *
+	 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
+	 * @return \WP_REST_Response
+	 */
+	public function get_counts( $request ) {
+		return rest_ensure_response(
+			array(
+				'orders_to_fulfill_count'     => $this->get_count_via(
+					'/wc-analytics/orders',
+					array(
+						'page'     => 1,
+						'per_page' => 1,
+						'status'   => $request->get_param( 'order_statuses' ),
+						'_fields'  => array( 'id' ),
+					)
+				),
+				'reviews_to_moderate_count'   => $this->get_count_via(
+					'/wc-analytics/products/reviews',
+					array(
+						'page'     => 1,
+						'per_page' => 1,
+						'status'   => $request->get_param( 'review_status' ),
+						'_fields'  => array( 'id' ),
+					)
+				),
+				'products_low_in_stock_count' => $this->get_count_via(
+					'/wc-analytics/products/count-low-in-stock',
+					array( 'status' => $request->get_param( 'product_status' ) )
+				),
+			)
+		);
+	}
+
+	/**
+	 * Run one of the existing count endpoints internally and read its total off the response,
+	 * so the counting logic itself (query building, permission checks) isn't duplicated here.
+	 *
+	 * @param string $route  REST route to call, e.g. '/wc-analytics/orders'.
+	 * @param array  $params Query params for the sub-request.
+	 * @return int|null Null when the sub-request failed, so callers can tell "unknown" apart from a real zero count.
+	 */
+	private function get_count_via( $route, $params ) {
+		$sub_request = new \WP_REST_Request( 'GET', $route );
+		foreach ( $params as $key => $value ) {
+			$sub_request->set_param( $key, $value );
+		}
+
+		$response = rest_do_request( $sub_request );
+
+		if ( $response->is_error() ) {
+			wc_get_logger()->warning(
+				sprintf( 'Activity Panel counts sub-request to %s failed.', $route ),
+				array( 'source' => 'activity-panel-counts' )
+			);
+			return null;
+		}
+
+		$headers = $response->get_headers();
+		if ( isset( $headers['X-WP-Total'] ) ) {
+			return (int) $headers['X-WP-Total'];
+		}
+
+		$data = $response->get_data();
+		return isset( $data['total'] ) ? (int) $data['total'] : null;
+	}
+
+	/**
+	 * Get the query params for the /activity-panel/counts endpoint.
+	 *
+	 * @return array
+	 */
+	public function get_counts_params() {
+		$params                   = array();
+		$params['context']        = $this->get_context_param( array( 'default' => 'view' ) );
+		$params['order_statuses'] = array(
+			'description'       => __( 'Order statuses counted as "to fulfill".', 'woocommerce' ),
+			'type'              => 'array',
+			'items'             => array( 'type' => 'string' ),
+			'default'           => $this->get_default_order_statuses(),
+			'sanitize_callback' => 'wp_parse_list',
+			'validate_callback' => 'rest_validate_request_arg',
+		);
+		$params['review_status']  = array(
+			'description'       => __( 'Review status counted as "to moderate".', 'woocommerce' ),
+			'type'              => 'string',
+			'default'           => 'hold',
+			'sanitize_callback' => 'sanitize_key',
+			'validate_callback' => 'rest_validate_request_arg',
+		);
+		$params['product_status'] = array(
+			'description'       => __( 'Product post status used for the low stock count.', 'woocommerce' ),
+			'type'              => 'string',
+			'default'           => 'publish',
+			'sanitize_callback' => 'sanitize_key',
+			'validate_callback' => 'rest_validate_request_arg',
+		);
+
+		return $params;
+	}
+
+	/**
+	 * Get the default order statuses counted as "to fulfill", matching the store's own
+	 * actionable order statuses setting.
+	 *
+	 * @return array
+	 */
+	private function get_default_order_statuses() {
+		$actionable = get_option( 'woocommerce_actionable_order_statuses', array() );
+		return is_array( $actionable ) && ! empty( $actionable ) ? $actionable : array( 'processing', 'on-hold' );
+	}
+
+	/**
+	 * Get the schema for the /activity-panel/counts response.
+	 *
+	 * @return array
+	 */
+	public function get_item_schema() {
+		$schema = array(
+			'$schema'    => 'http://json-schema.org/draft-04/schema#',
+			'title'      => 'activity_panel_counts',
+			'type'       => 'object',
+			'properties' => array(
+				'orders_to_fulfill_count'     => array(
+					'description' => __( 'Number of orders to fulfill. Null if the underlying sub-request failed.', 'woocommerce' ),
+					'type'        => array( 'integer', 'null' ),
+				),
+				'reviews_to_moderate_count'   => array(
+					'description' => __( 'Number of reviews awaiting moderation. Null if the underlying sub-request failed.', 'woocommerce' ),
+					'type'        => array( 'integer', 'null' ),
+				),
+				'products_low_in_stock_count' => array(
+					'description' => __( 'Number of products low in stock. Null if the underlying sub-request failed.', 'woocommerce' ),
+					'type'        => array( 'integer', 'null' ),
+				),
+			),
+		);
+
+		return $this->add_additional_fields_schema( $schema );
+	}
+}
diff --git a/plugins/woocommerce/src/Admin/API/Init.php b/plugins/woocommerce/src/Admin/API/Init.php
index 1de3bd82324..95050d88e54 100644
--- a/plugins/woocommerce/src/Admin/API/Init.php
+++ b/plugins/woocommerce/src/Admin/API/Init.php
@@ -153,6 +153,7 @@ class Init {
 			'Automattic\WooCommerce\Admin\API\ProductVariations',
 			'Automattic\WooCommerce\Admin\API\ProductReviews',
 			'Automattic\WooCommerce\Admin\API\ProductsLowInStock',
+			'Automattic\WooCommerce\Admin\API\ActivityPanelCounts',
 			'Automattic\WooCommerce\Admin\API\SettingOptions',
 			'Automattic\WooCommerce\Admin\API\Taxes',
 		);
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/ActivityPanelCountsTest.php b/plugins/woocommerce/tests/php/src/Admin/API/ActivityPanelCountsTest.php
new file mode 100644
index 00000000000..b27469fec97
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/API/ActivityPanelCountsTest.php
@@ -0,0 +1,184 @@
+<?php
+/**
+ * Test the API controller class that handles the /activity-panel/counts REST response.
+ *
+ * @package WooCommerce\Admin\Tests\Admin\API
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\API;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Enums\ProductStatus;
+use WC_Helper_Order;
+use WC_Helper_Product;
+use WC_REST_Unit_Test_Case;
+use WP_Error;
+use WP_REST_Request;
+
+/**
+ * ActivityPanelCounts API controller test.
+ *
+ * @class ActivityPanelCountsTest.
+ */
+class ActivityPanelCountsTest extends WC_REST_Unit_Test_Case {
+
+	/**
+	 * Endpoint.
+	 *
+	 * @var string
+	 */
+	const ENDPOINT = '/wc-analytics/activity-panel/counts';
+
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->user = $this->factory->user->create(
+			array(
+				'role' => 'administrator',
+			)
+		);
+	}
+
+	/**
+	 * Create an unapproved (hold) product review.
+	 *
+	 * @param int $product_id Product ID.
+	 * @return int Comment ID.
+	 */
+	private function create_unapproved_review( $product_id ) {
+		return wp_insert_comment(
+			array(
+				'comment_post_ID'      => $product_id,
+				'comment_author'       => 'shopper',
+				'comment_author_email' => 'shopper@example.com',
+				'comment_content'      => 'Awaiting moderation.',
+				'comment_approved'     => 0,
+				'comment_type'         => 'review',
+			)
+		);
+	}
+
+	/**
+	 * Test that the response has the expected shape and counts for a store manager.
+	 */
+	public function test_returns_counts_for_manager() {
+		wp_set_current_user( $this->user );
+
+		WC_Helper_Order::create_order( 1, null, array( 'status' => OrderStatus::PROCESSING ) );
+		WC_Helper_Order::create_order( 1, null, array( 'status' => OrderStatus::ON_HOLD ) );
+		WC_Helper_Order::create_order( 1, null, array( 'status' => OrderStatus::COMPLETED ) );
+
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_manage_stock( true );
+		$product->set_low_stock_amount( 2 );
+		$product->set_stock_quantity( 1 );
+		$product->save();
+
+		$this->create_unapproved_review( $product->get_id() );
+
+		$request  = new WP_REST_Request( 'GET', self::ENDPOINT );
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals( 2, $data['orders_to_fulfill_count'] );
+		$this->assertEquals( 1, $data['reviews_to_moderate_count'] );
+		$this->assertEquals( 1, $data['products_low_in_stock_count'] );
+	}
+
+	/**
+	 * Test that a user without manage_woocommerce is denied.
+	 */
+	public function test_permission_denied_for_user_without_manage_woocommerce() {
+		$subscriber = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+		wp_set_current_user( $subscriber );
+
+		$request  = new WP_REST_Request( 'GET', self::ENDPOINT );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 403, $response->get_status() );
+	}
+
+	/**
+	 * Test that a custom order_statuses param is respected instead of the default.
+	 */
+	public function test_custom_order_statuses_param_is_respected() {
+		wp_set_current_user( $this->user );
+
+		WC_Helper_Order::create_order( 1, null, array( 'status' => OrderStatus::PROCESSING ) );
+		WC_Helper_Order::create_order( 1, null, array( 'status' => OrderStatus::ON_HOLD ) );
+
+		$request = new WP_REST_Request( 'GET', self::ENDPOINT );
+		$request->set_param( 'order_statuses', array( OrderStatus::PROCESSING ) );
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals( 1, $data['orders_to_fulfill_count'] );
+	}
+
+	/**
+	 * Test that the low stock count matches the existing dedicated endpoint, i.e. this
+	 * controller isn't diverging from the counting logic it delegates to.
+	 */
+	public function test_low_stock_count_matches_dedicated_endpoint() {
+		wp_set_current_user( $this->user );
+
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_manage_stock( true );
+		$product->set_low_stock_amount( 5 );
+		$product->set_stock_quantity( 3 );
+		$product->save();
+
+		$counts_request  = new WP_REST_Request( 'GET', self::ENDPOINT );
+		$counts_response = $this->server->dispatch( $counts_request );
+		$counts_data     = $counts_response->get_data();
+
+		$dedicated_request = new WP_REST_Request( 'GET', '/wc-analytics/products/count-low-in-stock' );
+		$dedicated_request->set_param( 'status', ProductStatus::PUBLISH );
+		$dedicated_response = $this->server->dispatch( $dedicated_request );
+		$dedicated_data     = $dedicated_response->get_data();
+
+		$this->assertEquals(
+			$dedicated_data['total'],
+			$counts_data['products_low_in_stock_count']
+		);
+	}
+
+	/**
+	 * Test that a failed sub-request yields null for that count, not 0, so a merchant can't
+	 * mistake "sub-request failed" for a genuine zero count.
+	 */
+	public function test_failed_sub_request_returns_null_not_zero() {
+		wp_set_current_user( $this->user );
+
+		WC_Helper_Order::create_order( 1, null, array( 'status' => OrderStatus::PROCESSING ) );
+
+		$failing_route = '/wc-analytics/products/count-low-in-stock';
+		$force_error   = function ( $result, $server, $request ) use ( $failing_route ) {
+			if ( $request->get_route() === $failing_route ) {
+				return new WP_Error( 'test_forced_failure', 'Forced failure for test.', array( 'status' => 500 ) );
+			}
+			return $result;
+		};
+
+		add_filter( 'rest_pre_dispatch', $force_error, 10, 3 );
+
+		try {
+			$request  = new WP_REST_Request( 'GET', self::ENDPOINT );
+			$response = $this->server->dispatch( $request );
+			$data     = $response->get_data();
+		} finally {
+			remove_filter( 'rest_pre_dispatch', $force_error, 10 );
+		}
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertNull( $data['products_low_in_stock_count'] );
+		$this->assertEquals( 1, $data['orders_to_fulfill_count'] );
+	}
+}