From 60b0fd28c3771efef10346278bb18a05891a11b3 Mon Sep 17 00:00:00 2001
From: Matthieu Hochlander 
Date: Mon, 15 Sep 2025 18:52:45 +0200
Subject: [PATCH] Add empty property to ListBase and InfiniteListBase.
---
 docs/ListBase.md                              |  1 +
 .../controller/list/InfiniteListBase.spec.tsx |  9 +++++
 .../list/InfiniteListBase.stories.tsx         | 33 +++++++++++++++++
 .../src/controller/list/InfiniteListBase.tsx  | 32 ++++++++++++++--
 .../src/controller/list/ListBase.spec.tsx     |  8 ++++
 .../src/controller/list/ListBase.stories.tsx  | 33 +++++++++++++++++
 .../ra-core/src/controller/list/ListBase.tsx  | 37 ++++++++++++++++---
 .../src/list/InfiniteList.tsx                 |  5 ++-
 packages/ra-ui-materialui/src/list/List.tsx   |  2 +-
 9 files changed, 150 insertions(+), 10 deletions(-)
diff --git a/docs/ListBase.md b/docs/ListBase.md
index e361ec0f7ed..db7ff3cd737 100644
--- a/docs/ListBase.md
+++ b/docs/ListBase.md
@@ -84,6 +84,7 @@ The `` component accepts the following props:
 * [`debounce`](./List.md#debounce)
 * [`disableAuthentication`](./List.md#disableauthentication)
 * [`disableSyncWithLocation`](./List.md#disablesyncwithlocation)
+* [`empty`](./List.md#empty)
 * [`emptyWhileLoading`](./List.md#emptywhileloading)
 * [`exporter`](./List.md#exporter)
 * [`filter`](./List.md#filter-permanent-filter)
diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx
index f02f440b58d..f91c9d2bfb2 100644
--- a/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx
+++ b/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx
@@ -11,6 +11,7 @@ import {
 } from './InfiniteListBase.stories';
 import { fireEvent, render, screen, waitFor } from '@testing-library/react';
 import { testDataProvider } from '../../dataProvider';
+import { Empty } from './ListBase.stories';
 
 describe('InfiniteListBase', () => {
     it('should fetch a list of records on mount, put it in a ListContext, and render its children', async () => {
@@ -160,6 +161,14 @@ describe('InfiniteListBase', () => {
         expect(screen.queryByText('Loading...')).toBeNull();
     });
 
+    it('should render a custom empty component when data is empty', async () => {
+        render();
+        expect(screen.queryByText('Loading...')).not.toBeNull();
+        expect(screen.queryByText('War and Peace')).toBeNull();
+        fireEvent.click(screen.getByText('Resolve books loading'));
+        await screen.findByText('No books');
+    });
+
     it('should render loading component while loading', async () => {
         render();
         expect(screen.queryByText('Loading books...')).not.toBeNull();
diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx
index d31157a6285..1a8e86db136 100644
--- a/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx
+++ b/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx
@@ -252,6 +252,39 @@ export const FetchError = () => {
     );
 };
 
+export const Empty = () => {
+    let resolveGetList: (() => void) | null = null;
+    const dataProvider = {
+        ...defaultDataProvider,
+        getList: () => {
+            return new Promise(resolve => {
+                resolveGetList = () => resolve({ data: [], total: 0 });
+            });
+        },
+    };
+
+    return (
+        
+            
+            Loading...
}
+                empty={No books
}
+            >
+                
+                
+            
+        
+    );
+};
+
 const defaultI18nProvider = polyglotI18nProvider(
     locale =>
         locale === 'fr'
diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.tsx
index 122b92a246b..b27bf722ca3 100644
--- a/packages/ra-core/src/controller/list/InfiniteListBase.tsx
+++ b/packages/ra-core/src/controller/list/InfiniteListBase.tsx
@@ -50,6 +50,7 @@ export const InfiniteListBase = ({
     loading,
     offline,
     error,
+    empty,
     children,
     render,
     ...props
@@ -71,6 +72,11 @@ export const InfiniteListBase = ({
         isPending,
         isPlaceholderData,
         error: errorState,
+        data,
+        total,
+        hasPreviousPage,
+        hasNextPage,
+        filterValues,
     } = controllerProps;
 
     const showAuthLoading =
@@ -95,6 +101,23 @@ export const InfiniteListBase = ({
 
     const showError = errorState && error !== false && error !== undefined;
 
+    const showEmpty =
+        !errorState &&
+        // the list is not loading data for the first time
+        !isPending &&
+        // the API returned no data (using either normal or partial pagination)
+        (total === 0 ||
+            (total == null &&
+                hasPreviousPage === false &&
+                hasNextPage === false &&
+                // @ts-ignore FIXME total may be undefined when using partial pagination but the ListControllerResult type is wrong about it
+                data.length === 0)) &&
+        // the user didn't set any filters
+        !Object.keys(filterValues).length &&
+        // there is an empty page component
+        empty !== undefined &&
+        empty !== false;
+
     return (
         // We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided
         
@@ -118,9 +141,11 @@ export const InfiniteListBase = ({
                             ? offline
                             : showError
                               ? error
-                              : render
-                                ? render(controllerProps)
-                                : children}
+                              : showEmpty
+                                ? empty
+                                : render
+                                  ? render(controllerProps)
+                                  : children}
                 
             
         
@@ -133,6 +158,7 @@ export interface InfiniteListBaseProps
     loading?: ReactNode;
     offline?: ReactNode;
     error?: ReactNode;
+    empty?: ReactNode;
     children?: ReactNode;
     render?: (props: InfiniteListControllerResult) => ReactNode;
 }
diff --git a/packages/ra-core/src/controller/list/ListBase.spec.tsx b/packages/ra-core/src/controller/list/ListBase.spec.tsx
index f0459c30aef..bde8bef3d31 100644
--- a/packages/ra-core/src/controller/list/ListBase.spec.tsx
+++ b/packages/ra-core/src/controller/list/ListBase.spec.tsx
@@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
 import {
     AccessControl,
     DefaultTitle,
+    Empty,
     EmptyWhileLoading,
     EmptyWhileLoadingRender,
     FetchError,
@@ -168,6 +169,13 @@ describe('ListBase', () => {
         await screen.findByText('War and Peace');
         await screen.findByText('You are offline, the data may be outdated');
     });
+    it('should render a custom empty component when data is empty', async () => {
+        render();
+        expect(screen.queryByText('Loading...')).not.toBeNull();
+        expect(screen.queryByText('War and Peace')).toBeNull();
+        fireEvent.click(screen.getByText('Resolve books loading'));
+        await screen.findByText('No books');
+    });
     it('should render nothing while loading if emptyWhileLoading is set to true', async () => {
         render();
         expect(screen.queryByText('Loading...')).toBeNull();
diff --git a/packages/ra-core/src/controller/list/ListBase.stories.tsx b/packages/ra-core/src/controller/list/ListBase.stories.tsx
index c3677f6dc36..7fed25f2d6a 100644
--- a/packages/ra-core/src/controller/list/ListBase.stories.tsx
+++ b/packages/ra-core/src/controller/list/ListBase.stories.tsx
@@ -503,6 +503,39 @@ export const FetchError = () => {
     );
 };
 
+export const Empty = () => {
+    let resolveGetList: (() => void) | null = null;
+    const baseProvider = defaultDataProvider(0);
+    const dataProvider = {
+        ...baseProvider,
+        getList: () => {
+            return new Promise(resolve => {
+                resolveGetList = () => resolve({ data: [], total: 0 });
+            });
+        },
+    };
+
+    return (
+        
+            
+            Loading...}
+                empty={No books
}
+            >
+                
+            
+        
+    );
+};
+
 export const EmptyWhileLoading = () => {
     let resolveGetList: (() => void) | null = null;
     const baseProvider = defaultDataProvider(0);
diff --git a/packages/ra-core/src/controller/list/ListBase.tsx b/packages/ra-core/src/controller/list/ListBase.tsx
index dc5daa332ca..990e65d6d3d 100644
--- a/packages/ra-core/src/controller/list/ListBase.tsx
+++ b/packages/ra-core/src/controller/list/ListBase.tsx
@@ -51,6 +51,7 @@ export const ListBase = ({
     loading,
     offline,
     error,
+    empty,
     render,
     ...props
 }: ListBaseProps) => {
@@ -71,6 +72,11 @@ export const ListBase = ({
         isPending,
         isPlaceholderData,
         error: errorState,
+        data,
+        total,
+        hasPreviousPage,
+        hasNextPage,
+        filterValues,
     } = controllerProps;
 
     const showAuthLoading =
@@ -95,7 +101,25 @@ export const ListBase = ({
 
     const showError = errorState && error !== false && error !== undefined;
 
-    const showEmpty = isPending && !showOffline && emptyWhileLoading === true;
+    const showEmptyWhileLoading =
+        isPending && !showOffline && emptyWhileLoading === true;
+
+    const showEmpty =
+        !errorState &&
+        // the list is not loading data for the first time
+        !isPending &&
+        // the API returned no data (using either normal or partial pagination)
+        (total === 0 ||
+            (total == null &&
+                hasPreviousPage === false &&
+                hasNextPage === false &&
+                // @ts-ignore FIXME total may be undefined when using partial pagination but the ListControllerResult type is wrong about it
+                data.length === 0)) &&
+        // the user didn't set any filters
+        !Object.keys(filterValues).length &&
+        // there is an empty page component
+        empty !== undefined &&
+        empty !== false;
 
     return (
         // We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided
@@ -109,11 +133,13 @@ export const ListBase = ({
                         ? offline
                         : showError
                           ? error
-                          : showEmpty
+                          : showEmptyWhileLoading
                             ? null
-                            : render
-                              ? render(controllerProps)
-                              : children}
+                            : showEmpty
+                              ? empty
+                              : render
+                                ? render(controllerProps)
+                                : children}
             
         
     );
@@ -126,6 +152,7 @@ export interface ListBaseProps
     loading?: ReactNode;
     offline?: ReactNode;
     error?: ReactNode;
+    empty?: ReactNode;
     children?: ReactNode;
     render?: (props: ListControllerResult) => ReactNode;
 }
diff --git a/packages/ra-ui-materialui/src/list/InfiniteList.tsx b/packages/ra-ui-materialui/src/list/InfiniteList.tsx
index f960728d210..23df37a782d 100644
--- a/packages/ra-ui-materialui/src/list/InfiniteList.tsx
+++ b/packages/ra-ui-materialui/src/list/InfiniteList.tsx
@@ -118,7 +118,10 @@ const defaultFilter = {};
 const defaultAuthLoading = ;
 
 export interface InfiniteListProps
-    extends Omit, 'children' | 'render'>,
+    extends Omit<
+            InfiniteListBaseProps,
+            'children' | 'render' | 'empty'
+        >,
         ListViewProps {}
 
 const PREFIX = 'RaInfiniteList';
diff --git a/packages/ra-ui-materialui/src/list/List.tsx b/packages/ra-ui-materialui/src/list/List.tsx
index 03349a90424..d45f6a1081a 100644
--- a/packages/ra-ui-materialui/src/list/List.tsx
+++ b/packages/ra-ui-materialui/src/list/List.tsx
@@ -111,7 +111,7 @@ export const List = (
 };
 
 export interface ListProps
-    extends Omit, 'children' | 'render'>,
+    extends Omit, 'children' | 'render' | 'empty'>,
         ListViewProps {}
 
 const defaultFilter = {};