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 20 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
9 changes: 5 additions & 4 deletions cypress/support/CreatePage.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ export default url => ({
inputs: `.ra-input`,
richTextInputError: '.create-page .ra-rich-text-input-error',
snackbar: 'div[role="alert"]',
submitButton: ".create-page div[role='toolbar'] button[type='submit']",
submitButton:
".create-page div[role='toolbar'] div:first-child button[type='submit']",
submitAndShowButton:
".create-page form div[role='toolbar'] button[type='button']:nth-child(2)",
".create-page form div[role='toolbar'] div:first-child button[type='button']:nth-child(2)",
submitAndAddButton:
".create-page form div[role='toolbar'] button[type='button']:nth-child(3)",
".create-page form div[role='toolbar'] div:first-child button[type='button']:nth-child(3)",
submitCommentable:
".create-page form div[role='toolbar'] button[type='button']:last-child",
".create-page form div[role='toolbar'] div:first-child button[type='button']:last-child",
descInput: '.ProseMirror',
tab: index => `.form-tab:nth-of-type(${index})`,
title: '#react-admin-title',
Expand Down
126 changes: 126 additions & 0 deletions docs/DataProviders.md
Original file line number Diff line number Diff line change
Expand Up @@ -884,3 +884,129 @@ 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 { dataProvider } from './dataProvider';

export const queryClient = new QueryClient();

addOfflineSupportToQueryClient({
Copy link
Member

Choose a reason for hiding this comment

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

The fact that this function mutates the query client is unusual in react land. Can't it return a new query client instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

No, as it simply calls functions on the queryClient. There's no way around it

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
81 changes: 72 additions & 9 deletions examples/simple/src/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,84 @@
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 OfflineIcon from '@mui/icons-material/SignalWifiConnectedNoInternet4';
import '../assets/app.css';

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

export default ({ children }) => (
export const MyLayout = ({ children }) => (
<>
<Layout appBar={MyAppBar}>{children}</Layout>
<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 = () => {
setIsOnline(onlineManager.isOnline());
};
return onlineManager.subscribe(handleChange);
}, []);

return isOnline;
};

/**
* When react-query resumes 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();

React.useEffect(() => {
queryClient.setMutationDefaults([], {
onSettled(data, error) {
if (error) {
notify(error.message, { type: 'error' });
}
},
});
}, [queryClient, notify]);

return null;
};
Loading
Loading