@@ -301,6 +323,24 @@ export interface ListViewProps {
*/
filters?: ReactElement | ReactElement[];
+ /**
+ * The offline component to display. defaults to
+ *
+ * @see https://marmelab.com/react-admin/List.html#offline
+ * @example
+ * import { List } from 'react-admin';
+ * import { Alert } from '@mui/material';
+ *
+ * const offline = No internet connection. Could not load data.;
+ *
+ * export const PostList = () => (
+ *
+ * ...
+ *
+ * );
+ */
+ offline?: ReactNode | false;
+
/**
* The pagination component to display. defaults to
*
@@ -360,6 +400,7 @@ export const ListClasses = {
main: `${PREFIX}-main`,
content: `${PREFIX}-content`,
actions: `${PREFIX}-actions`,
+ noActions: `${PREFIX}-noActions`,
noResults: `${PREFIX}-noResults`,
};
@@ -383,6 +424,9 @@ const Root = styled('div', {
overflow: 'inherit',
},
+ [`& .${ListClasses.noActions}`]: {
+ marginTop: '1em',
+ },
[`& .${ListClasses.actions}`]: {},
[`& .${ListClasses.noResults}`]: {
From 845152fc8ee9902bcff136b022944c60e76a933e Mon Sep 17 00:00:00 2001
From: Gildas <1122076+djhi@users.noreply.github.com>
Date: Thu, 28 Aug 2025 10:13:46 +0200
Subject: [PATCH 2/5] Fix regression on disableAuthentication
---
packages/ra-core/src/controller/list/ListBase.tsx | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/packages/ra-core/src/controller/list/ListBase.tsx b/packages/ra-core/src/controller/list/ListBase.tsx
index 5d5fba934f3..c4417e58bd9 100644
--- a/packages/ra-core/src/controller/list/ListBase.tsx
+++ b/packages/ra-core/src/controller/list/ListBase.tsx
@@ -46,7 +46,6 @@ import { useIsAuthPending } from '../../auth';
*/
export const ListBase = ({
children,
- disableAuthentication,
render,
loading,
offline,
@@ -66,7 +65,7 @@ export const ListBase = ({
const showLoading =
isAuthPending &&
- !disableAuthentication &&
+ !props.disableAuthentication &&
loading !== undefined &&
loading !== false;
From 0678a7ef3d9e50069896e6e76753af39489ebfd8 Mon Sep 17 00:00:00 2001
From: Gildas <1122076+djhi@users.noreply.github.com>
Date: Thu, 28 Aug 2025 14:58:29 +0200
Subject: [PATCH 3/5] Add offline support to `` and
``
---
docs/InfiniteList.md | 44 +++++++++
.../list/InfiniteListBase.stories.tsx | 92 ++++++++++++++++++-
.../src/controller/list/InfiniteListBase.tsx | 31 +++++--
.../list/useInfiniteListController.ts | 4 +
.../src/list/InfiniteList.stories.tsx | 90 +++++++++++++++++-
.../src/list/InfiniteList.tsx | 2 +
packages/ra-ui-materialui/src/list/List.tsx | 2 +-
.../ra-ui-materialui/src/list/ListView.tsx | 2 +-
.../list/pagination/InfinitePagination.tsx | 30 +++++-
9 files changed, 283 insertions(+), 14 deletions(-)
diff --git a/docs/InfiniteList.md b/docs/InfiniteList.md
index 2b8ff6cf84f..584995dd8bb 100644
--- a/docs/InfiniteList.md
+++ b/docs/InfiniteList.md
@@ -72,6 +72,7 @@ The props are the same as [the `` component](./List.md):
| `filters` | Optional | `ReactElement` | - | The filters to display in the toolbar. |
| `filter` | Optional | `object` | - | The permanent filter values. |
| `filter DefaultValues` | Optional | `object` | - | The default filter values. |
+| `offline` | Optional | `ReactNode` | `` | The component to render when there is no connectivity and there is no data in the cache |
| `pagination` | Optional | `ReactElement` | `` | The pagination component to use. |
| `perPage` | Optional | `number` | `10` | The number of records to fetch per page. |
| `queryOptions` | Optional | `object` | - | The options to pass to the `useQuery` hook. |
@@ -85,6 +86,49 @@ Check the [`` component](./List.md) for details about each prop.
Additional props are passed down to the root component (a MUI `` by default).
+## `offline`
+
+By default, `` renders the `` component when there is no connectivity and there are no records in the cache yet for the current parameters (page, sort, etc.). You can provide your own component via the `offline` prop:
+
+```jsx
+import { InfiniteList, InfinitePagination } from 'react-admin';
+import { Alert } from '@mui/material';
+
+const offline = No network. Could not load the posts.;
+// The offline component may be displayed at the bottom of the page if the network connectivity is lost
+// when loading new pages. Make sure you pass your custom offline component here too
+const pagination = ;
+
+export const PostList = () => (
+
+ ...
+
+);
+```
+
+**Tip**: If the record is in the Tanstack Query cache but you want to warn the user that they may see an outdated version, you can use the `` component:
+
+```jsx
+import { InfiniteList, InfinitePagination, IsOffline } from 'react-admin';
+import { Alert } from '@mui/material';
+
+const offline = No network. Could not load the posts.;
+// The offline component may be displayed at the bottom of the page if the network connectivity is lost
+// when loading new pages. Make sure you pass your custom offline component here too
+const pagination = ;
+
+export const PostList = () => (
+
+
+
+ You are offline, the data may be outdated
+
+
+ ...
+
+);
+```
+
## `pagination`
You can replace the default "load on scroll" pagination (triggered by a component named ``) by a custom pagination component. To get the pagination state and callbacks, you'll need to read the `InfinitePaginationContext`.
diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx
index 3c23bb6a8c8..8a61af078de 100644
--- a/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx
+++ b/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx
@@ -3,7 +3,7 @@ import fakeRestProvider from 'ra-data-fakerest';
import englishMessages from 'ra-language-english';
import frenchMessages from 'ra-language-french';
import polyglotI18nProvider from 'ra-i18n-polyglot';
-import { InfiniteListBase } from './InfiniteListBase';
+import { InfiniteListBase, InfiniteListBaseProps } from './InfiniteListBase';
import { CoreAdminContext } from '../../core';
import { useListContext } from './useListContext';
import { useInfinitePaginationContext } from './useInfinitePaginationContext';
@@ -11,9 +11,12 @@ import {
AuthProvider,
DataProvider,
I18nProvider,
+ IsOffline,
mergeTranslations,
+ TestMemoryRouter,
useLocaleState,
} from '../..';
+import { onlineManager } from '@tanstack/react-query';
export default {
title: 'ra-core/controller/list/InfiniteListBase',
@@ -314,6 +317,93 @@ export const WithRenderProps = () => (
);
+export const Offline = ({
+ dataProvider = defaultDataProvider,
+ isOnline = true,
+ ...props
+}: {
+ dataProvider?: DataProvider;
+ isOnline?: boolean;
+} & Partial) => {
+ React.useEffect(() => {
+ onlineManager.setOnline(isOnline);
+ }, [isOnline]);
+ return (
+
+
+ You are offline, cannot load data}
+ render={controllerProps => {
+ const {
+ data,
+ error,
+ isPending,
+ page,
+ perPage,
+ setPage,
+ total,
+ } = controllerProps;
+ if (isPending) {
+ return
Loading...
;
+ }
+ if (error) {
+ return
Error...
;
+ }
+
+ return (
+
+
+ Use the story controls to simulate offline
+ mode:
+
+ );
+ }}
+ />
+
+
+ );
+};
+
+Offline.args = {
+ isOnline: true,
+};
+
+Offline.argTypes = {
+ isOnline: {
+ control: { type: 'boolean' },
+ },
+};
+
const Title = () => {
const { defaultTitle } = useListContext();
const [locale, setLocale] = useLocaleState();
diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.tsx
index b9c7e511860..e57697f3dc8 100644
--- a/packages/ra-core/src/controller/list/InfiniteListBase.tsx
+++ b/packages/ra-core/src/controller/list/InfiniteListBase.tsx
@@ -48,7 +48,8 @@ import { useIsAuthPending } from '../../auth';
export const InfiniteListBase = ({
children,
render,
- loading = null,
+ loading,
+ offline,
...props
}: InfiniteListBaseProps) => {
const controllerProps = useInfiniteListController(props);
@@ -57,16 +58,27 @@ export const InfiniteListBase = ({
action: 'list',
});
- if (isAuthPending && !props.disableAuthentication) {
- return loading;
- }
-
if (!render && !children) {
throw new Error(
" requires either a 'render' prop or 'children' prop"
);
}
+ const showLoading =
+ isAuthPending &&
+ !props.disableAuthentication &&
+ loading !== undefined &&
+ loading !== false;
+
+ const { isPaused, isPending, isPlaceholderData } = controllerProps;
+ const showOffline =
+ isPaused &&
+ // If isPending and isPaused are true, we are offline and couldn't even load the initial data
+ // If isPaused and isPlaceholderData are true, we are offline and couldn't even load data with different parameters on the same useQuery observer
+ (isPending || isPlaceholderData) &&
+ offline !== undefined &&
+ offline !== false;
+
return (
// We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided
@@ -82,7 +94,13 @@ export const InfiniteListBase = ({
controllerProps.isFetchingPreviousPage,
}}
>
- {render ? render(controllerProps) : children}
+ {showLoading
+ ? loading
+ : showOffline
+ ? offline
+ : render
+ ? render(controllerProps)
+ : children}
@@ -93,5 +111,6 @@ export interface InfiniteListBaseProps
extends InfiniteListControllerProps {
loading?: ReactNode;
children?: ReactNode;
+ offline?: ReactNode;
render?: (props: InfiniteListControllerResult) => ReactNode;
}
diff --git a/packages/ra-core/src/controller/list/useInfiniteListController.ts b/packages/ra-core/src/controller/list/useInfiniteListController.ts
index 8214e3ba9d0..23662d68042 100644
--- a/packages/ra-core/src/controller/list/useInfiniteListController.ts
+++ b/packages/ra-core/src/controller/list/useInfiniteListController.ts
@@ -102,7 +102,9 @@ export const useInfiniteListController = <
total,
error,
isLoading,
+ isPaused,
isPending,
+ isPlaceholderData,
isFetching,
hasNextPage,
hasPreviousPage,
@@ -204,7 +206,9 @@ export const useInfiniteListController = <
hideFilter: queryModifiers.hideFilter,
isFetching,
isLoading,
+ isPaused,
isPending,
+ isPlaceholderData,
onSelect: selectionModifiers.select,
onSelectAll,
onToggleItem: selectionModifiers.toggle,
diff --git a/packages/ra-ui-materialui/src/list/InfiniteList.stories.tsx b/packages/ra-ui-materialui/src/list/InfiniteList.stories.tsx
index 7aeba9b2dd1..f5cfc5d0bf7 100644
--- a/packages/ra-ui-materialui/src/list/InfiniteList.stories.tsx
+++ b/packages/ra-ui-materialui/src/list/InfiniteList.stories.tsx
@@ -7,8 +7,16 @@ import {
useListContext,
useInfinitePaginationContext,
TestMemoryRouter,
+ IsOffline,
} from 'ra-core';
-import { Box, Button, Card, ThemeOptions, Typography } from '@mui/material';
+import {
+ Alert,
+ Box,
+ Button,
+ Card,
+ ThemeOptions,
+ Typography,
+} from '@mui/material';
import { InfiniteList } from './InfiniteList';
import { SimpleList } from './SimpleList';
import { DataTable, type DataTableProps } from './datatable';
@@ -24,6 +32,7 @@ import { TopToolbar, Layout } from '../layout';
import { BulkActionsToolbar } from './BulkActionsToolbar';
import { deepmerge } from '@mui/utils';
import { defaultLightTheme } from '../theme';
+import { onlineManager } from '@tanstack/react-query';
export default {
title: 'ra-ui-materialui/list/InfiniteList',
@@ -522,3 +531,82 @@ export const WithRenderProp = () => (
/>
);
+
+export const Offline = ({
+ isOnline = true,
+ offline,
+ pagination,
+}: {
+ isOnline?: boolean;
+ offline?: React.ReactNode;
+ pagination?: React.ReactNode;
+}) => {
+ React.useEffect(() => {
+ onlineManager.setOnline(isOnline);
+ }, [isOnline]);
+ return (
+
+ (
+
+
+
+ )}
+ />
+
+ );
+};
+
+const BookListOffline = () => {
+ const { error, isPending } = useListContext();
+ if (isPending) {
+ return
Loading...
;
+ }
+ if (error) {
+ return
Error: {error.message}
;
+ }
+ return (
+ <>
+
+
+ You are offline, the data may be outdated
+
+
+
+ >
+ );
+};
+
+const CustomOffline = () => {
+ return You are offline!;
+};
+
+Offline.args = {
+ isOnline: true,
+ offline: 'default',
+ pagination: 'infinite',
+};
+
+Offline.argTypes = {
+ isOnline: {
+ control: { type: 'boolean' },
+ },
+ pagination: {
+ control: { type: 'radio' },
+ options: ['infinite', 'classic'],
+ mapping: {
+ infinite: ,
+ classic: ,
+ },
+ },
+ offline: {
+ name: 'Offline component',
+ control: { type: 'radio' },
+ options: ['default', 'custom'],
+ mapping: {
+ default: undefined,
+ custom: ,
+ },
+ },
+};
diff --git a/packages/ra-ui-materialui/src/list/InfiniteList.tsx b/packages/ra-ui-materialui/src/list/InfiniteList.tsx
index 54248d269e7..349b51a8780 100644
--- a/packages/ra-ui-materialui/src/list/InfiniteList.tsx
+++ b/packages/ra-ui-materialui/src/list/InfiniteList.tsx
@@ -103,6 +103,8 @@ export const InfiniteList = (
resource={resource}
sort={sort}
storeKey={storeKey}
+ // Disable offline support from InfiniteListBase as it is handled by ListView to keep the ListView container
+ offline={false}
>
{...rest} pagination={pagination} />
diff --git a/packages/ra-ui-materialui/src/list/List.tsx b/packages/ra-ui-materialui/src/list/List.tsx
index 6446d6bd6f1..082be5293bf 100644
--- a/packages/ra-ui-materialui/src/list/List.tsx
+++ b/packages/ra-ui-materialui/src/list/List.tsx
@@ -100,7 +100,7 @@ export const List = (
resource={resource}
sort={sort}
storeKey={storeKey}
- // Disable offline support from ShowBase as it is handled by ShowView to keep the ShowView container
+ // Disable offline support from ListBase as it is handled by ListView to keep the ListView container
offline={false}
>
{...rest} render={render} />
diff --git a/packages/ra-ui-materialui/src/list/ListView.tsx b/packages/ra-ui-materialui/src/list/ListView.tsx
index 16f59f92151..8dfd7dabbb4 100644
--- a/packages/ra-ui-materialui/src/list/ListView.tsx
+++ b/packages/ra-ui-materialui/src/list/ListView.tsx
@@ -356,7 +356,7 @@ export interface ListViewProps {
*
* );
*/
- pagination?: ReactElement | false;
+ pagination?: ReactNode | false;
/**
* The page title (main title) to display above the data. Defaults to the humanized resource name.
diff --git a/packages/ra-ui-materialui/src/list/pagination/InfinitePagination.tsx b/packages/ra-ui-materialui/src/list/pagination/InfinitePagination.tsx
index f1be231cddd..a562b97d5fd 100644
--- a/packages/ra-ui-materialui/src/list/pagination/InfinitePagination.tsx
+++ b/packages/ra-ui-materialui/src/list/pagination/InfinitePagination.tsx
@@ -6,6 +6,7 @@ import {
useEvent,
} from 'ra-core';
import { Box, CircularProgress, type SxProps, type Theme } from '@mui/material';
+import { Offline } from '../../Offline';
/**
* A pagination component that loads more results when the user scrolls to the bottom of the list.
@@ -25,10 +26,11 @@ import { Box, CircularProgress, type SxProps, type Theme } from '@mui/material';
* );
*/
export const InfinitePagination = ({
+ offline = defaultOffline,
options = defaultOptions,
sx,
}: InfinitePaginationProps) => {
- const { isPending } = useListContext();
+ const { isPaused, isPending } = useListContext();
const { fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfinitePaginationContext();
@@ -38,17 +40,26 @@ export const InfinitePagination = ({
);
}
+ const [hasRequestedNextPage, setHasRequestedNextPage] =
+ React.useState(false);
const observerElem = useRef(null);
-
const handleObserver = useEvent<[IntersectionObserverEntry[]], void>(
entries => {
const [target] = entries;
if (target.isIntersecting && hasNextPage && !isFetchingNextPage) {
+ setHasRequestedNextPage(true);
fetchNextPage();
}
}
);
+ useEffect(() => {
+ // Whenever the query is unpaused, reset the requested next page state
+ if (!isPaused) {
+ setHasRequestedNextPage(false);
+ }
+ }, [isPaused]);
+
useEffect(() => {
const element = observerElem.current;
if (!element) return;
@@ -66,6 +77,13 @@ export const InfinitePagination = ({
if (isPending) return null;
+ const showOffline =
+ isPaused &&
+ hasNextPage &&
+ hasRequestedNextPage &&
+ offline !== false &&
+ offline !== undefined;
+
return (
- {isFetchingNextPage && hasNextPage && (
+ {showOffline ? (
+ offline
+ ) : isFetchingNextPage && hasNextPage ? (
- )}
+ ) : null}
);
};
const defaultOptions = { threshold: 0 };
+const defaultOffline = ;
export interface InfinitePaginationProps {
+ offline?: React.ReactNode;
options?: IntersectionObserverInit;
sx?: SxProps;
}
From 23e5374c76ddeecfba00e4332c16869b87562c98 Mon Sep 17 00:00:00 2001
From: Gildas <1122076+djhi@users.noreply.github.com>
Date: Thu, 28 Aug 2025 15:36:30 +0200
Subject: [PATCH 4/5] Add tests regarding disableAuthentication
---
.../controller/list/InfiniteListBase.spec.tsx | 23 +++++++++++++++++++
.../list/InfiniteListBase.stories.tsx | 3 +++
.../src/controller/list/ListBase.spec.tsx | 23 +++++++++++++++++++
.../src/controller/list/ListBase.stories.tsx | 3 +++
4 files changed, 52 insertions(+)
diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx
index c471272b73e..e9032a7f5a7 100644
--- a/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx
+++ b/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx
@@ -73,6 +73,29 @@ describe('InfiniteListBase', () => {
resolveAuth!();
await screen.findByText('Hello');
});
+ it('should not wait for the authentication resolution before loading data when disableAuthentication is true', async () => {
+ const authProvider = {
+ login: () => Promise.resolve(),
+ logout: () => Promise.resolve(),
+ checkError: () => Promise.resolve(),
+ checkAuth: jest.fn(),
+ };
+ const dataProvider = testDataProvider({
+ // @ts-ignore
+ getList: jest.fn(() =>
+ Promise.resolve({ data: [{ id: 1, title: 'Hello' }], total: 1 })
+ ),
+ });
+ render(
+
+ );
+ await screen.findByText('Hello');
+ expect(authProvider.checkAuth).not.toHaveBeenCalled();
+ });
it('should wait for both the authentication and authorization resolution before loading data', async () => {
let resolveAuth: () => void;
let resolveCanAccess: (value: boolean) => void;
diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx
index 8a61af078de..368acb45318 100644
--- a/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx
+++ b/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx
@@ -143,15 +143,18 @@ export const WithAuthProviderNoAccessControl = ({
checkError: () => Promise.resolve(),
},
dataProvider = defaultDataProvider,
+ InfiniteListProps,
}: {
authProvider?: AuthProvider;
dataProvider?: DataProvider;
+ InfiniteListProps?: Partial;
}) => (
Authentication loading...
}
+ {...InfiniteListProps}
>
diff --git a/packages/ra-core/src/controller/list/ListBase.spec.tsx b/packages/ra-core/src/controller/list/ListBase.spec.tsx
index 982c36bb9fa..0b096048cb7 100644
--- a/packages/ra-core/src/controller/list/ListBase.spec.tsx
+++ b/packages/ra-core/src/controller/list/ListBase.spec.tsx
@@ -22,6 +22,29 @@ describe('ListBase', () => {
expect(dataProvider.getList).toHaveBeenCalled();
await screen.findByText('Hello');
});
+ it('should not wait for the authentication resolution before loading data when disableAuthentication is true', async () => {
+ const authProvider = {
+ login: () => Promise.resolve(),
+ logout: () => Promise.resolve(),
+ checkError: () => Promise.resolve(),
+ checkAuth: jest.fn(),
+ };
+ const dataProvider = testDataProvider({
+ // @ts-ignore
+ getList: jest.fn(() =>
+ Promise.resolve({ data: [{ id: 1, title: 'Hello' }], total: 1 })
+ ),
+ });
+ render(
+
+ );
+ await screen.findByText('Hello');
+ expect(authProvider.checkAuth).not.toHaveBeenCalled();
+ });
it('should wait for the authentication resolution before loading data', async () => {
let resolveAuth: () => void;
const authProvider = {
diff --git a/packages/ra-core/src/controller/list/ListBase.stories.tsx b/packages/ra-core/src/controller/list/ListBase.stories.tsx
index 8d5f327857f..2fbf0cf335c 100644
--- a/packages/ra-core/src/controller/list/ListBase.stories.tsx
+++ b/packages/ra-core/src/controller/list/ListBase.stories.tsx
@@ -147,15 +147,18 @@ export const WithAuthProviderNoAccessControl = ({
checkError: () => Promise.resolve(),
},
dataProvider = defaultDataProvider,
+ ListProps,
}: {
authProvider?: AuthProvider;
dataProvider?: DataProvider;
+ ListProps?: Partial;
}) => (
Authentication loading...}
+ {...ListProps}
>
From 2d96182beebc456511f3391983e0d6a67bb17156 Mon Sep 17 00:00:00 2001
From: Gildas <1122076+djhi@users.noreply.github.com>
Date: Mon, 1 Sep 2025 13:28:04 +0200
Subject: [PATCH 5/5] Fix InfiniteList documentation
---
docs/InfiniteList.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/InfiniteList.md b/docs/InfiniteList.md
index 584995dd8bb..7244b9d9ecb 100644
--- a/docs/InfiniteList.md
+++ b/docs/InfiniteList.md
@@ -88,7 +88,7 @@ Additional props are passed down to the root component (a MUI `` by defaul
## `offline`
-By default, `` renders the `` component when there is no connectivity and there are no records in the cache yet for the current parameters (page, sort, etc.). You can provide your own component via the `offline` prop:
+By default, `` renders the `` component when there is no connectivity and there are no records in the cache yet for the current parameters (page, sort, etc.). You can provide your own component via the `offline` prop:
```jsx
import { InfiniteList, InfinitePagination } from 'react-admin';