Skip to content

Add support for offline/local first applications #10545

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 62 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
aaf7860
Add support for offline/local first applications
djhi Feb 24, 2025
2aa2cba
Ensure no existing tests are broken
djhi Feb 24, 2025
a1b2157
Try stabilizing tests
djhi Feb 24, 2025
9d66e05
Fix e2e tests
djhi Feb 25, 2025
fe15863
Introduce `addOfflineSupportToQueryClient`
djhi Apr 25, 2025
1abc446
Add documentation
djhi Apr 25, 2025
25ff38b
Trigger build
djhi Apr 25, 2025
9f83cab
Merge remote-tracking branch 'origin/next' into support-offline-mode
slax57 Apr 29, 2025
eae5aa6
fix merge issues
slax57 Apr 29, 2025
87e537a
Merge branch 'next' into support-offline-mode
djhi Apr 30, 2025
b0bc9a0
Cleanup documentation
djhi Apr 30, 2025
cf79b3b
Fix e2e tests
djhi Apr 30, 2025
78262e9
Merge branch 'next' into support-offline-mode
djhi May 6, 2025
6287a1c
Fix documentation
djhi May 6, 2025
7d3d2e2
Remove unnecessary ts-ignore
djhi May 6, 2025
fd94c6a
Apply suggestions from code review
djhi May 7, 2025
b87aed7
Apply review suggestions
djhi May 7, 2025
39ce253
Update useListContextWithProps
djhi May 7, 2025
2737f75
Improve post creation ux
djhi May 9, 2025
07121fd
Fix e2e tests
djhi May 12, 2025
233ce57
Add offline message to ReferenceField
djhi May 15, 2025
f88c509
Add offline support to list components
djhi May 15, 2025
8f3096b
Add offline support to reference components
djhi May 15, 2025
9f418bf
Add offline support to details components
djhi May 15, 2025
6778fd1
Fix tests and stories
djhi May 15, 2025
934fff4
Use Offline everywhere
djhi May 15, 2025
32af617
Add support for offline in Reference inputs
djhi May 15, 2025
02a957a
Add documentation
djhi May 15, 2025
4c71375
Fix documentation
djhi May 15, 2025
47cdf92
Improve EditView types
djhi May 15, 2025
466aeb1
Apply suggestions from code review
djhi May 19, 2025
72ff0b1
Fix exports
djhi May 19, 2025
6d3daeb
Fix list children handling of offline state
djhi May 20, 2025
c7b22d6
Fix detail views handling of offline state
djhi May 20, 2025
1be8e73
Fix reference inputs handling of offline state
djhi May 20, 2025
01a1a94
Fix ReferenceOneField
djhi May 20, 2025
10ae938
Improve ReferenceInput
djhi May 20, 2025
9849a2c
Correctly export Offline component
djhi May 20, 2025
4a3cb09
Avoid offline specific styles in details views
djhi May 20, 2025
84207e0
Rename isOnline hook to useIsOffline
djhi May 20, 2025
2e2e10c
Improve Offline component design for reference fields and inputs
djhi Jun 5, 2025
8452c4f
Make sure users know about pending operations
djhi Jun 6, 2025
a55d5ef
Improve mutation mode selector
djhi Jun 10, 2025
8384ca3
Improve documentation
djhi Jun 10, 2025
02dbe24
Improve notifications for offline mutations
djhi Jun 20, 2025
0ee8837
Merge branch 'next' into support-offline-mode
djhi Jun 25, 2025
b648d08
Fix delete mutations success message handling
djhi Jun 25, 2025
929d606
Fix ReferenceOneField empty case handling
djhi Jun 25, 2025
1154f17
Fix simple example dependencies
djhi Jun 26, 2025
5b7f83a
Fix JSDoc examples
djhi Jun 26, 2025
d114f96
Remove unnecessary CSS classes
djhi Jun 26, 2025
30a5ec0
Fix Offline types
djhi Jun 26, 2025
68d00a7
Fix LoadingIndicator
djhi Jun 26, 2025
ed0f0df
Fix SingleFieldList story
djhi Jun 26, 2025
d0586a1
Fix ReferenceInput offline detection
djhi Jun 26, 2025
b03dfb4
Fix reference fields and inputs
djhi Jun 26, 2025
dbd8dd1
Improve documentation
djhi Jun 26, 2025
b8b13c5
Fix yarn.lock
djhi Jun 26, 2025
a3b1da8
Document how to handle errors for resumed mutations
djhi Jun 26, 2025
4413807
Dedupe @tanstack/query-core
djhi Jul 9, 2025
3fda18b
Improve DataProviders documentation
djhi Jul 9, 2025
66416c1
Fix following tanstack/query-core upgrade
djhi Jul 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cypress/e2e/edit.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ describe('Edit Page', () => {
it('should redirect to list page after edit success', () => {
// For some unknown reason, the click on submit didn't work in cypress
// so we submit with enter
EditPostPage.setInputValue('input', 'title', 'Lorem Ipsum{enter}');
EditPostPage.setInputValue(
'input',
'title',
'Lorem Ipsum again{enter}'
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because react-query now persist queries and mutations for offline mode, the previous test now leaks into the second (e.g. this post has its title changed to Lorem Ipsum). I tried to configure testIsolation in Cypress but our version is probably too old

);
cy.url().should('match', /\/#\/posts$/);
});

Expand Down
133 changes: 133 additions & 0 deletions docs/DataProviders.md
Original file line number Diff line number Diff line change
Expand Up @@ -884,3 +884,136 @@ export default App;
```

**Tip**: This example uses the function version of `setState` (`setDataProvider(() => dataProvider)`) instead of the more classic version (`setDataProvider(dataProvider)`). This is because some legacy Data Providers are actually functions, and `setState` would call them immediately on mount.

---
layout: default
title: "Offline Support"
---

## Offline Support

React Query supports offline/local-first applications. To enable it in your React Admin application, install the required React Query packages:

```sh
yarn add @tanstack/react-query-persist-client @tanstack/query-sync-storage-persister
```

Then, register default functions for React Admin mutations on the `QueryClient` to enable resumable mutations (mutations triggered while offline). React Admin provides the `addOfflineSupportToQueryClient` function for this:

```ts
// in src/queryClient.ts
import { addOfflineSupportToQueryClient } from 'react-admin';
import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { dataProvider } from './dataProvider';

export const queryClient = new QueryClient();

addOfflineSupportToQueryClient({
queryClient,
dataProvider,
resources: ['posts', 'comments'],
});
```

Then, wrap your `<Admin>` inside a [`<PersistQueryClientProvider>`](https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#persistqueryclientprovider):

{% raw %}
```tsx
// in src/App.tsx
import { Admin, Resource } from 'react-admin';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { queryClient } from './queryClient';
import { dataProvider } from './dataProvider';
import { posts } from './posts';
import { comments } from './comments';

const localStoragePersister = createSyncStoragePersister({
storage: window.localStorage,
});

export const App = () => (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister: localStoragePersister }}
onSuccess={() => {
// resume mutations after initial restore from localStorage is successful
queryClient.resumePausedMutations();
}}
>
<Admin queryClient={queryClient} dataProvider={dataProvider}>
<Resource name="posts" {...posts} />
<Resource name="comments" {...comments} />
</Admin>
</PersistQueryClientProvider>
)
```
{% endraw %}

If you have [custom mutations](./Actions.md#calling-custom-methods) on your dataProvider, you can enable offline support for them too. For instance, if your `dataProvider` exposes a `banUser()` method:

```ts
const dataProvider = {
getList: /* ... */,
getOne: /* ... */,
getMany: /* ... */,
getManyReference: /* ... */,
create: /* ... */,
update: /* ... */,
updateMany: /* ... */,
delete: /* ... */,
deleteMany: /* ... */,
banUser: (userId: string) => {
return fetch(`/api/user/${userId}/ban`, { method: 'POST' })
.then(response => response.json());
},
}

export type MyDataProvider = DataProvider & {
banUser: (userId: string) => Promise<{ data: RaRecord }>
}
```

First, you must set a `mutationKey` for this mutation:

{% raw %}
```tsx
const BanUserButton = ({ userId }: { userId: string }) => {
const dataProvider = useDataProvider();
const { mutate, isPending } = useMutation({
mutationKey: 'banUser'
mutationFn: (userId) => dataProvider.banUser(userId)
});
return <Button label="Ban" onClick={() => mutate(userId)} disabled={isPending} />;
};
```
{% endraw %}

**Tip**: Note that unlike the [_Calling Custom Methods_ example](./Actions.md#calling-custom-methods), we passed `userId` to the `mutate` function. This is necessary so that React Query passes it too to the default function when resuming the mutation.

Then, register a default function for it:

```ts
// in src/queryClient.ts
import { addOfflineSupportToQueryClient } from 'react-admin';
import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { dataProvider } from './dataProvider';

export const queryClient = new QueryClient();

addOfflineSupportToQueryClient({
queryClient,
dataProvider,
resources: ['posts', 'comments'],
});

queryClient.setMutationDefaults('banUser', {
mutationFn: async (userId) => {
return dataProviderFn.banUser(userId);
},
});
```
2 changes: 2 additions & 0 deletions examples/simple/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
"dependencies": {
"@mui/icons-material": "^5.16.12",
"@mui/material": "^5.16.12",
"@tanstack/query-sync-storage-persister": "5.47.0",
"@tanstack/react-query": "^5.21.7",
"@tanstack/react-query-devtools": "^5.21.7",
"@tanstack/react-query-persist-client": "5.47.0",
"jsonexport": "^3.2.0",
"lodash": "~4.17.5",
"ra-data-fakerest": "^5.6.1",
Expand Down
91 changes: 75 additions & 16 deletions examples/simple/src/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,80 @@
import * as React from 'react';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { AppBar, Layout, InspectorButton, TitlePortal } from 'react-admin';
import {
AppBar,
Layout,
InspectorButton,
TitlePortal,
useNotify,
} from 'react-admin';
import { onlineManager, useQueryClient } from '@tanstack/react-query';
import { Stack, Tooltip } from '@mui/material';
import CircleIcon from '@mui/icons-material/Circle';
import '../assets/app.css';

const MyAppBar = () => (
<AppBar>
<TitlePortal />
<InspectorButton />
</AppBar>
);
const MyAppBar = () => {
const isOnline = useIsOnline();
return (
<AppBar>
<TitlePortal />
<Stack direction="row" spacing={1}>
<Tooltip title={isOnline ? 'Online' : 'Offline'}>
<CircleIcon
sx={{
color: isOnline ? 'success.main' : 'warning.main',
width: 24,
height: 24,
}}
/>
</Tooltip>
</Stack>
<InspectorButton />
</AppBar>
);
};

export default ({ children }) => (
<>
<Layout appBar={MyAppBar}>{children}</Layout>
<ReactQueryDevtools
initialIsOpen={false}
buttonPosition="bottom-left"
/>
</>
);
export default ({ children }) => {
return (
<>
<Layout appBar={MyAppBar}>
{children}
<NotificationsFromQueryClient />
</Layout>
<ReactQueryDevtools
initialIsOpen={false}
buttonPosition="bottom-left"
/>
</>
);
};

const useIsOnline = () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in my tests with Firefox, the app bar never shows the offline icon when setting the network dev tools to offline.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproduced. However, I simply use react-query onlineManager. I don't know how to solve this.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After further tests, it appears toggling network in the firefox devtool does not trigger the online nor offline events. You have to be actually offline or online at the OS level. This is a bug in FF

const [isOnline, setIsOnline] = React.useState(onlineManager.isOnline());
React.useEffect(() => {
const handleChange = isOnline => {
setIsOnline(isOnline);
};
return onlineManager.subscribe(handleChange);
});

return isOnline;
};

/**
* When react-query resume persisted mutations through their default functions (provided in the getOfflineFirstQueryClient file) after the browser tab
* has been closed, it cannot handle their side effects unless we set up some defaults. In order to leverage the react-admin notification system
* we add a default onSettled function to the mutation defaults here.
*/
const NotificationsFromQueryClient = () => {
const queryClient = useQueryClient();
const notify = useNotify();

queryClient.setMutationDefaults([], {
onSettled(data, error) {
if (error) {
notify(error.message, { type: 'error' });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will result in a doubled mutation with e.g. <Edit>, which already shows an error notification with onError. Also, I don't understand the point of showing directly the error message...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you actually try it? This effect only runs when there are persisted mutations to resume. For instance, create a new post with title f00bar in optimistic mode while offline and click Save and edit then close your browser tab. Reopen the tab, restore the network if needed. You'll see the server error as expected.
However, should you do the same while offline, the above function will not be called

}
},
});
return null;
};
107 changes: 69 additions & 38 deletions examples/simple/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
/* eslint react/jsx-key: off */
import * as React from 'react';
import { Admin, Resource, CustomRoutes } from 'react-admin'; // eslint-disable-line import/no-unresolved
import {
addOfflineSupportToQueryClient,
Admin,
Resource,
CustomRoutes,
} from 'react-admin';
import { createRoot } from 'react-dom/client';
import { Route } from 'react-router-dom';

import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import authProvider from './authProvider';
import comments from './comments';
import CustomRouteLayout from './customRouteLayout';
Expand All @@ -16,47 +22,72 @@ import users from './users';
import tags from './tags';
import { queryClient } from './queryClient';

const localStoragePersister = createSyncStoragePersister({
storage: window.localStorage,
});

addOfflineSupportToQueryClient({
queryClient,
dataProvider,
resources: ['posts', 'comments', 'tags', 'users'],
});

const container = document.getElementById('root') as HTMLElement;
const root = createRoot(container);

root.render(
<React.StrictMode>
<Admin
authProvider={authProvider}
dataProvider={dataProvider}
i18nProvider={i18nProvider}
queryClient={queryClient}
title="Example Admin"
layout={Layout}
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister: localStoragePersister }}
onSuccess={() => {
// resume mutations after initial restore from localStorage is successful
queryClient.resumePausedMutations();
}}
>
<Resource name="posts" {...posts} />
<Resource name="comments" {...comments} />
<Resource name="tags" {...tags} />
<Resource name="users" {...users} />
<CustomRoutes noLayout>
<Route
path="/custom"
element={<CustomRouteNoLayout title="Posts from /custom" />}
/>
<Route
path="/custom1"
element={
<CustomRouteNoLayout title="Posts from /custom1" />
}
/>
</CustomRoutes>
<CustomRoutes>
<Route
path="/custom2"
element={<CustomRouteLayout title="Posts from /custom2" />}
/>
</CustomRoutes>
<CustomRoutes>
<Route
path="/custom3"
element={<CustomRouteLayout title="Posts from /custom3" />}
/>
</CustomRoutes>
</Admin>
<Admin
authProvider={authProvider}
dataProvider={dataProvider}
i18nProvider={i18nProvider}
queryClient={queryClient}
title="Example Admin"
layout={Layout}
>
<Resource name="posts" {...posts} />
<Resource name="comments" {...comments} />
<Resource name="tags" {...tags} />
<Resource name="users" {...users} />
<CustomRoutes noLayout>
<Route
path="/custom"
element={
<CustomRouteNoLayout title="Posts from /custom" />
}
/>
<Route
path="/custom1"
element={
<CustomRouteNoLayout title="Posts from /custom1" />
}
/>
</CustomRoutes>
<CustomRoutes>
<Route
path="/custom2"
element={
<CustomRouteLayout title="Posts from /custom2" />
}
/>
</CustomRoutes>
<CustomRoutes>
<Route
path="/custom3"
element={
<CustomRouteLayout title="Posts from /custom3" />
}
/>
</CustomRoutes>
</Admin>
</PersistQueryClientProvider>
</React.StrictMode>
);
Loading
Loading