-
-
Notifications
You must be signed in to change notification settings - Fork 5.4k
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
base: next
Are you sure you want to change the base?
Changes from all commits
aaf7860
2aa2cba
a1b2157
9d66e05
fe15863
1abc446
25ff38b
9f83cab
eae5aa6
87e537a
b0bc9a0
cf79b3b
78262e9
6287a1c
7d3d2e2
fd94c6a
b87aed7
39ce253
2737f75
07121fd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'; | ||
erwanMarmelab marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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); | ||
}, | ||
}); | ||
``` |
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 = () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reproduced. However, I simply use react-query There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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; | ||
}; |
There was a problem hiding this comment.
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