diff --git a/docs/src/manifests/manifest.json b/docs/src/manifests/manifest.json index ac3a029884..7a6f9d746d 100644 --- a/docs/src/manifests/manifest.json +++ b/docs/src/manifests/manifest.json @@ -326,6 +326,11 @@ "title": "React Native", "path": "/examples/react-native", "editUrl": "/examples/react-native.mdx" + }, + { + "title": "Offline Queries and Mutations", + "path": "/examples/offline", + "editUrl": "/examples/offline.mdx" } ] }, diff --git a/docs/src/pages/examples/offline.mdx b/docs/src/pages/examples/offline.mdx new file mode 100644 index 0000000000..d7dc18a4f7 --- /dev/null +++ b/docs/src/pages/examples/offline.mdx @@ -0,0 +1,23 @@ +--- +id: offline +title: Offline Queries and Mutations +toc: false +--- + +- [Open in CodeSandbox](https://codesandbox.io/s/github/tannerlinsley/react-query/tree/alpha/examples/offline) +- [View Source](https://github.com/tannerlinsley/react-query/tree/alpha/examples/offline) + + diff --git a/docs/src/pages/plugins/persistQueryClient.md b/docs/src/pages/plugins/persistQueryClient.md index aa31b064dc..cbd29513f5 100644 --- a/docs/src/pages/plugins/persistQueryClient.md +++ b/docs/src/pages/plugins/persistQueryClient.md @@ -159,6 +159,68 @@ There are actually three interfaces available: - `PersistedQueryClientRestoreOptions` is used for `persistQueryClientRestore` (doesn't use `dehydrateOptions`). - `PersistQueryClientOptions` is used for `persistQueryClient` +## Usage with React + +[persistQueryClient](#persistQueryClient) will try to restore the cache and automatically subscribes to further changes, thus syncing your client to the provided storage. + +However, restoring is asynchronous, because all persisters are async by nature, which means that if you render your App while you are restoring, you might get into race conditions if a query mounts and fetches at the same time. + +Further, if you subscribe to changes outside of the React component lifecycle, you have no way of unsubscribing: + +```js +// 🚨 never unsubscribes from syncing +persistQueryClient({ + queryClient, + persister: localStoragePersister, +}) + +// 🚨 happens at the same time as restoring +ReactDOM.render(, rootElement) +``` + +### PeristQueryClientProvider + +For this use-case, you can use the `PersistQueryClientProvider`. It will make sure to subscribe / unsubscribe correctly according to the React component lifecycle, and it will also make sure that queries will not start fetching while we are still restoring. Queries will still render though, they will just be put into `fetchingState: 'idle'` until data has been restored. Then, they will refetch unless the restored data is _fresh_ enough, and _initialData_ will also be respected. It can be used _instead of_ the normal [QueryClientProvider](../reference/QueryClientProvider): + +```jsx + +import { PersistQueryClientProvider } from 'react-query/persistQueryClient' +import { createWebStoragePersister } from 'react-query/createWebStoragePersister' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + cacheTime: 1000 * 60 * 60 * 24, // 24 hours + }, + }, +}) + +const persister = createWebStoragePersister({ + storage: window.localStorage, +}) + +ReactDOM.render( + + + , + rootElement +) +``` + +#### Props + +`PersistQueryClientProvider` takes the same props as [QueryClientProvider](../reference/QueryClientProvider), and additionally: + +- `persistOptions: PersistQueryClientOptions` + - all [options](#options) you cann pass to [persistQueryClient](#persistqueryclient) minus the QueryClient itself +- `onSuccess?: () => void` + - optional + - will be called when the initial restore is finished + - can be used to [resumePausedMutations](../reference/QueryClient#queryclientresumepausedmutations) + ## Persisters ### Persisters Interface diff --git a/docs/src/pages/reference/QueryClient.md b/docs/src/pages/reference/QueryClient.md index 3e4c2daf74..2996693544 100644 --- a/docs/src/pages/reference/QueryClient.md +++ b/docs/src/pages/reference/QueryClient.md @@ -49,6 +49,7 @@ Its available methods are: - [`queryClient.getQueryCache`](#queryclientgetquerycache) - [`queryClient.getMutationCache`](#queryclientgetmutationcache) - [`queryClient.clear`](#queryclientclear) +- - [`queryClient.resumePausedMutations`](#queryclientresumepausedmutations) **Options** @@ -563,3 +564,11 @@ The `clear` method clears all connected caches. ```js queryClient.clear() ``` + +## `queryClient.resumePausedMutations` + +Can be used to resume mutations that have been paused because there was no network connection. + +```js +queryClient.resumePausedMutations() +``` diff --git a/examples/offline/.babelrc b/examples/offline/.babelrc new file mode 100644 index 0000000000..c14b2828d1 --- /dev/null +++ b/examples/offline/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["react-app"] +} diff --git a/examples/offline/.eslintrc b/examples/offline/.eslintrc new file mode 100644 index 0000000000..404725ad66 --- /dev/null +++ b/examples/offline/.eslintrc @@ -0,0 +1,7 @@ +{ + "extends": ["react-app", "prettier"], + "rules": { + // "eqeqeq": 0, + // "jsx-a11y/anchor-is-valid": 0 + } +} diff --git a/examples/offline/.gitignore b/examples/offline/.gitignore new file mode 100644 index 0000000000..613b2de638 --- /dev/null +++ b/examples/offline/.gitignore @@ -0,0 +1,26 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +yarn.lock +package-lock.json + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/offline/.prettierrc b/examples/offline/.prettierrc new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/examples/offline/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/examples/offline/.rescriptsrc.js b/examples/offline/.rescriptsrc.js new file mode 100644 index 0000000000..433b258d85 --- /dev/null +++ b/examples/offline/.rescriptsrc.js @@ -0,0 +1,37 @@ +const path = require("path"); +const resolveFrom = require("resolve-from"); + +const fixLinkedDependencies = (config) => { + config.resolve = { + ...config.resolve, + alias: { + ...config.resolve.alias, + react$: resolveFrom(path.resolve("node_modules"), "react"), + "react-dom$": resolveFrom(path.resolve("node_modules"), "react-dom"), + }, + }; + return config; +}; + +const includeSrcDirectory = (config) => { + config.resolve = { + ...config.resolve, + modules: [path.resolve("src"), ...config.resolve.modules], + }; + return config; +}; + +const allowOutsideSrc = (config) => { + config.resolve.plugins = config.resolve.plugins.filter( + (p) => p.constructor.name !== "ModuleScopePlugin" + ); + return config; +}; + +module.exports = [ + ["use-babel-config", ".babelrc"], + ["use-eslint-config", ".eslintrc"], + fixLinkedDependencies, + allowOutsideSrc, + // includeSrcDirectory, +]; diff --git a/examples/offline/README.md b/examples/offline/README.md new file mode 100644 index 0000000000..b168d3c4b1 --- /dev/null +++ b/examples/offline/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` or `yarn` +- `npm run start` or `yarn start` diff --git a/examples/offline/package.json b/examples/offline/package.json new file mode 100644 index 0000000000..ce0f6a88ce --- /dev/null +++ b/examples/offline/package.json @@ -0,0 +1,36 @@ +{ + "private": true, + "scripts": { + "start": "rescripts start", + "build": "rescripts build", + "test": "rescripts test", + "eject": "rescripts eject" + }, + "dependencies": { + "@tanstack/react-location": "^3.7.0", + "ky": "^0.30.0", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-hot-toast": "^2.2.0", + "react-query": "^4.0.0-alpha.19", + "react-scripts": "3.0.1" + }, + "devDependencies": { + "@rescripts/cli": "^0.0.11", + "@rescripts/rescript-use-babel-config": "^0.0.8", + "@rescripts/rescript-use-eslint-config": "^0.0.9", + "babel-eslint": "10.0.1" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/offline/public/favicon.ico b/examples/offline/public/favicon.ico new file mode 100644 index 0000000000..41cff3b9db --- /dev/null +++ b/examples/offline/public/favicon.ico @@ -0,0 +1 @@ +https://rawcdn.githack.com/tannerlinsley/react-query/master/examples/simple/public/favicon.ico \ No newline at end of file diff --git a/examples/offline/public/index.html b/examples/offline/public/index.html new file mode 100644 index 0000000000..dd1ccfd4cd --- /dev/null +++ b/examples/offline/public/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + React App + + + +
+ + + diff --git a/examples/offline/public/manifest.json b/examples/offline/public/manifest.json new file mode 100644 index 0000000000..1f2f141faf --- /dev/null +++ b/examples/offline/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/examples/offline/public/mockServiceWorker.js b/examples/offline/public/mockServiceWorker.js new file mode 100644 index 0000000000..ba0c013b83 --- /dev/null +++ b/examples/offline/public/mockServiceWorker.js @@ -0,0 +1,338 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (0.39.1). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929' +const bypassHeaderName = 'x-msw-bypass' +const activeClientIds = new Set() + +self.addEventListener('install', function () { + return self.skipWaiting() +}) + +self.addEventListener('activate', async function (event) { + return self.clients.claim() +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll() + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +// Resolve the "main" client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll() + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const clonedResponse = response.clone() + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: serializeHeaders(clonedResponse.headers), + redirected: clonedResponse.redirected, + }, + }) + })() + } + + return response +} + +async function getResponse(event, client, requestId) { + const { request } = event + const requestClone = request.clone() + const getOriginalResponse = () => fetch(requestClone) + + // Bypass mocking when the request client is not active. + if (!client) { + return getOriginalResponse() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return await getOriginalResponse() + } + + // Bypass requests with the explicit bypass header + if (requestClone.headers.get(bypassHeaderName) === 'true') { + const cleanRequestHeaders = serializeHeaders(requestClone.headers) + + // Remove the bypass header to comply with the CORS preflight check. + delete cleanRequestHeaders[bypassHeaderName] + + const originalRequest = new Request(requestClone, { + headers: new Headers(cleanRequestHeaders), + }) + + return fetch(originalRequest) + } + + // Send the request to the client-side MSW. + const reqHeaders = serializeHeaders(request.headers) + const body = await request.text() + + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: reqHeaders, + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body, + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + switch (clientMessage.type) { + case 'MOCK_SUCCESS': { + return delayPromise( + () => respondWithMock(clientMessage), + clientMessage.payload.delay, + ) + } + + case 'MOCK_NOT_FOUND': { + return getOriginalResponse() + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.payload + const networkError = new Error(message) + networkError.name = name + + // Rejecting a request Promise emulates a network error. + throw networkError + } + + case 'INTERNAL_ERROR': { + const parsedBody = JSON.parse(clientMessage.payload.body) + + console.error( + `\ +[MSW] Uncaught exception in the request handler for "%s %s": + +${parsedBody.location} + +This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\ +`, + request.method, + request.url, + ) + + return respondWithMock(clientMessage) + } + } + + return getOriginalResponse() +} + +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' + + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return + } + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = uuidv4() + + return event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}`, + ) + }), + ) +}) + +function serializeHeaders(headers) { + const reqHeaders = {} + headers.forEach((value, name) => { + reqHeaders[name] = reqHeaders[name] + ? [].concat(reqHeaders[name]).concat(value) + : value + }) + return reqHeaders +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(JSON.stringify(message), [channel.port2]) + }) +} + +function delayPromise(cb, duration) { + return new Promise((resolve) => { + setTimeout(() => resolve(cb()), duration) + }) +} + +function respondWithMock(clientMessage) { + return new Response(clientMessage.payload.body, { + ...clientMessage.payload, + headers: clientMessage.payload.headers, + }) +} + +function uuidv4() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0 + const v = c == 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} diff --git a/examples/offline/src/App.js b/examples/offline/src/App.js new file mode 100644 index 0000000000..41f56b632b --- /dev/null +++ b/examples/offline/src/App.js @@ -0,0 +1,215 @@ +import * as React from "react"; +import "./App.css"; + +import { + useQuery, + QueryClient, + MutationCache, + useMutation, + onlineManager, +} from "react-query"; +import { ReactQueryDevtools } from "react-query/devtools"; +import toast, { Toaster } from "react-hot-toast"; + +import { PersistQueryClientProvider } from "react-query/persistQueryClient"; +import { createWebStoragePersister } from "react-query/createWebStoragePersister"; +import { + Link, + Outlet, + ReactLocation, + Router, + useMatch, +} from "@tanstack/react-location"; + +import * as api from "api"; +import { movieKeys, useMovie } from "./movies"; + +const persister = createWebStoragePersister({ + storage: window.localStorage, +}); + +const location = new ReactLocation(); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + cacheTime: 1000 * 60 * 60 * 24, // 24 hours + staleTime: 2000, + retry: 0, + }, + }, + // configure global cache callbacks to show toast notifications + mutationCache: new MutationCache({ + onSuccess: (data) => { + toast.success(data.message); + }, + onError: (error) => { + toast.error(error.message); + }, + }), +}); + +// we need a default mutation function so that paused mutations can resume after a page reload +queryClient.setMutationDefaults(movieKeys.all(), { + mutationFn: async ({ id, comment }) => { + // to avoid clashes with our optimistic update when an offline mutation continues + await queryClient.cancelQueries(movieKeys.detail(id)); + return api.updateMovie(id, comment); + }, +}); + +export default function App() { + return ( + { + // resume mutations after initial restore from localStorage was successful + queryClient.resumePausedMutations().then(() => { + queryClient.invalidateQueries(); + }); + }} + > + , + }, + { + path: ":movieId", + element: , + errorElement: , + loader: ({ params: { movieId } }) => + queryClient.getQueryData(movieKeys.detail(movieId)) ?? + // do not load if we are offline because it returns a promise that is pending until we go online again + // we just let the Detail component handle it + (onlineManager.isOnline() + ? queryClient.fetchQuery(movieKeys.detail(movieId), () => + api.fetchMovie(movieId) + ) + : undefined), + }, + ]} + > + + + + + + ); +} + +function List() { + const moviesQuery = useQuery(movieKeys.list(), api.fetchMovies); + + if (moviesQuery.isLoading && moviesQuery.isFetching) { + return "Loading..."; + } + + if (moviesQuery.data) { + return ( +
+

Movies

+

+ Try to mock offline behaviour with the button in the devtools. You can + navigate around as long as there is already data in the cache. You'll + get a refetch as soon as you go online again. +

+ +
+ Updated at: {new Date(moviesQuery.data.ts).toLocaleTimeString()} +
+
{moviesQuery.isFetching && "fetching..."}
+
+ ); + } + + // query will be in 'idle' fetchStatus while restoring from localStorage + return null; +} + +function MovieError() { + const { error } = useMatch(); + + return ( +
+ Back +

Couldn't load movie!

+
{error.message}
+
+ ); +} + +function Detail() { + const { + params: { movieId }, + } = useMatch(); + const { comment, setComment, updateMovie, movieQuery } = useMovie(movieId); + + if (movieQuery.isLoading && movieQuery.isFetching) { + return "Loading..."; + } + + function submitForm(event) { + event.preventDefault(); + + updateMovie.mutate({ + id: movieId, + comment, + }); + } + + if (movieQuery.data) { + return ( +
+ Back +

Movie: {movieQuery.data.movie.title}

+

+ Try to mock offline behaviour with the button in the devtools, then + update the comment. The optimistic update will succeed, but the actual + mutation will be paused and resumed once you go online again. +

+

+ You can also reload the page, which will make the persisted mutation + resume, as you will be online again when you "come back". +

+

+