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 12 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
128 changes: 128 additions & 0 deletions docs/DataProviders.md
Original file line number Diff line number Diff line change
Expand Up @@ -884,3 +884,131 @@ 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.

## 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.8.0",
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 = () => {
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' });
}
},
});
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';
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