From ad7300446fa72d474cea281552bc665889e55605 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 13 Nov 2021 08:59:12 +0100 Subject: [PATCH 01/19] feat(hydration): remove hydration package (#2936) --- docs/src/manifests/manifest.json | 7 ++++++- .../guides/migrating-to-react-query-4.md | 19 +++++++++++++++++++ docs/src/pages/reference/hydration.md | 8 -------- hydration/package.json | 6 ------ rollup.config.js | 1 - src/hydration/index.ts | 15 --------------- tsconfig.types.json | 1 - 7 files changed, 25 insertions(+), 32 deletions(-) create mode 100644 docs/src/pages/guides/migrating-to-react-query-4.md delete mode 100644 hydration/package.json delete mode 100644 src/hydration/index.ts diff --git a/docs/src/manifests/manifest.json b/docs/src/manifests/manifest.json index 18ad4cd4e5..1c97c82318 100644 --- a/docs/src/manifests/manifest.json +++ b/docs/src/manifests/manifest.json @@ -204,6 +204,11 @@ "title": "Migrating to React Query 3", "path": "/guides/migrating-to-react-query-3", "editUrl": "/guides/migrating-to-react-query-3.md" + }, + { + "title": "Migrating to React Query 4", + "path": "/guides/migrating-to-react-query-4", + "editUrl": "/guides/migrating-to-react-query-4.md" } ] }, @@ -442,4 +447,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/docs/src/pages/guides/migrating-to-react-query-4.md b/docs/src/pages/guides/migrating-to-react-query-4.md new file mode 100644 index 0000000000..65e54687e1 --- /dev/null +++ b/docs/src/pages/guides/migrating-to-react-query-4.md @@ -0,0 +1,19 @@ +--- +id: migrating-to-react-query-4 +title: Migrating to React Query 4 +--- + +## Breaking Changes + +### Separate hydration exports have been removed + +With version [3.22.0](https://github.com/tannerlinsley/react-query/releases/tag/v3.22.0), hydration utilities moved into the react-query core. With v3, you could still use the old exports from `react-query/hydration`, but these exports have been removed with v4. + +```diff +- import { dehydrate, hydrate, useHydrate, Hydrate } from 'react-query/hydration' ++ import { dehydrate, hydrate, useHydrate, Hydrate } from 'react-query' +``` + + + + diff --git a/docs/src/pages/reference/hydration.md b/docs/src/pages/reference/hydration.md index 0ea0040063..f1694fb634 100644 --- a/docs/src/pages/reference/hydration.md +++ b/docs/src/pages/reference/hydration.md @@ -15,8 +15,6 @@ const dehydratedState = dehydrate(queryClient, { }) ``` -> Note: Since version `3.22.0` hydration utilities moved into to core. If you using lower version your should import `dehydrate` from `react-query/hydration` - **Options** - `client: QueryClient` @@ -72,8 +70,6 @@ import { hydrate } from 'react-query' hydrate(queryClient, dehydratedState, options) ``` -> Note: Since version `3.22.0` hydration utilities moved into to core. If you using lower version your should import `hydrate` from `react-query/hydration` - **Options** - `client: QueryClient` @@ -99,8 +95,6 @@ import { useHydrate } from 'react-query' useHydrate(dehydratedState, options) ``` -> Note: Since version `3.22.0` hydration utilities moved into to core. If you using lower version your should import `useHydrate` from `react-query/hydration` - **Options** - `dehydratedState: DehydratedState` @@ -123,8 +117,6 @@ function App() { } ``` -> Note: Since version `3.22.0` hydration utilities moved into to core. If you using lower version your should import `Hydrate` from `react-query/hydration` - **Options** - `state: DehydratedState` diff --git a/hydration/package.json b/hydration/package.json deleted file mode 100644 index 804509cb4d..0000000000 --- a/hydration/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "internal": true, - "main": "../lib/hydration/index.js", - "module": "../es/hydration/index.js", - "types": "../types/hydration/index.d.ts" -} diff --git a/rollup.config.js b/rollup.config.js index 528bebbaf5..f11400c4c2 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -19,7 +19,6 @@ const inputSrcs = [ ['src/index.ts', 'ReactQuery', 'react-query'], ['src/core/index.ts', 'ReactQueryCore', 'react-query-core'], ['src/devtools/index.ts', 'ReactQueryDevtools', 'react-query-devtools'], - ['src/hydration/index.ts', 'ReactQueryHydration', 'react-query-hydration'], [ 'src/persistQueryClient-experimental/index.ts', 'ReactQueryPersistQueryClientExperimental', diff --git a/src/hydration/index.ts b/src/hydration/index.ts deleted file mode 100644 index 1c8a35e43a..0000000000 --- a/src/hydration/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -// This package once contained these functions, but they have now been moved -// into the core and react packages. -// They are re-exported here to avoid a breaking change, but this package -// should be considered deprecated and removed in a future major version. -export { dehydrate, hydrate, useHydrate, Hydrate } from 'react-query' - -// Types -export type { - DehydrateOptions, - DehydratedState, - HydrateOptions, - ShouldDehydrateMutationFunction, - ShouldDehydrateQueryFunction, -} from '../core/hydration' -export type { HydrateProps } from '../react/Hydrate' diff --git a/tsconfig.types.json b/tsconfig.types.json index f95e1f4c19..820f702ad9 100644 --- a/tsconfig.types.json +++ b/tsconfig.types.json @@ -11,7 +11,6 @@ }, "files": [ "./src/index.ts", - "./src/hydration/index.ts", "./src/devtools/index.ts", "./src/persistQueryClient-experimental/index.ts", "./src/createWebStoragePersistor-experimental/index.ts", From e89255728c3169adcaec17d4f1b6434841200e4c Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 13 Nov 2021 17:28:14 +0100 Subject: [PATCH 02/19] V4: streamline cancel refetch (#2937) * feat: streamline cancelRefetch the following functions now default to true for cancelRefetch: - refetchQueries (+invalidateQueries, + resetQueries) - query.refetch - fetchNextPage (unchanged) - fetchPreviousPage (unchanged) * feat: streamline cancelRefetch make sure that refetchOnReconnect and refetchOnWindowFocus do not cancel already running requests * feat: streamline cancelRefetch update tests refetch and invalidate now both cancel previous queries, which is intended, so we get more calls to the queryFn in these cases * feat: streamline cancelRefetch add more tests for cancelRefetch behavior * feat: streamline cancelRefetch update docs and migration guide * feat: streamline cancelRefetch simplify conditions by moving the ?? true default down to fetch on observer level; all 3 callers (fetchNextPage, fetchPreviousPage and refetch) just pass their options down and adhere to this default; refetch also only has 3 callers: - refetch from useQuery, where we want the default - onOnline and onFocus, where we now explicitly pass false to keep the previous behavior and add more tests * feat: streamline cancelRefetch we always call this.fetch() with options, so we can just as well make the mandatory also, streamline signatures by destructing values that can't be forwarded (and use empty object as default value) in options and just spread the rest * feat: streamline cancelRefetch fix types for refetch it was accidentally made too wide and allowed all refetchFilters, like `predicate`; but with `refetch` on an obserserver, there is nothing to filter for, except the page, so that is what we need to accept via `RefetchPageFilters` * feat: streamline cancelRefetch refetch never took a queryKey as param - it is always bound to the observer --- .../guides/migrating-to-react-query-4.md | 28 +++++ docs/src/pages/reference/QueryClient.md | 18 ++-- docs/src/pages/reference/useQuery.md | 5 +- src/core/infiniteQueryObserver.ts | 29 +++-- src/core/query.ts | 4 +- src/core/queryClient.ts | 1 + src/core/queryObserver.ts | 20 ++-- src/core/tests/queryClient.test.tsx | 42 +++++++- src/core/tests/queryObserver.test.tsx | 2 +- src/react/tests/useQuery.test.tsx | 102 ++++++++++++++++++ 10 files changed, 215 insertions(+), 36 deletions(-) diff --git a/docs/src/pages/guides/migrating-to-react-query-4.md b/docs/src/pages/guides/migrating-to-react-query-4.md index 65e54687e1..a62afb5b6b 100644 --- a/docs/src/pages/guides/migrating-to-react-query-4.md +++ b/docs/src/pages/guides/migrating-to-react-query-4.md @@ -14,6 +14,34 @@ With version [3.22.0](https://github.com/tannerlinsley/react-query/releases/tag/ + import { dehydrate, hydrate, useHydrate, Hydrate } from 'react-query' ``` +### Consistent behavior for `cancelRefetch` +The `cancelRefetch` can be passed to all functions that imperatively fetch a query, namely: +- `queryClient.refetchQueries` + - `queryClient.invalidateQueries` + - `queryClient.resetQueries` +- `refetch` returned from `useQuery` +- `fetchNetPage` and `fetchPreviousPage` returned from `useInfiniteQuery` +Except for `fetchNetxPage` and `fetchPreviousPage`, this flag was defaulting to `false`, which was inconsistent and potentially troublesome: Calling `refetchQueries` or `invalidateQueries` after a mutation might not yield the latest result if a previous slow fetch was already ongoing, because this refetch would have been skipped. + +We believe that if a query is actively refetched by some code you write, it should, per default, re-start the fetch. + +That is why this flag now defaults to _true_ for all methods mentioned above. It also means that if you call `refetchQueries` twice in a row, without awaiting it, it will now cancel the first fetch and re-start it with the second one: + +``` +queryClient.refetchQueries({ queryKey: ['todos'] }) +// this will abort the previous refetch and start a new fetch +queryClient.refetchQueries({ queryKey: ['todos'] }) +``` + +You can opt-out of this behaviour by explicitly passing `cancelRefetch:false`: + +``` +queryClient.refetchQueries({ queryKey: ['todos'] }) +// this will not abort the previous refetch - it will just be ignored +queryClient.refetchQueries({ queryKey: ['todos'] }, { cancelRefetch: false }) +``` + +> Note: There is no change in behaviour for automatically triggered fetches, e.g. because a query mounts or because of a window focus refetch. diff --git a/docs/src/pages/reference/QueryClient.md b/docs/src/pages/reference/QueryClient.md index be3fa19d6a..c826b81f21 100644 --- a/docs/src/pages/reference/QueryClient.md +++ b/docs/src/pages/reference/QueryClient.md @@ -295,8 +295,10 @@ await queryClient.invalidateQueries('posts', { - `options?: InvalidateOptions`: - `throwOnError?: boolean` - When set to `true`, this method will throw if any of the query refetch tasks fail. - - cancelRefetch?: boolean - - When set to `true`, then the current request will be cancelled before a new request is made + - `cancelRefetch?: boolean` + - Defaults to `true` + - Per default, a currently running request will be cancelled before a new request is made + - When set to `false`, no refetch will be made if there is already a request running. ## `queryClient.refetchQueries` @@ -328,8 +330,10 @@ await queryClient.refetchQueries(['posts', 1], { active: true, exact: true }) - `options?: RefetchOptions`: - `throwOnError?: boolean` - When set to `true`, this method will throw if any of the query refetch tasks fail. - - cancelRefetch?: boolean - - When set to `true`, then the current request will be cancelled before a new request is made + - `cancelRefetch?: boolean` + - Defaults to `true` + - Per default, a currently running request will be cancelled before a new request is made + - When set to `false`, no refetch will be made if there is already a request running. **Returns** @@ -396,8 +400,10 @@ queryClient.resetQueries(queryKey, { exact: true }) - `options?: ResetOptions`: - `throwOnError?: boolean` - When set to `true`, this method will throw if any of the query refetch tasks fail. - - cancelRefetch?: boolean - - When set to `true`, then the current request will be cancelled before a new request is made + - `cancelRefetch?: boolean` + - Defaults to `true` + - Per default, a currently running request will be cancelled before a new request is made + - When set to `false`, no refetch will be made if there is already a request running. **Returns** diff --git a/docs/src/pages/reference/useQuery.md b/docs/src/pages/reference/useQuery.md index 0b5588acc7..a2464cd2b7 100644 --- a/docs/src/pages/reference/useQuery.md +++ b/docs/src/pages/reference/useQuery.md @@ -236,6 +236,9 @@ const result = useQuery({ - `refetch: (options: { throwOnError: boolean, cancelRefetch: boolean }) => Promise` - A function to manually refetch the query. - If the query errors, the error will only be logged. If you want an error to be thrown, pass the `throwOnError: true` option - - If `cancelRefetch` is `true`, then the current request will be cancelled before a new request is made + - `cancelRefetch?: boolean` + - Defaults to `true` + - Per default, a currently running request will be cancelled before a new request is made + - When set to `false`, no refetch will be made if there is already a request running. - `remove: () => void` - A function to remove the query from the cache. diff --git a/src/core/infiniteQueryObserver.ts b/src/core/infiniteQueryObserver.ts index 19ef264650..54d0833a7c 100644 --- a/src/core/infiniteQueryObserver.ts +++ b/src/core/infiniteQueryObserver.ts @@ -39,7 +39,7 @@ export class InfiniteQueryObserver< // Type override protected fetch!: ( - fetchOptions?: ObserverFetchOptions + fetchOptions: ObserverFetchOptions ) => Promise> // eslint-disable-next-line @typescript-eslint/no-useless-constructor @@ -90,28 +90,27 @@ export class InfiniteQueryObserver< > } - fetchNextPage( - options?: FetchNextPageOptions - ): Promise> { + fetchNextPage({ pageParam, ...options }: FetchNextPageOptions = {}): Promise< + InfiniteQueryObserverResult + > { return this.fetch({ - // TODO consider removing `?? true` in future breaking change, to be consistent with `refetch` API (see https://github.com/tannerlinsley/react-query/issues/2617) - cancelRefetch: options?.cancelRefetch ?? true, - throwOnError: options?.throwOnError, + ...options, meta: { - fetchMore: { direction: 'forward', pageParam: options?.pageParam }, + fetchMore: { direction: 'forward', pageParam }, }, }) } - fetchPreviousPage( - options?: FetchPreviousPageOptions - ): Promise> { + fetchPreviousPage({ + pageParam, + ...options + }: FetchPreviousPageOptions = {}): Promise< + InfiniteQueryObserverResult + > { return this.fetch({ - // TODO consider removing `?? true` in future breaking change, to be consistent with `refetch` API (see https://github.com/tannerlinsley/react-query/issues/2617) - cancelRefetch: options?.cancelRefetch ?? true, - throwOnError: options?.throwOnError, + ...options, meta: { - fetchMore: { direction: 'backward', pageParam: options?.pageParam }, + fetchMore: { direction: 'backward', pageParam }, }, }) } diff --git a/src/core/query.ts b/src/core/query.ts index 336e9b8748..a466b137bc 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -297,7 +297,7 @@ export class Query< const observer = this.observers.find(x => x.shouldFetchOnWindowFocus()) if (observer) { - observer.refetch() + observer.refetch({ cancelRefetch: false }) } // Continue fetch if currently paused @@ -308,7 +308,7 @@ export class Query< const observer = this.observers.find(x => x.shouldFetchOnReconnect()) if (observer) { - observer.refetch() + observer.refetch({ cancelRefetch: false }) } // Continue fetch if currently paused diff --git a/src/core/queryClient.ts b/src/core/queryClient.ts index cb9d6ba181..d6e973136a 100644 --- a/src/core/queryClient.ts +++ b/src/core/queryClient.ts @@ -291,6 +291,7 @@ export class QueryClient { this.queryCache.findAll(filters).map(query => query.fetch(undefined, { ...options, + cancelRefetch: options?.cancelRefetch ?? true, meta: { refetchPage: filters?.refetchPage }, }) ) diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index f1ab5c8d2b..fe9cba01aa 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -1,4 +1,4 @@ -import { RefetchQueryFilters } from './types' +import { RefetchPageFilters } from './types' import { isServer, isValidTimeout, @@ -278,12 +278,15 @@ export class QueryObserver< this.client.getQueryCache().remove(this.currentQuery) } - refetch( - options?: RefetchOptions & RefetchQueryFilters - ): Promise> { + refetch({ + refetchPage, + ...options + }: RefetchOptions & RefetchPageFilters = {}): Promise< + QueryObserverResult + > { return this.fetch({ ...options, - meta: { refetchPage: options?.refetchPage }, + meta: { refetchPage }, }) } @@ -314,9 +317,12 @@ export class QueryObserver< } protected fetch( - fetchOptions?: ObserverFetchOptions + fetchOptions: ObserverFetchOptions ): Promise> { - return this.executeFetch(fetchOptions).then(() => { + return this.executeFetch({ + ...fetchOptions, + cancelRefetch: fetchOptions.cancelRefetch ?? true, + }).then(() => { this.updateResult() return this.currentResult }) diff --git a/src/core/tests/queryClient.test.tsx b/src/core/tests/queryClient.test.tsx index 5e0ee089a1..7fa76f9e74 100644 --- a/src/core/tests/queryClient.test.tsx +++ b/src/core/tests/queryClient.test.tsx @@ -694,7 +694,8 @@ describe('queryClient', () => { queryClient.invalidateQueries(key1) await queryClient.refetchQueries({ stale: true }) unsubscribe() - expect(queryFn1).toHaveBeenCalledTimes(2) + // fetchQuery, observer mount, invalidation (cancels observer mount) and refetch + expect(queryFn1).toHaveBeenCalledTimes(4) expect(queryFn2).toHaveBeenCalledTimes(1) }) @@ -711,7 +712,10 @@ describe('queryClient', () => { queryFn: queryFn1, }) const unsubscribe = observer.subscribe() - await queryClient.refetchQueries({ active: true, stale: true }) + await queryClient.refetchQueries( + { active: true, stale: true }, + { cancelRefetch: false } + ) unsubscribe() expect(queryFn1).toHaveBeenCalledTimes(2) expect(queryFn2).toHaveBeenCalledTimes(1) @@ -940,9 +944,10 @@ describe('queryClient', () => { expect(queryFn2).toHaveBeenCalledTimes(1) }) - test('should cancel ongoing fetches if cancelRefetch option is passed', async () => { + test('should cancel ongoing fetches if cancelRefetch option is set (default value)', async () => { const key = queryKey() const cancelFn = jest.fn() + let fetchCount = 0 const observer = new QueryObserver(queryClient, { queryKey: key, enabled: false, @@ -952,6 +957,7 @@ describe('queryClient', () => { queryClient.fetchQuery(key, () => { const promise = new Promise(resolve => { + fetchCount++ setTimeout(() => resolve(5), 10) }) // @ts-expect-error @@ -959,9 +965,37 @@ describe('queryClient', () => { return promise }) - await queryClient.refetchQueries(undefined, { cancelRefetch: true }) + await queryClient.refetchQueries() observer.destroy() expect(cancelFn).toHaveBeenCalledTimes(1) + expect(fetchCount).toBe(2) + }) + + test('should not cancel ongoing fetches if cancelRefetch option is set to false', async () => { + const key = queryKey() + const cancelFn = jest.fn() + let fetchCount = 0 + const observer = new QueryObserver(queryClient, { + queryKey: key, + enabled: false, + initialData: 1, + }) + observer.subscribe() + + queryClient.fetchQuery(key, () => { + const promise = new Promise(resolve => { + fetchCount++ + setTimeout(() => resolve(5), 10) + }) + // @ts-expect-error + promise.cancel = cancelFn + return promise + }) + + await queryClient.refetchQueries(undefined, { cancelRefetch: false }) + observer.destroy() + expect(cancelFn).toHaveBeenCalledTimes(0) + expect(fetchCount).toBe(1) }) }) diff --git a/src/core/tests/queryObserver.test.tsx b/src/core/tests/queryObserver.test.tsx index 1cba155cbd..aea4dbc46a 100644 --- a/src/core/tests/queryObserver.test.tsx +++ b/src/core/tests/queryObserver.test.tsx @@ -622,7 +622,7 @@ describe('queryObserver', () => { select: () => selectedData, }) - await observer.refetch({ queryKey: key }) + await observer.refetch() expect(observer.getCurrentResult().data).toBe(selectedData) unsubscribe() diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index 547d5ddf1b..c0a9a77584 100644 --- a/src/react/tests/useQuery.test.tsx +++ b/src/react/tests/useQuery.test.tsx @@ -549,6 +549,108 @@ describe('useQuery', () => { consoleMock.mockRestore() }) + it('should not cancel an ongoing fetch when refetch is called with cancelRefetch=false if we have data already', async () => { + const key = queryKey() + let fetchCount = 0 + + function Page() { + const { refetch } = useQuery( + key, + async () => { + fetchCount++ + await sleep(10) + return 'data' + }, + { enabled: false, initialData: 'initialData' } + ) + + React.useEffect(() => { + setActTimeout(() => { + refetch() + }, 5) + setActTimeout(() => { + refetch({ cancelRefetch: false }) + }, 5) + }, [refetch]) + + return null + } + + renderWithClient(queryClient, ) + + await sleep(20) + // first refetch only, second refetch is ignored + expect(fetchCount).toBe(1) + }) + + it('should cancel an ongoing fetch when refetch is called (cancelRefetch=true) if we have data already', async () => { + const key = queryKey() + let fetchCount = 0 + + function Page() { + const { refetch } = useQuery( + key, + async () => { + fetchCount++ + await sleep(10) + return 'data' + }, + { enabled: false, initialData: 'initialData' } + ) + + React.useEffect(() => { + setActTimeout(() => { + refetch() + }, 5) + setActTimeout(() => { + refetch() + }, 5) + }, [refetch]) + + return null + } + + renderWithClient(queryClient, ) + + await sleep(20) + // first refetch (gets cancelled) and second refetch + expect(fetchCount).toBe(2) + }) + + it('should not cancel an ongoing fetch when refetch is called (cancelRefetch=true) if we do not have data yet', async () => { + const key = queryKey() + let fetchCount = 0 + + function Page() { + const { refetch } = useQuery( + key, + async () => { + fetchCount++ + await sleep(10) + return 'data' + }, + { enabled: false } + ) + + React.useEffect(() => { + setActTimeout(() => { + refetch() + }, 5) + setActTimeout(() => { + refetch() + }, 5) + }, [refetch]) + + return null + } + + renderWithClient(queryClient, ) + + await sleep(20) + // first refetch will not get cancelled, second one gets skipped + expect(fetchCount).toBe(1) + }) + it('should be able to watch a query without providing a query function', async () => { const key = queryKey() const states: UseQueryResult[] = [] From 56598fb0fb223172a53340fca7696943c106807f Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sun, 14 Nov 2021 12:23:23 +0100 Subject: [PATCH 03/19] feat: better query filters (#2938) --- docs/src/pages/guides/filters.md | 16 +++--- .../guides/migrating-to-react-query-4.md | 48 +++++++++++++++++ docs/src/pages/reference/QueryClient.md | 23 ++++----- src/core/queryClient.ts | 18 +++---- src/core/tests/queriesObserver.test.tsx | 8 +-- src/core/tests/queryCache.test.tsx | 18 +++---- src/core/tests/queryClient.test.tsx | 51 ++++++------------- src/core/tests/utils.test.tsx | 21 -------- src/core/types.ts | 5 +- src/core/utils.ts | 49 +++--------------- 10 files changed, 113 insertions(+), 144 deletions(-) diff --git a/docs/src/pages/guides/filters.md b/docs/src/pages/guides/filters.md index 9419e44d11..67d19f8fc2 100644 --- a/docs/src/pages/guides/filters.md +++ b/docs/src/pages/guides/filters.md @@ -14,25 +14,23 @@ A query filter is an object with certain conditions to match a query with: await queryClient.cancelQueries() // Remove all inactive queries that begin with `posts` in the key -queryClient.removeQueries('posts', { inactive: true }) +queryClient.removeQueries('posts', { type: 'inactive' }) // Refetch all active queries -await queryClient.refetchQueries({ active: true }) +await queryClient.refetchQueries({ type: 'active' }) // Refetch all active queries that begin with `posts` in the key -await queryClient.refetchQueries('posts', { active: true }) +await queryClient.refetchQueries('posts', { type: 'active' }) ``` A query filter object supports the following properties: - `exact?: boolean` - If you don't want to search queries inclusively by query key, you can pass the `exact: true` option to return only the query with the exact query key you have passed. -- `active?: boolean` - - When set to `true` it will match active queries. - - When set to `false` it will match inactive queries. -- `inactive?: boolean` - - When set to `true` it will match inactive queries. - - When set to `false` it will match active queries. +- `type?: 'active' | 'inactive' | 'all'` + - Defaults to `all` + - When set to `active` it will match active queries. + - When set to `inactive` it will match inactive queries. - `stale?: boolean` - When set to `true` it will match stale queries. - When set to `false` it will match fresh queries. diff --git a/docs/src/pages/guides/migrating-to-react-query-4.md b/docs/src/pages/guides/migrating-to-react-query-4.md index a62afb5b6b..c3ec75d6cb 100644 --- a/docs/src/pages/guides/migrating-to-react-query-4.md +++ b/docs/src/pages/guides/migrating-to-react-query-4.md @@ -45,3 +45,51 @@ queryClient.refetchQueries({ queryKey: ['todos'] }, { cancelRefetch: false }) ``` > Note: There is no change in behaviour for automatically triggered fetches, e.g. because a query mounts or because of a window focus refetch. + +### Query Filters + +A [query filter](../guides/filters) is an object with certain conditions to match a query. Historically, the filter options have mostly been a combination of boolean flags. However, combining those flags can lead to impossible states. Specifically: + +``` +active?: boolean + - When set to true it will match active queries. + - When set to false it will match inactive queries. +inactive?: boolean + - When set to true it will match inactive queries. + - When set to false it will match active queries. +``` + +Those flags don't work well when used together, because they are mutually exclusive. Setting `false` for both flags could match all queries, judging from the description, or no queries, which doesn't make much sense. + +With v4, those filters have been combined into a single filter to better show the intent: + +```diff +- active?: boolean +- inactive?: boolean ++ type?: 'active' | 'inactive' | 'all' +``` + +The filter defaults to `all`, and you can choose to only match `active` or `inactive` queries. + +#### refetchActive / refetchInactive + +[queryClient.invalidateQueries](../reference/QueryClient#queryclientinvalidatequeries) had two additional, similar flags: + +``` +refetchActive: Boolean + - Defaults to true + - When set to false, queries that match the refetch predicate and are actively being rendered via useQuery and friends will NOT be refetched in the background, and only marked as invalid. +refetchInactive: Boolean + - Defaults to false + - When set to true, queries that match the refetch predicate and are not being rendered via useQuery and friends will be both marked as invalid and also refetched in the background +``` + +For the same reason, those have also been combined: + +```diff +- active?: boolean +- inactive?: boolean ++ refetchType?: 'active' | 'inactive' | 'all' | 'none' +``` + +This flag defaults to `active` because `refetchActive` defaulted to `true`. This means we also need a way to tell `invalidateQueries` to not refetch at all, which is why a fourth option (`none`) is also allowed here. diff --git a/docs/src/pages/reference/QueryClient.md b/docs/src/pages/reference/QueryClient.md index c826b81f21..42f29eadaf 100644 --- a/docs/src/pages/reference/QueryClient.md +++ b/docs/src/pages/reference/QueryClient.md @@ -268,14 +268,13 @@ queryClient.setQueriesData(queryKey | filters, updater) The `invalidateQueries` method can be used to invalidate and refetch single or multiple queries in the cache based on their query keys or any other functionally accessible property/state of the query. By default, all matching queries are immediately marked as invalid and active queries are refetched in the background. -- If you **do not want active queries to refetch**, and simply be marked as invalid, you can use the `refetchActive: false` option. -- If you **want inactive queries to refetch** as well, use the `refetchInactive: true` option +- If you **do not want active queries to refetch**, and simply be marked as invalid, you can use the `refetchType: 'none'` option. +- If you **want inactive queries to refetch** as well, use the `refetchTye: 'all'` option ```js await queryClient.invalidateQueries('posts', { exact, - refetchActive: true, - refetchInactive: false + refetchType: 'active', }, { throwOnError, cancelRefetch }) ``` @@ -283,12 +282,12 @@ await queryClient.invalidateQueries('posts', { - `queryKey?: QueryKey`: [Query Keys](../guides/query-keys) - `filters?: QueryFilters`: [Query Filters](../guides/filters#query-filters) - - `refetchActive: Boolean` - - Defaults to `true` - - When set to `false`, queries that match the refetch predicate and are actively being rendered via `useQuery` and friends will NOT be refetched in the background, and only marked as invalid. - - `refetchInactive: Boolean` - - Defaults to `false` - - When set to `true`, queries that match the refetch predicate and are not being rendered via `useQuery` and friends will be both marked as invalid and also refetched in the background + - `refetchType?: 'active' | 'inactive' | 'all' | 'none'` + - Defaults to `'active'` + - When set to `active`, only queries that match the refetch predicate and are actively being rendered via `useQuery` and friends will be refetched in the background. + - When set to `inactive`, only queries that match the refetch predicate and are NOT actively being rendered via `useQuery` and friends will be refetched in the background. + - When set to `all`, all queries that match the refetch predicate will be refetched in the background. + - When set to `none`, no queries will be refetched, and those that match the refetch predicate will be marked as invalid only. - `refetchPage: (page: TData, index: number, allPages: TData[]) => boolean` - Only for [Infinite Queries](../guides/infinite-queries#refetchpage) - Use this function to specify which pages should be refetched @@ -314,10 +313,10 @@ await queryClient.refetchQueries() await queryClient.refetchQueries({ stale: true }) // refetch all active queries partially matching a query key: -await queryClient.refetchQueries(['posts'], { active: true }) +await queryClient.refetchQueries(['posts'], { type: 'active' }) // refetch all active queries exactly matching a query key: -await queryClient.refetchQueries(['posts', 1], { active: true, exact: true }) +await queryClient.refetchQueries(['posts', 1], { type: 'active', exact: true }) ``` **Options** diff --git a/src/core/queryClient.ts b/src/core/queryClient.ts index d6e973136a..32cf43e160 100644 --- a/src/core/queryClient.ts +++ b/src/core/queryClient.ts @@ -203,8 +203,8 @@ export class QueryClient { const queryCache = this.queryCache const refetchFilters: RefetchQueryFilters = { + type: 'active', ...filters, - active: true, } return notifyManager.batch(() => { @@ -255,18 +255,18 @@ export class QueryClient { ): Promise { const [filters, options] = parseFilterArgs(arg1, arg2, arg3) - const refetchFilters: RefetchQueryFilters = { - ...filters, - // if filters.refetchActive is not provided and filters.active is explicitly false, - // e.g. invalidateQueries({ active: false }), we don't want to refetch active queries - active: filters.refetchActive ?? filters.active ?? true, - inactive: filters.refetchInactive ?? false, - } - return notifyManager.batch(() => { this.queryCache.findAll(filters).forEach(query => { query.invalidate() }) + + if (filters?.refetchType === 'none') { + return Promise.resolve() + } + const refetchFilters: RefetchQueryFilters = { + ...filters, + type: filters?.refetchType ?? filters?.type ?? 'active', + } return this.refetchQueries(refetchFilters, options) }) } diff --git a/src/core/tests/queriesObserver.test.tsx b/src/core/tests/queriesObserver.test.tsx index 8aca223332..9b0a7535e9 100644 --- a/src/core/tests/queriesObserver.test.tsx +++ b/src/core/tests/queriesObserver.test.tsx @@ -101,11 +101,11 @@ describe('queriesObserver', () => { observer.setQueries([{ queryKey: key2, queryFn: queryFn2 }]) await sleep(1) const queryCache = queryClient.getQueryCache() - expect(queryCache.find(key1, { active: true })).toBeUndefined() - expect(queryCache.find(key2, { active: true })).toBeDefined() + expect(queryCache.find(key1, { type: 'active' })).toBeUndefined() + expect(queryCache.find(key2, { type: 'active' })).toBeDefined() unsubscribe() - expect(queryCache.find(key1, { active: true })).toBeUndefined() - expect(queryCache.find(key2, { active: true })).toBeUndefined() + expect(queryCache.find(key1, { type: 'active' })).toBeUndefined() + expect(queryCache.find(key2, { type: 'active' })).toBeUndefined() expect(results.length).toBe(6) expect(results[0]).toMatchObject([ { status: 'idle', data: undefined }, diff --git a/src/core/tests/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx index cf26eb3ef7..4a17d2d0a0 100644 --- a/src/core/tests/queryCache.test.tsx +++ b/src/core/tests/queryCache.test.tsx @@ -92,20 +92,20 @@ describe('queryCache', () => { expect(queryCache.findAll([key1])).toEqual([query1]) expect(queryCache.findAll()).toEqual([query1, query2, query3, query4]) expect(queryCache.findAll({})).toEqual([query1, query2, query3, query4]) - expect(queryCache.findAll(key1, { active: false })).toEqual([query1]) - expect(queryCache.findAll(key1, { active: true })).toEqual([]) + expect(queryCache.findAll(key1, { type: 'inactive' })).toEqual([query1]) + expect(queryCache.findAll(key1, { type: 'active' })).toEqual([]) expect(queryCache.findAll(key1, { stale: true })).toEqual([]) expect(queryCache.findAll(key1, { stale: false })).toEqual([query1]) - expect(queryCache.findAll(key1, { stale: false, active: true })).toEqual( - [] - ) expect( - queryCache.findAll(key1, { stale: false, active: false }) + queryCache.findAll(key1, { stale: false, type: 'active' }) + ).toEqual([]) + expect( + queryCache.findAll(key1, { stale: false, type: 'inactive' }) ).toEqual([query1]) expect( queryCache.findAll(key1, { stale: false, - active: false, + type: 'inactive', exact: true, }) ).toEqual([query1]) @@ -128,8 +128,8 @@ describe('queryCache', () => { query3, ]) expect(queryCache.findAll([{ a: 'a' }], { stale: true })).toEqual([]) - expect(queryCache.findAll([{ a: 'a' }], { active: true })).toEqual([]) - expect(queryCache.findAll([{ a: 'a' }], { inactive: true })).toEqual([ + expect(queryCache.findAll([{ a: 'a' }], { type: 'active' })).toEqual([]) + expect(queryCache.findAll([{ a: 'a' }], { type: 'inactive' })).toEqual([ query3, ]) expect( diff --git a/src/core/tests/queryClient.test.tsx b/src/core/tests/queryClient.test.tsx index 7fa76f9e74..47f0cd7f07 100644 --- a/src/core/tests/queryClient.test.tsx +++ b/src/core/tests/queryClient.test.tsx @@ -673,7 +673,7 @@ describe('queryClient', () => { staleTime: Infinity, }) const unsubscribe = observer.subscribe() - await queryClient.refetchQueries({ active: true, stale: false }) + await queryClient.refetchQueries({ type: 'active', stale: false }) unsubscribe() expect(queryFn1).toHaveBeenCalledTimes(2) expect(queryFn2).toHaveBeenCalledTimes(1) @@ -713,7 +713,7 @@ describe('queryClient', () => { }) const unsubscribe = observer.subscribe() await queryClient.refetchQueries( - { active: true, stale: true }, + { type: 'active', stale: true }, { cancelRefetch: false } ) unsubscribe() @@ -753,7 +753,7 @@ describe('queryClient', () => { staleTime: Infinity, }) const unsubscribe = observer.subscribe() - await queryClient.refetchQueries({ active: true, inactive: true }) + await queryClient.refetchQueries({ type: 'all' }) unsubscribe() expect(queryFn1).toHaveBeenCalledTimes(2) expect(queryFn2).toHaveBeenCalledTimes(2) @@ -772,7 +772,7 @@ describe('queryClient', () => { staleTime: Infinity, }) const unsubscribe = observer.subscribe() - await queryClient.refetchQueries({ active: true }) + await queryClient.refetchQueries({ type: 'active' }) unsubscribe() expect(queryFn1).toHaveBeenCalledTimes(2) expect(queryFn2).toHaveBeenCalledTimes(1) @@ -791,31 +791,12 @@ describe('queryClient', () => { staleTime: Infinity, }) const unsubscribe = observer.subscribe() - await queryClient.refetchQueries({ inactive: true }) + await queryClient.refetchQueries({ type: 'inactive' }) unsubscribe() expect(queryFn1).toHaveBeenCalledTimes(1) expect(queryFn2).toHaveBeenCalledTimes(2) }) - test('should skip refetch for all active and inactive queries', async () => { - const key1 = queryKey() - const key2 = queryKey() - const queryFn1 = jest.fn() - const queryFn2 = jest.fn() - await queryClient.fetchQuery(key1, queryFn1) - await queryClient.fetchQuery(key2, queryFn2) - const observer = new QueryObserver(queryClient, { - queryKey: key1, - queryFn: queryFn1, - staleTime: Infinity, - }) - const unsubscribe = observer.subscribe() - await queryClient.refetchQueries({ active: false, inactive: false }) - unsubscribe() - expect(queryFn1).toHaveBeenCalledTimes(1) - expect(queryFn2).toHaveBeenCalledTimes(1) - }) - test('should throw an error if throwOnError option is set to true', async () => { const consoleMock = mockConsoleError() const key1 = queryKey() @@ -880,7 +861,7 @@ describe('queryClient', () => { expect(queryFn2).toHaveBeenCalledTimes(1) }) - test('should not refetch active queries when "refetchActive" is false', async () => { + test('should not refetch active queries when "refetch" is "none"', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = jest.fn() @@ -894,14 +875,14 @@ describe('queryClient', () => { }) const unsubscribe = observer.subscribe() queryClient.invalidateQueries(key1, { - refetchActive: false, + refetchType: 'none', }) unsubscribe() expect(queryFn1).toHaveBeenCalledTimes(1) expect(queryFn2).toHaveBeenCalledTimes(1) }) - test('should refetch inactive queries when "refetchInactive" is true', async () => { + test('should refetch inactive queries when "refetch" is "inactive"', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = jest.fn() @@ -916,14 +897,14 @@ describe('queryClient', () => { }) const unsubscribe = observer.subscribe() queryClient.invalidateQueries(key1, { - refetchInactive: true, + refetchType: 'inactive', }) unsubscribe() expect(queryFn1).toHaveBeenCalledTimes(2) expect(queryFn2).toHaveBeenCalledTimes(1) }) - test('should not refetch active queries when "refetchActive" is not provided and "active" is false', async () => { + test('should refetch active and inactive queries when "refetch" is "all"', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = jest.fn() @@ -936,12 +917,12 @@ describe('queryClient', () => { staleTime: Infinity, }) const unsubscribe = observer.subscribe() - queryClient.invalidateQueries(key1, { - active: false, + queryClient.invalidateQueries({ + refetchType: 'all', }) unsubscribe() - expect(queryFn1).toHaveBeenCalledTimes(1) - expect(queryFn2).toHaveBeenCalledTimes(1) + expect(queryFn1).toHaveBeenCalledTimes(2) + expect(queryFn2).toHaveBeenCalledTimes(2) }) test('should cancel ongoing fetches if cancelRefetch option is set (default value)', async () => { @@ -1123,7 +1104,7 @@ describe('queryClient', () => { await queryClient.invalidateQueries({ queryKey: key, - refetchInactive: true, + refetchType: 'all', refetchPage: (page, _, allPages) => { return page === allPages[0] }, @@ -1155,7 +1136,7 @@ describe('queryClient', () => { await queryClient.resetQueries({ queryKey: key, - inactive: true, + type: 'inactive', refetchPage: (page, _, allPages) => { return page === allPages[0] }, diff --git a/src/core/tests/utils.test.tsx b/src/core/tests/utils.test.tsx index e237215270..2c907c6e95 100644 --- a/src/core/tests/utils.test.tsx +++ b/src/core/tests/utils.test.tsx @@ -2,7 +2,6 @@ import { replaceEqualDeep, partialDeepEqual, isPlainObject, - mapQueryStatusFilter, parseMutationArgs, matchMutation, scheduleMicrotask, @@ -340,26 +339,6 @@ describe('core/utils', () => { }) }) - describe('mapQueryStatusFilter', () => { - it.each` - active | inactive | statusFilter - ${true} | ${true} | ${'all'} - ${undefined} | ${undefined} | ${'all'} - ${false} | ${false} | ${'none'} - ${true} | ${false} | ${'active'} - ${true} | ${undefined} | ${'active'} - ${undefined} | ${false} | ${'active'} - ${false} | ${true} | ${'inactive'} - ${undefined} | ${true} | ${'inactive'} - ${false} | ${undefined} | ${'inactive'} - `( - 'returns "$statusFilter" when active is $active, and inactive is $inactive', - ({ active, inactive, statusFilter }) => { - expect(mapQueryStatusFilter(active, inactive)).toBe(statusFilter) - } - ) - }) - describe('parseMutationArgs', () => { it('should return mutation options', () => { const options = { mutationKey: 'key' } diff --git a/src/core/types.ts b/src/core/types.ts index 2aa3dda467..7d186fa1e4 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,7 +1,7 @@ import type { MutationState } from './mutation' import type { QueryBehavior, Query } from './query' import type { RetryValue, RetryDelayValue } from './retryer' -import type { QueryFilters } from './utils' +import type { QueryFilters, QueryTypeFilter } from './utils' export type QueryKey = string | readonly unknown[] export type EnsuredQueryKey = T extends string @@ -270,8 +270,7 @@ export interface RefetchOptions extends ResultOptions { export interface InvalidateQueryFilters extends QueryFilters, RefetchPageFilters { - refetchActive?: boolean - refetchInactive?: boolean + refetchType?: QueryTypeFilter | 'none' } export interface RefetchQueryFilters diff --git a/src/core/utils.ts b/src/core/utils.ts index 1b01474502..65fbb7e949 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -14,17 +14,13 @@ import type { export interface QueryFilters { /** - * Include or exclude active queries + * Filter to active queries, inactive queries or all queries */ - active?: boolean + type?: QueryTypeFilter /** * Match query key exactly */ exact?: boolean - /** - * Include or exclude inactive queries - */ - inactive?: boolean /** * Include queries matching this predicate function */ @@ -68,7 +64,7 @@ export type Updater = | TOutput | DataUpdateFunction -export type QueryStatusFilter = 'all' | 'active' | 'inactive' | 'none' +export type QueryTypeFilter = 'all' | 'active' | 'inactive' // UTILS @@ -173,38 +169,11 @@ export function parseMutationFilterArgs( return isQueryKey(arg1) ? { ...arg2, mutationKey: arg1 } : arg1 } -export function mapQueryStatusFilter( - active?: boolean, - inactive?: boolean -): QueryStatusFilter { - if ( - (active === true && inactive === true) || - (active == null && inactive == null) - ) { - return 'all' - } else if (active === false && inactive === false) { - return 'none' - } else { - // At this point, active|inactive can only be true|false or false|true - // so, when only one value is provided, the missing one has to be the negated value - const isActive = active ?? !inactive - return isActive ? 'active' : 'inactive' - } -} - export function matchQuery( filters: QueryFilters, query: Query ): boolean { - const { - active, - exact, - fetching, - inactive, - predicate, - queryKey, - stale, - } = filters + const { type = 'all', exact, fetching, predicate, queryKey, stale } = filters if (isQueryKey(queryKey)) { if (exact) { @@ -216,16 +185,12 @@ export function matchQuery( } } - const queryStatusFilter = mapQueryStatusFilter(active, inactive) - - if (queryStatusFilter === 'none') { - return false - } else if (queryStatusFilter !== 'all') { + if (type !== 'all') { const isActive = query.isActive() - if (queryStatusFilter === 'active' && !isActive) { + if (type === 'active' && !isActive) { return false } - if (queryStatusFilter === 'inactive' && isActive) { + if (type === 'inactive' && isActive) { return false } } From 1f6ac1164e3ab39aec612c0fef77325a9fafcfb1 Mon Sep 17 00:00:00 2001 From: Eddy Date: Wed, 17 Nov 2021 21:11:34 +0100 Subject: [PATCH 04/19] fix: rename react directory to reactjs (#2884) * fix: rename react directory to reactjs the directory being named "react" causes an error with the moduleDirectories option from jest * fix: update package.json files to match the updated reactjs directory name * fix: change react test utils imports to match new directory name * docs(v4): add renamed reactjs details to migration guide Co-authored-by: Eddy Vinck --- .../guides/migrating-to-react-query-4.md | 17 ++++++++++++++++ package.json | 20 +++++++++++-------- react/package.json | 6 ------ reactjs/package.json | 6 ++++++ src/core/tests/hydration.test.tsx | 2 +- src/core/tests/infiniteQueryBehavior.test.tsx | 2 +- src/core/tests/infiniteQueryObserver.test.tsx | 2 +- src/core/tests/mutationCache.test.tsx | 2 +- src/core/tests/mutationObserver.test.tsx | 2 +- src/core/tests/mutations.test.tsx | 2 +- src/core/tests/queriesObserver.test.tsx | 2 +- src/core/tests/query.test.tsx | 2 +- src/core/tests/queryCache.test.tsx | 2 +- src/core/tests/queryClient.test.tsx | 2 +- src/core/tests/queryObserver.test.tsx | 2 +- src/core/tests/utils.test.tsx | 2 +- .../tests/storageIsFull.test.ts | 2 +- src/hydration/index.ts | 15 ++++++++++++++ src/index.ts | 2 +- src/{react => reactjs}/Hydrate.tsx | 0 .../QueryClientProvider.tsx | 0 .../QueryErrorResetBoundary.tsx | 0 src/{react => reactjs}/index.ts | 0 src/{react => reactjs}/logger.native.ts | 0 src/{react => reactjs}/logger.ts | 0 .../reactBatchedUpdates.native.ts | 0 src/{react => reactjs}/reactBatchedUpdates.ts | 0 src/{react => reactjs}/setBatchUpdatesFn.ts | 0 src/{react => reactjs}/setLogger.ts | 0 src/{react => reactjs}/tests/Hydrate.test.tsx | 0 .../tests/QueryClientProvider.test.tsx | 0 .../tests/QueryResetErrorBoundary.test.tsx | 0 .../tests/logger.native.test.tsx | 0 .../tests/ssr-hydration.test.tsx | 0 src/{react => reactjs}/tests/ssr.test.tsx | 0 .../tests/suspense.test.tsx | 0 .../tests/useInfiniteQuery.test.tsx | 0 .../tests/useIsFetching.test.tsx | 0 .../tests/useIsMutating.test.tsx | 0 .../tests/useMutation.test.tsx | 0 .../tests/useQueries.test.tsx | 0 .../tests/useQuery.test.tsx | 0 src/{react => reactjs}/tests/utils.tsx | 0 src/{react => reactjs}/types.ts | 0 src/{react => reactjs}/useBaseQuery.ts | 0 src/{react => reactjs}/useInfiniteQuery.ts | 0 src/{react => reactjs}/useIsFetching.ts | 0 src/{react => reactjs}/useIsMutating.ts | 0 src/{react => reactjs}/useMutation.ts | 0 src/{react => reactjs}/useQueries.ts | 0 src/{react => reactjs}/useQuery.ts | 0 src/{react => reactjs}/utils.ts | 0 src/ts3.8/useQueries.ts | 2 +- 53 files changed, 65 insertions(+), 29 deletions(-) delete mode 100644 react/package.json create mode 100644 reactjs/package.json create mode 100644 src/hydration/index.ts rename src/{react => reactjs}/Hydrate.tsx (100%) rename src/{react => reactjs}/QueryClientProvider.tsx (100%) rename src/{react => reactjs}/QueryErrorResetBoundary.tsx (100%) rename src/{react => reactjs}/index.ts (100%) rename src/{react => reactjs}/logger.native.ts (100%) rename src/{react => reactjs}/logger.ts (100%) rename src/{react => reactjs}/reactBatchedUpdates.native.ts (100%) rename src/{react => reactjs}/reactBatchedUpdates.ts (100%) rename src/{react => reactjs}/setBatchUpdatesFn.ts (100%) rename src/{react => reactjs}/setLogger.ts (100%) rename src/{react => reactjs}/tests/Hydrate.test.tsx (100%) rename src/{react => reactjs}/tests/QueryClientProvider.test.tsx (100%) rename src/{react => reactjs}/tests/QueryResetErrorBoundary.test.tsx (100%) rename src/{react => reactjs}/tests/logger.native.test.tsx (100%) rename src/{react => reactjs}/tests/ssr-hydration.test.tsx (100%) rename src/{react => reactjs}/tests/ssr.test.tsx (100%) rename src/{react => reactjs}/tests/suspense.test.tsx (100%) rename src/{react => reactjs}/tests/useInfiniteQuery.test.tsx (100%) rename src/{react => reactjs}/tests/useIsFetching.test.tsx (100%) rename src/{react => reactjs}/tests/useIsMutating.test.tsx (100%) rename src/{react => reactjs}/tests/useMutation.test.tsx (100%) rename src/{react => reactjs}/tests/useQueries.test.tsx (100%) rename src/{react => reactjs}/tests/useQuery.test.tsx (100%) rename src/{react => reactjs}/tests/utils.tsx (100%) rename src/{react => reactjs}/types.ts (100%) rename src/{react => reactjs}/useBaseQuery.ts (100%) rename src/{react => reactjs}/useInfiniteQuery.ts (100%) rename src/{react => reactjs}/useIsFetching.ts (100%) rename src/{react => reactjs}/useIsMutating.ts (100%) rename src/{react => reactjs}/useMutation.ts (100%) rename src/{react => reactjs}/useQueries.ts (100%) rename src/{react => reactjs}/useQuery.ts (100%) rename src/{react => reactjs}/utils.ts (100%) diff --git a/docs/src/pages/guides/migrating-to-react-query-4.md b/docs/src/pages/guides/migrating-to-react-query-4.md index c3ec75d6cb..a60331b915 100644 --- a/docs/src/pages/guides/migrating-to-react-query-4.md +++ b/docs/src/pages/guides/migrating-to-react-query-4.md @@ -93,3 +93,20 @@ For the same reason, those have also been combined: ``` This flag defaults to `active` because `refetchActive` defaulted to `true`. This means we also need a way to tell `invalidateQueries` to not refetch at all, which is why a fourth option (`none`) is also allowed here. + +### The `src/react` directory was renamed to `src/reactjs` + +Previously, react-query had a directory named `react` which imported from the `react` module. This could cause problems with some Jest configurations, resulting in errors when running tests like: + +``` +TypeError: Cannot read property 'createContext' of undefined +``` + +With the renamed directory this no longer is an issue. + +If you were importing anything from `'react-query/react'` directly in your project (as opposed to just `'react-query'`), then you need to update your imports: + +```diff +- import { QueryClientProvider } from 'react-query/react'; ++ import { QueryClientProvider } from 'react-query/reactjs'; +``` diff --git a/package.json b/package.json index 560a5dcec6..6207edf8ec 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,13 @@ "module": "es/index.js", "sideEffects": [ "es/index.js", - "es/react/index.js", - "es/react/setBatchUpdatesFn.js", - "es/react/setLogger.js", + "es/reactjs/index.js", + "es/reactjs/setBatchUpdatesFn.js", + "es/reactjs/setLogger.js", "lib/index.js", - "lib/react/index.js", - "lib/react/setBatchUpdatesFn.js", - "lib/react/setLogger.js" + "lib/reactjs/index.js", + "lib/reactjs/setBatchUpdatesFn.js", + "lib/reactjs/setLogger.js" ], "scripts": { "test": "is-ci \"test:ci\" \"test:dev\"", @@ -60,7 +60,7 @@ "createAsyncStoragePersistor-experimental", "broadcastQueryClient-experimental", "lib", - "react", + "reactjs", "scripts", "types" ], @@ -81,7 +81,11 @@ } }, "typesVersions": { - "<4.1": { "types/*": ["types/ts3.8/*"] } + "<4.1": { + "types/*": [ + "types/ts3.8/*" + ] + } }, "devDependencies": { "@babel/cli": "^7.11.6", diff --git a/react/package.json b/react/package.json deleted file mode 100644 index 02f74bc64a..0000000000 --- a/react/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "internal": true, - "main": "../lib/react/index.js", - "module": "../es/react/index.js", - "types": "../types/react/index.d.ts" -} diff --git a/reactjs/package.json b/reactjs/package.json new file mode 100644 index 0000000000..8d0e431ae3 --- /dev/null +++ b/reactjs/package.json @@ -0,0 +1,6 @@ +{ + "internal": true, + "main": "../lib/reactjs/index.js", + "module": "../es/reactjs/index.js", + "types": "../types/reactjs/index.d.ts" +} diff --git a/src/core/tests/hydration.test.tsx b/src/core/tests/hydration.test.tsx index 2de31d1132..cc7ae08a80 100644 --- a/src/core/tests/hydration.test.tsx +++ b/src/core/tests/hydration.test.tsx @@ -1,4 +1,4 @@ -import { mockNavigatorOnLine, sleep } from '../../react/tests/utils' +import { mockNavigatorOnLine, sleep } from '../../reactjs/tests/utils' import { QueryCache } from '../queryCache' import { QueryClient } from '../queryClient' import { dehydrate, hydrate } from '../hydration' diff --git a/src/core/tests/infiniteQueryBehavior.test.tsx b/src/core/tests/infiniteQueryBehavior.test.tsx index 92d5c6c9f9..8ab5389936 100644 --- a/src/core/tests/infiniteQueryBehavior.test.tsx +++ b/src/core/tests/infiniteQueryBehavior.test.tsx @@ -1,5 +1,5 @@ import { waitFor } from '@testing-library/react' -import { queryKey, mockConsoleError } from '../../react/tests/utils' +import { queryKey, mockConsoleError } from '../../reactjs/tests/utils' import { QueryClient, InfiniteQueryObserver, diff --git a/src/core/tests/infiniteQueryObserver.test.tsx b/src/core/tests/infiniteQueryObserver.test.tsx index e0fff2a28b..27094fe615 100644 --- a/src/core/tests/infiniteQueryObserver.test.tsx +++ b/src/core/tests/infiniteQueryObserver.test.tsx @@ -1,4 +1,4 @@ -import { sleep, queryKey } from '../../react/tests/utils' +import { sleep, queryKey } from '../../reactjs/tests/utils' import { QueryClient, InfiniteQueryObserver } from '../..' describe('InfiniteQueryObserver', () => { diff --git a/src/core/tests/mutationCache.test.tsx b/src/core/tests/mutationCache.test.tsx index 425c438907..2e701427ed 100644 --- a/src/core/tests/mutationCache.test.tsx +++ b/src/core/tests/mutationCache.test.tsx @@ -1,4 +1,4 @@ -import { queryKey, mockConsoleError } from '../../react/tests/utils' +import { queryKey, mockConsoleError } from '../../reactjs/tests/utils' import { MutationCache, QueryClient } from '../..' describe('mutationCache', () => { diff --git a/src/core/tests/mutationObserver.test.tsx b/src/core/tests/mutationObserver.test.tsx index 68521fb4fa..c1f577f936 100644 --- a/src/core/tests/mutationObserver.test.tsx +++ b/src/core/tests/mutationObserver.test.tsx @@ -1,5 +1,5 @@ import { waitFor } from '@testing-library/react' -import { sleep } from '../../react/tests/utils' +import { sleep } from '../../reactjs/tests/utils' import { QueryClient, MutationObserver } from '../..' describe('mutationObserver', () => { diff --git a/src/core/tests/mutations.test.tsx b/src/core/tests/mutations.test.tsx index f606ee6028..ad189c5189 100644 --- a/src/core/tests/mutations.test.tsx +++ b/src/core/tests/mutations.test.tsx @@ -1,5 +1,5 @@ import { QueryClient } from '../..' -import { mockConsoleError, queryKey, sleep } from '../../react/tests/utils' +import { mockConsoleError, queryKey, sleep } from '../../reactjs/tests/utils' import { MutationState } from '../mutation' import { MutationObserver } from '../mutationObserver' diff --git a/src/core/tests/queriesObserver.test.tsx b/src/core/tests/queriesObserver.test.tsx index 9b0a7535e9..f890d9be28 100644 --- a/src/core/tests/queriesObserver.test.tsx +++ b/src/core/tests/queriesObserver.test.tsx @@ -1,5 +1,5 @@ import { waitFor } from '@testing-library/react' -import { sleep, queryKey } from '../../react/tests/utils' +import { sleep, queryKey } from '../../reactjs/tests/utils' import { QueryClient, QueriesObserver, diff --git a/src/core/tests/query.test.tsx b/src/core/tests/query.test.tsx index 583799cfb3..5bd06adf2d 100644 --- a/src/core/tests/query.test.tsx +++ b/src/core/tests/query.test.tsx @@ -3,7 +3,7 @@ import { queryKey, mockVisibilityState, mockConsoleError, -} from '../../react/tests/utils' +} from '../../reactjs/tests/utils' import { QueryCache, QueryClient, diff --git a/src/core/tests/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx index 4a17d2d0a0..edff7dfff3 100644 --- a/src/core/tests/queryCache.test.tsx +++ b/src/core/tests/queryCache.test.tsx @@ -1,4 +1,4 @@ -import { sleep, queryKey, mockConsoleError } from '../../react/tests/utils' +import { sleep, queryKey, mockConsoleError } from '../../reactjs/tests/utils' import { QueryCache, QueryClient } from '../..' import { Query } from '.././query' diff --git a/src/core/tests/queryClient.test.tsx b/src/core/tests/queryClient.test.tsx index 47f0cd7f07..16fe2936f7 100644 --- a/src/core/tests/queryClient.test.tsx +++ b/src/core/tests/queryClient.test.tsx @@ -1,4 +1,4 @@ -import { sleep, queryKey, mockConsoleError } from '../../react/tests/utils' +import { sleep, queryKey, mockConsoleError } from '../../reactjs/tests/utils' import { InfiniteQueryObserver, QueryCache, diff --git a/src/core/tests/queryObserver.test.tsx b/src/core/tests/queryObserver.test.tsx index aea4dbc46a..31fb1ea27b 100644 --- a/src/core/tests/queryObserver.test.tsx +++ b/src/core/tests/queryObserver.test.tsx @@ -3,7 +3,7 @@ import { queryKey, mockConsoleError, expectType, -} from '../../react/tests/utils' +} from '../../reactjs/tests/utils' import { QueryClient, QueryObserver, diff --git a/src/core/tests/utils.test.tsx b/src/core/tests/utils.test.tsx index 2c907c6e95..13fda6b217 100644 --- a/src/core/tests/utils.test.tsx +++ b/src/core/tests/utils.test.tsx @@ -7,7 +7,7 @@ import { scheduleMicrotask, } from '../utils' import { QueryClient, QueryCache, setLogger, Logger } from '../..' -import { queryKey } from '../../react/tests/utils' +import { queryKey } from '../../reactjs/tests/utils' import { Mutation } from '../mutation' import { waitFor } from '@testing-library/dom' diff --git a/src/createWebStoragePersistor-experimental/tests/storageIsFull.test.ts b/src/createWebStoragePersistor-experimental/tests/storageIsFull.test.ts index 2b19a1d45b..328884d4d1 100644 --- a/src/createWebStoragePersistor-experimental/tests/storageIsFull.test.ts +++ b/src/createWebStoragePersistor-experimental/tests/storageIsFull.test.ts @@ -1,5 +1,5 @@ import { dehydrate, MutationCache, QueryCache, QueryClient } from '../../core' -import { sleep } from '../../react/tests/utils' +import { sleep } from '../../reactjs/tests/utils' import { createWebStoragePersistor } from '../index' function getMockStorage(limitSize?: number) { diff --git a/src/hydration/index.ts b/src/hydration/index.ts new file mode 100644 index 0000000000..9a53924dea --- /dev/null +++ b/src/hydration/index.ts @@ -0,0 +1,15 @@ +// This package once contained these functions, but they have now been moved +// into the core and react packages. +// They are re-exported here to avoid a breaking change, but this package +// should be considered deprecated and removed in a future major version. +export { dehydrate, hydrate, useHydrate, Hydrate } from 'react-query' + +// Types +export type { + DehydrateOptions, + DehydratedState, + HydrateOptions, + ShouldDehydrateMutationFunction, + ShouldDehydrateQueryFunction, +} from '../core/hydration' +export type { HydrateProps } from '../reactjs/Hydrate' diff --git a/src/index.ts b/src/index.ts index 5a7d84f690..9fe661089c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ export * from './core' -export * from './react' +export * from './reactjs' diff --git a/src/react/Hydrate.tsx b/src/reactjs/Hydrate.tsx similarity index 100% rename from src/react/Hydrate.tsx rename to src/reactjs/Hydrate.tsx diff --git a/src/react/QueryClientProvider.tsx b/src/reactjs/QueryClientProvider.tsx similarity index 100% rename from src/react/QueryClientProvider.tsx rename to src/reactjs/QueryClientProvider.tsx diff --git a/src/react/QueryErrorResetBoundary.tsx b/src/reactjs/QueryErrorResetBoundary.tsx similarity index 100% rename from src/react/QueryErrorResetBoundary.tsx rename to src/reactjs/QueryErrorResetBoundary.tsx diff --git a/src/react/index.ts b/src/reactjs/index.ts similarity index 100% rename from src/react/index.ts rename to src/reactjs/index.ts diff --git a/src/react/logger.native.ts b/src/reactjs/logger.native.ts similarity index 100% rename from src/react/logger.native.ts rename to src/reactjs/logger.native.ts diff --git a/src/react/logger.ts b/src/reactjs/logger.ts similarity index 100% rename from src/react/logger.ts rename to src/reactjs/logger.ts diff --git a/src/react/reactBatchedUpdates.native.ts b/src/reactjs/reactBatchedUpdates.native.ts similarity index 100% rename from src/react/reactBatchedUpdates.native.ts rename to src/reactjs/reactBatchedUpdates.native.ts diff --git a/src/react/reactBatchedUpdates.ts b/src/reactjs/reactBatchedUpdates.ts similarity index 100% rename from src/react/reactBatchedUpdates.ts rename to src/reactjs/reactBatchedUpdates.ts diff --git a/src/react/setBatchUpdatesFn.ts b/src/reactjs/setBatchUpdatesFn.ts similarity index 100% rename from src/react/setBatchUpdatesFn.ts rename to src/reactjs/setBatchUpdatesFn.ts diff --git a/src/react/setLogger.ts b/src/reactjs/setLogger.ts similarity index 100% rename from src/react/setLogger.ts rename to src/reactjs/setLogger.ts diff --git a/src/react/tests/Hydrate.test.tsx b/src/reactjs/tests/Hydrate.test.tsx similarity index 100% rename from src/react/tests/Hydrate.test.tsx rename to src/reactjs/tests/Hydrate.test.tsx diff --git a/src/react/tests/QueryClientProvider.test.tsx b/src/reactjs/tests/QueryClientProvider.test.tsx similarity index 100% rename from src/react/tests/QueryClientProvider.test.tsx rename to src/reactjs/tests/QueryClientProvider.test.tsx diff --git a/src/react/tests/QueryResetErrorBoundary.test.tsx b/src/reactjs/tests/QueryResetErrorBoundary.test.tsx similarity index 100% rename from src/react/tests/QueryResetErrorBoundary.test.tsx rename to src/reactjs/tests/QueryResetErrorBoundary.test.tsx diff --git a/src/react/tests/logger.native.test.tsx b/src/reactjs/tests/logger.native.test.tsx similarity index 100% rename from src/react/tests/logger.native.test.tsx rename to src/reactjs/tests/logger.native.test.tsx diff --git a/src/react/tests/ssr-hydration.test.tsx b/src/reactjs/tests/ssr-hydration.test.tsx similarity index 100% rename from src/react/tests/ssr-hydration.test.tsx rename to src/reactjs/tests/ssr-hydration.test.tsx diff --git a/src/react/tests/ssr.test.tsx b/src/reactjs/tests/ssr.test.tsx similarity index 100% rename from src/react/tests/ssr.test.tsx rename to src/reactjs/tests/ssr.test.tsx diff --git a/src/react/tests/suspense.test.tsx b/src/reactjs/tests/suspense.test.tsx similarity index 100% rename from src/react/tests/suspense.test.tsx rename to src/reactjs/tests/suspense.test.tsx diff --git a/src/react/tests/useInfiniteQuery.test.tsx b/src/reactjs/tests/useInfiniteQuery.test.tsx similarity index 100% rename from src/react/tests/useInfiniteQuery.test.tsx rename to src/reactjs/tests/useInfiniteQuery.test.tsx diff --git a/src/react/tests/useIsFetching.test.tsx b/src/reactjs/tests/useIsFetching.test.tsx similarity index 100% rename from src/react/tests/useIsFetching.test.tsx rename to src/reactjs/tests/useIsFetching.test.tsx diff --git a/src/react/tests/useIsMutating.test.tsx b/src/reactjs/tests/useIsMutating.test.tsx similarity index 100% rename from src/react/tests/useIsMutating.test.tsx rename to src/reactjs/tests/useIsMutating.test.tsx diff --git a/src/react/tests/useMutation.test.tsx b/src/reactjs/tests/useMutation.test.tsx similarity index 100% rename from src/react/tests/useMutation.test.tsx rename to src/reactjs/tests/useMutation.test.tsx diff --git a/src/react/tests/useQueries.test.tsx b/src/reactjs/tests/useQueries.test.tsx similarity index 100% rename from src/react/tests/useQueries.test.tsx rename to src/reactjs/tests/useQueries.test.tsx diff --git a/src/react/tests/useQuery.test.tsx b/src/reactjs/tests/useQuery.test.tsx similarity index 100% rename from src/react/tests/useQuery.test.tsx rename to src/reactjs/tests/useQuery.test.tsx diff --git a/src/react/tests/utils.tsx b/src/reactjs/tests/utils.tsx similarity index 100% rename from src/react/tests/utils.tsx rename to src/reactjs/tests/utils.tsx diff --git a/src/react/types.ts b/src/reactjs/types.ts similarity index 100% rename from src/react/types.ts rename to src/reactjs/types.ts diff --git a/src/react/useBaseQuery.ts b/src/reactjs/useBaseQuery.ts similarity index 100% rename from src/react/useBaseQuery.ts rename to src/reactjs/useBaseQuery.ts diff --git a/src/react/useInfiniteQuery.ts b/src/reactjs/useInfiniteQuery.ts similarity index 100% rename from src/react/useInfiniteQuery.ts rename to src/reactjs/useInfiniteQuery.ts diff --git a/src/react/useIsFetching.ts b/src/reactjs/useIsFetching.ts similarity index 100% rename from src/react/useIsFetching.ts rename to src/reactjs/useIsFetching.ts diff --git a/src/react/useIsMutating.ts b/src/reactjs/useIsMutating.ts similarity index 100% rename from src/react/useIsMutating.ts rename to src/reactjs/useIsMutating.ts diff --git a/src/react/useMutation.ts b/src/reactjs/useMutation.ts similarity index 100% rename from src/react/useMutation.ts rename to src/reactjs/useMutation.ts diff --git a/src/react/useQueries.ts b/src/reactjs/useQueries.ts similarity index 100% rename from src/react/useQueries.ts rename to src/reactjs/useQueries.ts diff --git a/src/react/useQuery.ts b/src/reactjs/useQuery.ts similarity index 100% rename from src/react/useQuery.ts rename to src/reactjs/useQuery.ts diff --git a/src/react/utils.ts b/src/reactjs/utils.ts similarity index 100% rename from src/react/utils.ts rename to src/reactjs/utils.ts diff --git a/src/ts3.8/useQueries.ts b/src/ts3.8/useQueries.ts index b710556d35..a143b5359f 100644 --- a/src/ts3.8/useQueries.ts +++ b/src/ts3.8/useQueries.ts @@ -1,4 +1,4 @@ -import { UseQueryOptions, UseQueryResult } from '../react/types' +import { UseQueryOptions, UseQueryResult } from '../reactjs/types' /** * Backwards-compatible definition for TS < 4.1 From ac1eefdf357d9b77f57820326582036aa0a59f77 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Wed, 17 Nov 2021 21:26:21 +0100 Subject: [PATCH 05/19] feat: mutation cache duration (#2963) * feat: mutation cachetime stramline queryCache / mutationCache events by combining them into notifiable.ts * feat: mutation cachetime removable * feat: mutation cachetime add gc to mutations * feat: mutation cachetime streamline event types between queries and mutations * feat: mutation cachetime tests, and I forgot to implement optionalRemove, so make it abstract * feat: mutation cachetime replicate gc behavior from https://github.com/tannerlinsley/react-query/pull/2950 and add more tests * feat: mutation cachetime get test coverage back to 100% * feat: mutation cachetime docs * feat: mutation cachetime try to make tests more resilient * feat: mutation cachetime fix imports after merge conflict --- .../guides/migrating-to-react-query-4.md | 27 ++++ docs/src/pages/reference/MutationCache.md | 6 +- docs/src/pages/reference/useMutation.md | 6 +- .../index.ts | 12 +- src/core/mutation.ts | 45 +++++- src/core/mutationCache.ts | 57 +++++--- src/core/notifiable.ts | 12 ++ src/core/query.ts | 36 ++--- src/core/queryCache.ts | 36 ++--- src/core/queryObserver.ts | 7 +- src/core/removable.ts | 35 +++++ src/core/tests/mutationCache.test.tsx | 136 +++++++++++++++++- src/core/tests/queryCache.test.tsx | 4 +- src/core/types.ts | 1 + 14 files changed, 335 insertions(+), 85 deletions(-) create mode 100644 src/core/notifiable.ts create mode 100644 src/core/removable.ts diff --git a/docs/src/pages/guides/migrating-to-react-query-4.md b/docs/src/pages/guides/migrating-to-react-query-4.md index a60331b915..a6df6ef61b 100644 --- a/docs/src/pages/guides/migrating-to-react-query-4.md +++ b/docs/src/pages/guides/migrating-to-react-query-4.md @@ -94,6 +94,27 @@ For the same reason, those have also been combined: This flag defaults to `active` because `refetchActive` defaulted to `true`. This means we also need a way to tell `invalidateQueries` to not refetch at all, which is why a fourth option (`none`) is also allowed here. +### Streamlined NotifyEvents + +Subscribing manually to the `QueryCache` has always given you a `QueryCacheNotifyEvent`, but this was not true for the `MutationCache`. We have streamlined the behavior and also adapted event names accordingly. + +#### QueryCacheNotifyEvent + +```diff +- type: 'queryAdded' ++ type: 'added' +- type: 'queryRemoved' ++ type: 'removed' +- type: 'queryUpdated' ++ type: 'updated' +``` + +#### MutationCacheNotifyEvent + +The `MutationCacheNotifyEvent` uses the same types as the `QueryCacheNotifyEvent`. + +> Note: This is only relevant if you manually subscribe to the caches via `queryCache.subscribe` or `mutationCache.subscribe` + ### The `src/react` directory was renamed to `src/reactjs` Previously, react-query had a directory named `react` which imported from the `react` module. This could cause problems with some Jest configurations, resulting in errors when running tests like: @@ -110,3 +131,9 @@ If you were importing anything from `'react-query/react'` directly in your proje - import { QueryClientProvider } from 'react-query/react'; + import { QueryClientProvider } from 'react-query/reactjs'; ``` + +## New Features 🚀 + +### Mutation Cache Garbage Collection + +Mutations can now also be garbage collected automatically, just like queries. The default `cacheTime` for mutations is also set to 5 minutes. diff --git a/docs/src/pages/reference/MutationCache.md b/docs/src/pages/reference/MutationCache.md index 8bb76b4745..2b4d966cc3 100644 --- a/docs/src/pages/reference/MutationCache.md +++ b/docs/src/pages/reference/MutationCache.md @@ -60,8 +60,8 @@ const mutations = mutationCache.getAll() The `subscribe` method can be used to subscribe to the mutation cache as a whole and be informed of safe/known updates to the cache like mutation states changing or mutations being updated, added or removed. ```js -const callback = mutation => { - console.log(mutation) +const callback = event => { + console.log(event.type, event.mutation) } const unsubscribe = mutationCache.subscribe(callback) @@ -69,7 +69,7 @@ const unsubscribe = mutationCache.subscribe(callback) **Options** -- `callback: (mutation?: Mutation) => void` +- `callback: (mutation?: MutationCacheNotifyEvent) => void` - This function will be called with the mutation cache any time it is updated. **Returns** diff --git a/docs/src/pages/reference/useMutation.md b/docs/src/pages/reference/useMutation.md index e8b2e9cdcb..6f4571dcbb 100644 --- a/docs/src/pages/reference/useMutation.md +++ b/docs/src/pages/reference/useMutation.md @@ -17,13 +17,14 @@ const { reset, status, } = useMutation(mutationFn, { + cacheTime, mutationKey, onError, onMutate, onSettled, onSuccess, useErrorBoundary, - meta, + meta }) mutate(variables, { @@ -39,6 +40,9 @@ mutate(variables, { - **Required** - A function that performs an asynchronous task and returns a promise. - `variables` is an object that `mutate` will pass to your `mutationFn` +- `cacheTime: number | Infinity` + - The time in milliseconds that unused/inactive cache data remains in memory. When a mutation's cache becomes unused or inactive, that cache data will be garbage collected after this duration. When different cache times are specified, the longest one will be used. + - If set to `Infinity`, will disable garbage collection - `mutationKey: string` - Optional - A mutation key can be set to inherit defaults set with `queryClient.setMutationDefaults` or to identify the mutation in the devtools. diff --git a/src/broadcastQueryClient-experimental/index.ts b/src/broadcastQueryClient-experimental/index.ts index 9f6181553a..6a36d1c339 100644 --- a/src/broadcastQueryClient-experimental/index.ts +++ b/src/broadcastQueryClient-experimental/index.ts @@ -33,20 +33,20 @@ export function broadcastQueryClient({ } = queryEvent if ( - queryEvent.type === 'queryUpdated' && + queryEvent.type === 'updated' && queryEvent.action?.type === 'success' ) { channel.postMessage({ - type: 'queryUpdated', + type: 'updated', queryHash, queryKey, state, }) } - if (queryEvent.type === 'queryRemoved') { + if (queryEvent.type === 'removed') { channel.postMessage({ - type: 'queryRemoved', + type: 'removed', queryHash, queryKey, }) @@ -61,7 +61,7 @@ export function broadcastQueryClient({ tx(() => { const { type, queryHash, queryKey, state } = action - if (type === 'queryUpdated') { + if (type === 'updated') { const query = queryCache.get(queryHash) if (query) { @@ -77,7 +77,7 @@ export function broadcastQueryClient({ }, state ) - } else if (type === 'queryRemoved') { + } else if (type === 'removed') { const query = queryCache.get(queryHash) if (query) { diff --git a/src/core/mutation.ts b/src/core/mutation.ts index 3846e60ba4..f44f9b50f6 100644 --- a/src/core/mutation.ts +++ b/src/core/mutation.ts @@ -3,6 +3,7 @@ import type { MutationCache } from './mutationCache' import type { MutationObserver } from './mutationObserver' import { getLogger } from './logger' import { notifyManager } from './notifyManager' +import { Removable } from './removable' import { Retryer } from './retryer' import { noop } from './utils' @@ -81,7 +82,7 @@ export class Mutation< TError = unknown, TVariables = void, TContext = unknown -> { +> extends Removable { state: MutationState options: MutationOptions mutationId: number @@ -92,6 +93,8 @@ export class Mutation< private retryer?: Retryer constructor(config: MutationConfig) { + super() + this.options = { ...config.defaultOptions, ...config.options, @@ -101,6 +104,9 @@ export class Mutation< this.observers = [] this.state = config.state || getDefaultState() this.meta = config.meta + + this.updateCacheTime(this.options.cacheTime) + this.scheduleGc() } setState(state: MutationState): void { @@ -110,11 +116,42 @@ export class Mutation< addObserver(observer: MutationObserver): void { if (this.observers.indexOf(observer) === -1) { this.observers.push(observer) + + // Stop the mutation from being garbage collected + this.clearGcTimeout() + + this.mutationCache.notify({ + type: 'observerAdded', + mutation: this, + observer, + }) } } removeObserver(observer: MutationObserver): void { this.observers = this.observers.filter(x => x !== observer) + + if (this.cacheTime) { + this.scheduleGc() + } else { + this.mutationCache.remove(this) + } + + this.mutationCache.notify({ + type: 'observerRemoved', + mutation: this, + observer, + }) + } + + protected optionalRemove() { + if (!this.observers.length) { + if (this.state.status === 'loading') { + this.scheduleGc() + } else { + this.mutationCache.remove(this) + } + } } cancel(): Promise { @@ -252,7 +289,11 @@ export class Mutation< this.observers.forEach(observer => { observer.onMutationUpdate(action) }) - this.mutationCache.notify(this) + this.mutationCache.notify({ + mutation: this, + type: 'updated', + action, + }) }) } } diff --git a/src/core/mutationCache.ts b/src/core/mutationCache.ts index eef98e3ec4..7a2741c922 100644 --- a/src/core/mutationCache.ts +++ b/src/core/mutationCache.ts @@ -1,9 +1,10 @@ +import { MutationObserver } from './mutationObserver' import type { MutationOptions } from './types' import type { QueryClient } from './queryClient' import { notifyManager } from './notifyManager' -import { Mutation, MutationState } from './mutation' +import { Action, Mutation, MutationState } from './mutation' import { matchMutation, MutationFilters, noop } from './utils' -import { Subscribable } from './subscribable' +import { Notifiable } from './notifiable' // TYPES @@ -12,21 +13,53 @@ interface MutationCacheConfig { error: unknown, variables: unknown, context: unknown, - mutation: Mutation + mutation: Mutation ) => void onSuccess?: ( data: unknown, variables: unknown, context: unknown, - mutation: Mutation + mutation: Mutation ) => void } -type MutationCacheListener = (mutation?: Mutation) => void +interface NotifyEventMutationAdded { + type: 'added' + mutation: Mutation +} +interface NotifyEventMutationRemoved { + type: 'removed' + mutation: Mutation +} + +interface NotifyEventMutationObserverAdded { + type: 'observerAdded' + mutation: Mutation + observer: MutationObserver +} + +interface NotifyEventMutationObserverRemoved { + type: 'observerRemoved' + mutation: Mutation + observer: MutationObserver +} + +interface NotifyEventMutationUpdated { + type: 'updated' + mutation: Mutation + action: Action +} + +type MutationCacheNotifyEvent = + | NotifyEventMutationAdded + | NotifyEventMutationRemoved + | NotifyEventMutationObserverAdded + | NotifyEventMutationObserverRemoved + | NotifyEventMutationUpdated // CLASS -export class MutationCache extends Subscribable { +export class MutationCache extends Notifiable { config: MutationCacheConfig private mutations: Mutation[] @@ -62,13 +95,13 @@ export class MutationCache extends Subscribable { add(mutation: Mutation): void { this.mutations.push(mutation) - this.notify(mutation) + this.notify({ type: 'added', mutation }) } remove(mutation: Mutation): void { this.mutations = this.mutations.filter(x => x !== mutation) mutation.cancel() - this.notify(mutation) + this.notify({ type: 'removed', mutation }) } clear(): void { @@ -97,14 +130,6 @@ export class MutationCache extends Subscribable { return this.mutations.filter(mutation => matchMutation(filters, mutation)) } - notify(mutation?: Mutation) { - notifyManager.batch(() => { - this.listeners.forEach(listener => { - listener(mutation) - }) - }) - } - onFocus(): void { this.resumePausedMutations() } diff --git a/src/core/notifiable.ts b/src/core/notifiable.ts new file mode 100644 index 0000000000..3d2bcd857f --- /dev/null +++ b/src/core/notifiable.ts @@ -0,0 +1,12 @@ +import { Subscribable } from './subscribable' +import { notifyManager } from '../core/notifyManager' + +export class Notifiable extends Subscribable<(event: TEvent) => void> { + notify(event: TEvent) { + notifyManager.batch(() => { + this.listeners.forEach(listener => { + listener(event) + }) + }) + } +} diff --git a/src/core/query.ts b/src/core/query.ts index a466b137bc..643825e381 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -2,7 +2,6 @@ import { getAbortController, Updater, functionalUpdate, - isValidTimeout, noop, replaceEqualDeep, timeUntilStale, @@ -24,6 +23,7 @@ import type { QueryObserver } from './queryObserver' import { notifyManager } from './notifyManager' import { getLogger } from './logger' import { Retryer, isCancelledError } from './retryer' +import { Removable } from './removable' // TYPES @@ -146,25 +146,25 @@ export class Query< TError = unknown, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey -> { +> extends Removable { queryKey: TQueryKey queryHash: string options!: QueryOptions initialState: QueryState revertState?: QueryState state: QueryState - cacheTime!: number meta: QueryMeta | undefined private cache: QueryCache private promise?: Promise - private gcTimeout?: number private retryer?: Retryer private observers: QueryObserver[] private defaultOptions?: QueryOptions private abortSignalConsumed: boolean constructor(config: QueryConfig) { + super() + this.abortSignalConsumed = false this.defaultOptions = config.defaultOptions this.setOptions(config.options) @@ -185,11 +185,7 @@ export class Query< this.meta = options?.meta - // Default to 5 minutes if not cache time is set - this.cacheTime = Math.max( - this.cacheTime || 0, - this.options.cacheTime ?? 5 * 60 * 1000 - ) + this.updateCacheTime(this.options.cacheTime) } setDefaultOptions( @@ -198,22 +194,7 @@ export class Query< this.defaultOptions = options } - private scheduleGc(): void { - this.clearGcTimeout() - - if (isValidTimeout(this.cacheTime)) { - this.gcTimeout = setTimeout(() => { - this.optionalRemove() - }, this.cacheTime) - } - } - - private clearGcTimeout() { - clearTimeout(this.gcTimeout) - this.gcTimeout = undefined - } - - private optionalRemove() { + protected optionalRemove() { if (!this.observers.length && !this.state.isFetching) { this.cache.remove(this) } @@ -260,7 +241,8 @@ export class Query< } destroy(): void { - this.clearGcTimeout() + super.destroy() + this.cancel({ silent: true }) } @@ -508,7 +490,7 @@ export class Query< observer.onQueryUpdate(action) }) - this.cache.notify({ query: this, type: 'queryUpdated', action }) + this.cache.notify({ query: this, type: 'updated', action }) }) } diff --git a/src/core/queryCache.ts b/src/core/queryCache.ts index f57b970ead..a5dadc7022 100644 --- a/src/core/queryCache.ts +++ b/src/core/queryCache.ts @@ -8,7 +8,7 @@ import { Action, Query, QueryState } from './query' import type { QueryKey, QueryOptions } from './types' import { notifyManager } from './notifyManager' import type { QueryClient } from './queryClient' -import { Subscribable } from './subscribable' +import { Notifiable } from './notifiable' import { QueryObserver } from './queryObserver' // TYPES @@ -23,34 +23,34 @@ interface QueryHashMap { } interface NotifyEventQueryAdded { - type: 'queryAdded' + type: 'added' query: Query } interface NotifyEventQueryRemoved { - type: 'queryRemoved' + type: 'removed' query: Query } interface NotifyEventQueryUpdated { - type: 'queryUpdated' + type: 'updated' query: Query action: Action } -interface NotifyEventObserverAdded { +interface NotifyEventQueryObserverAdded { type: 'observerAdded' query: Query observer: QueryObserver } -interface NotifyEventObserverRemoved { +interface NotifyEventQueryObserverRemoved { type: 'observerRemoved' query: Query observer: QueryObserver } -interface NotifyEventObserverResultsUpdated { +interface NotifyEventQueryObserverResultsUpdated { type: 'observerResultsUpdated' query: Query } @@ -59,15 +59,13 @@ type QueryCacheNotifyEvent = | NotifyEventQueryAdded | NotifyEventQueryRemoved | NotifyEventQueryUpdated - | NotifyEventObserverAdded - | NotifyEventObserverRemoved - | NotifyEventObserverResultsUpdated - -type QueryCacheListener = (event?: QueryCacheNotifyEvent) => void + | NotifyEventQueryObserverAdded + | NotifyEventQueryObserverRemoved + | NotifyEventQueryObserverResultsUpdated // CLASS -export class QueryCache extends Subscribable { +export class QueryCache extends Notifiable { config: QueryCacheConfig private queries: Query[] @@ -111,7 +109,7 @@ export class QueryCache extends Subscribable { this.queriesMap[query.queryHash] = query this.queries.push(query) this.notify({ - type: 'queryAdded', + type: 'added', query, }) } @@ -129,7 +127,7 @@ export class QueryCache extends Subscribable { delete this.queriesMap[query.queryHash] } - this.notify({ type: 'queryRemoved', query }) + this.notify({ type: 'removed', query }) } } @@ -179,14 +177,6 @@ export class QueryCache extends Subscribable { : this.queries } - notify(event: QueryCacheNotifyEvent) { - notifyManager.batch(() => { - this.listeners.forEach(listener => { - listener(event) - }) - }) - } - onFocus(): void { notifyManager.batch(() => { this.queries.forEach(query => { diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index fe9cba01aa..fceea94f37 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -711,9 +711,10 @@ export class QueryObserver< // Then the cache listeners if (notifyOptions.cache) { - this.client - .getQueryCache() - .notify({ query: this.currentQuery, type: 'observerResultsUpdated' }) + this.client.getQueryCache().notify({ + query: this.currentQuery, + type: 'observerResultsUpdated', + }) } }) } diff --git a/src/core/removable.ts b/src/core/removable.ts new file mode 100644 index 0000000000..c33f08202c --- /dev/null +++ b/src/core/removable.ts @@ -0,0 +1,35 @@ +import { isValidTimeout } from './utils' + +export abstract class Removable { + cacheTime!: number + private gcTimeout?: number + + destroy(): void { + this.clearGcTimeout() + } + + protected scheduleGc(): void { + this.clearGcTimeout() + + if (isValidTimeout(this.cacheTime)) { + this.gcTimeout = setTimeout(() => { + this.optionalRemove() + }, this.cacheTime) + } + } + + protected updateCacheTime(newCacheTime: number | undefined): void { + // Default to 5 minutes if no cache time is set + this.cacheTime = Math.max( + this.cacheTime || 0, + newCacheTime ?? 5 * 60 * 1000 + ) + } + + protected clearGcTimeout() { + clearTimeout(this.gcTimeout) + this.gcTimeout = undefined + } + + protected abstract optionalRemove(): void +} diff --git a/src/core/tests/mutationCache.test.tsx b/src/core/tests/mutationCache.test.tsx index 2e701427ed..37e3f3017b 100644 --- a/src/core/tests/mutationCache.test.tsx +++ b/src/core/tests/mutationCache.test.tsx @@ -1,5 +1,6 @@ -import { queryKey, mockConsoleError } from '../../reactjs/tests/utils' -import { MutationCache, QueryClient } from '../..' +import { waitFor } from '@testing-library/react' +import { queryKey, mockConsoleError, sleep } from '../../reactjs/tests/utils' +import { MutationCache, MutationObserver, QueryClient } from '../..' describe('mutationCache', () => { describe('MutationCacheConfig.onError', () => { @@ -106,4 +107,135 @@ describe('mutationCache', () => { ).toEqual([mutation2]) }) }) + + describe('garbage collection', () => { + test('should remove unused mutations after cacheTime has elapsed', async () => { + const testCache = new MutationCache() + const testClient = new QueryClient({ mutationCache: testCache }) + const onSuccess = jest.fn() + await testClient.executeMutation({ + mutationKey: ['a', 1], + variables: 1, + cacheTime: 10, + mutationFn: () => Promise.resolve(), + onSuccess, + }) + + expect(testCache.getAll()).toHaveLength(1) + await sleep(10) + await waitFor(() => { + expect(testCache.getAll()).toHaveLength(0) + }) + expect(onSuccess).toHaveBeenCalledTimes(1) + }) + + test('should not remove mutations if there are active observers', async () => { + const queryClient = new QueryClient() + const observer = new MutationObserver(queryClient, { + variables: 1, + cacheTime: 10, + mutationFn: () => Promise.resolve(), + }) + const unsubscribe = observer.subscribe() + + expect(queryClient.getMutationCache().getAll()).toHaveLength(0) + observer.mutate(1) + expect(queryClient.getMutationCache().getAll()).toHaveLength(1) + await sleep(10) + expect(queryClient.getMutationCache().getAll()).toHaveLength(1) + unsubscribe() + expect(queryClient.getMutationCache().getAll()).toHaveLength(1) + await sleep(10) + await waitFor(() => { + expect(queryClient.getMutationCache().getAll()).toHaveLength(0) + }) + }) + + test('should only remove when the last observer unsubscribes', async () => { + const queryClient = new QueryClient() + const observer1 = new MutationObserver(queryClient, { + variables: 1, + cacheTime: 10, + mutationFn: async () => { + await sleep(10) + return 'update1' + }, + }) + + const observer2 = new MutationObserver(queryClient, { + cacheTime: 10, + mutationFn: async () => { + await sleep(10) + return 'update2' + }, + }) + + await observer1.mutate() + + // we currently have no way to add multiple observers to the same mutation + const currentMutation = observer1['currentMutation']! + currentMutation?.addObserver(observer1) + currentMutation?.addObserver(observer2) + + expect(currentMutation['observers'].length).toEqual(2) + expect(queryClient.getMutationCache().getAll()).toHaveLength(1) + + currentMutation?.removeObserver(observer1) + currentMutation?.removeObserver(observer2) + expect(currentMutation['observers'].length).toEqual(0) + expect(queryClient.getMutationCache().getAll()).toHaveLength(1) + // wait for cacheTime to gc + await sleep(10) + await waitFor(() => { + expect(queryClient.getMutationCache().getAll()).toHaveLength(0) + }) + }) + + test('should be garbage collected later when unsubscribed and mutation is loading', async () => { + const queryClient = new QueryClient() + const onSuccess = jest.fn() + const observer = new MutationObserver(queryClient, { + variables: 1, + cacheTime: 10, + mutationFn: async () => { + await sleep(20) + return 'data' + }, + onSuccess, + }) + const unsubscribe = observer.subscribe() + observer.mutate(1) + unsubscribe() + expect(queryClient.getMutationCache().getAll()).toHaveLength(1) + await sleep(10) + // unsubscribe should not remove even though cacheTime has elapsed b/c mutation is still loading + expect(queryClient.getMutationCache().getAll()).toHaveLength(1) + await sleep(10) + // should be removed after an additional cacheTime wait + await waitFor(() => { + expect(queryClient.getMutationCache().getAll()).toHaveLength(0) + }) + expect(onSuccess).toHaveBeenCalledTimes(1) + }) + + test('should call callbacks even with cacheTime 0 and mutation still loading', async () => { + const queryClient = new QueryClient() + const onSuccess = jest.fn() + const observer = new MutationObserver(queryClient, { + variables: 1, + cacheTime: 0, + mutationFn: async () => { + return 'data' + }, + onSuccess, + }) + const unsubscribe = observer.subscribe() + observer.mutate(1) + unsubscribe() + await waitFor(() => { + expect(queryClient.getMutationCache().getAll()).toHaveLength(0) + }) + expect(onSuccess).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/src/core/tests/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx index edff7dfff3..09fdec6fff 100644 --- a/src/core/tests/queryCache.test.tsx +++ b/src/core/tests/queryCache.test.tsx @@ -23,7 +23,7 @@ describe('queryCache', () => { queryClient.setQueryData(key, 'foo') const query = queryCache.find(key) await sleep(1) - expect(subscriber).toHaveBeenCalledWith({ query, type: 'queryAdded' }) + expect(subscriber).toHaveBeenCalledWith({ query, type: 'added' }) unsubscribe() }) @@ -43,7 +43,7 @@ describe('queryCache', () => { queryClient.prefetchQuery(key, () => 'data') const query = queryCache.find(key) await sleep(100) - expect(callback).toHaveBeenCalledWith({ query, type: 'queryAdded' }) + expect(callback).toHaveBeenCalledWith({ query, type: 'added' }) }) test('should notify subscribers when new query with initialData is added', async () => { diff --git a/src/core/types.ts b/src/core/types.ts index 9bdd3eaf07..3e3817f339 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -536,6 +536,7 @@ export interface MutationOptions< ) => Promise | void retry?: RetryValue retryDelay?: RetryDelayValue + cacheTime?: number _defaulted?: boolean meta?: MutationMeta } From fa123913886ae7e5c4d8e10331976126bd3baf1e Mon Sep 17 00:00:00 2001 From: Prateek Surana Date: Thu, 18 Nov 2021 22:21:29 +0530 Subject: [PATCH 06/19] refactor(persistQueryClient): Make persistQueryClient stable (#2961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :truck: Remove experimental from persist-query-client * :truck: Rename persistor -> persister * ✏️ Fix Persistor -> Persister in imports * :truck: Update name in rollup config * :truck: Move createAsyncStoragePersister and createWebStoragePersister to stable version and rename persistor -> persister * 📝 Update documentation * 📝 Add migrating to v4 docs * Apply suggestions from code review Co-authored-by: Dominik Dorfmeister --- createAsyncStoragePersister/package.json | 6 +++ .../package.json | 6 --- createWebStoragePersister/package.json | 6 +++ .../package.json | 6 --- docs/src/manifests/manifest.json | 14 +++--- .../guides/migrating-to-react-query-4.md | 16 +++++++ ...stor.md => createAsyncStoragePersister.md} | 28 ++++++----- ...sistor.md => createWebStoragePersister.md} | 30 ++++++------ docs/src/pages/plugins/persistQueryClient.md | 46 +++++++++---------- examples/basic/src/index.js | 17 ++++++- package.json | 6 +-- persistQueryClient-experimental/package.json | 6 --- persistQueryClient/package.json | 6 +++ rollup.config.js | 18 ++++---- .../index.ts | 8 ++-- .../index.ts | 8 ++-- .../tests/storageIsFull.test.ts | 26 +++++------ .../index.ts | 18 ++++---- tsconfig.types.json | 6 +-- 19 files changed, 151 insertions(+), 126 deletions(-) create mode 100644 createAsyncStoragePersister/package.json delete mode 100644 createAsyncStoragePersistor-experimental/package.json create mode 100644 createWebStoragePersister/package.json delete mode 100644 createWebStoragePersistor-experimental/package.json rename docs/src/pages/plugins/{createAsyncStoragePersistor.md => createAsyncStoragePersister.md} (62%) rename docs/src/pages/plugins/{createWebStoragePersistor.md => createWebStoragePersister.md} (54%) delete mode 100644 persistQueryClient-experimental/package.json create mode 100644 persistQueryClient/package.json rename src/{createAsyncStoragePersistor-experimental => createAsyncStoragePersister}/index.ts (91%) rename src/{createWebStoragePersistor-experimental => createWebStoragePersister}/index.ts (92%) rename src/{createWebStoragePersistor-experimental => createWebStoragePersister}/tests/storageIsFull.test.ts (87%) rename src/{persistQueryClient-experimental => persistQueryClient}/index.ts (87%) diff --git a/createAsyncStoragePersister/package.json b/createAsyncStoragePersister/package.json new file mode 100644 index 0000000000..4d74825ea3 --- /dev/null +++ b/createAsyncStoragePersister/package.json @@ -0,0 +1,6 @@ +{ + "internal": true, + "main": "../lib/createAsyncStoragePersister/index.js", + "module": "../es/createAsyncStoragePersister/index.js", + "types": "../types/createAsyncStoragePersister/index.d.ts" +} diff --git a/createAsyncStoragePersistor-experimental/package.json b/createAsyncStoragePersistor-experimental/package.json deleted file mode 100644 index 6a1bf0b2cb..0000000000 --- a/createAsyncStoragePersistor-experimental/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "internal": true, - "main": "../lib/createAsyncStoragePersistor-experimental/index.js", - "module": "../es/createAsyncStoragePersistor-experimental/index.js", - "types": "../types/createAsyncStoragePersistor-experimental/index.d.ts" -} diff --git a/createWebStoragePersister/package.json b/createWebStoragePersister/package.json new file mode 100644 index 0000000000..21c28c7658 --- /dev/null +++ b/createWebStoragePersister/package.json @@ -0,0 +1,6 @@ +{ + "internal": true, + "main": "../lib/createWebStoragePersister/index.js", + "module": "../es/createWebStoragePersister/index.js", + "types": "../types/createWebStoragePersister/index.d.ts" +} diff --git a/createWebStoragePersistor-experimental/package.json b/createWebStoragePersistor-experimental/package.json deleted file mode 100644 index 2f4a1486c3..0000000000 --- a/createWebStoragePersistor-experimental/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "internal": true, - "main": "../lib/createWebStoragePersistor-experimental/index.js", - "module": "../es/createWebStoragePersistor-experimental/index.js", - "types": "../types/createWebStoragePersistor-experimental/index.d.ts" -} diff --git a/docs/src/manifests/manifest.json b/docs/src/manifests/manifest.json index 1c97c82318..c0347bf931 100644 --- a/docs/src/manifests/manifest.json +++ b/docs/src/manifests/manifest.json @@ -319,19 +319,19 @@ "heading": true, "routes": [ { - "title": "persistQueryClient (Experimental)", + "title": "persistQueryClient", "path": "/plugins/persistQueryClient", "editUrl": "/plugins/persistQueryClient.md" }, { - "title": "createWebStoragePersistor (Experimental)", - "path": "/plugins/createWebStoragePersistor", - "editUrl": "/plugins/createWebStoragePersistor.md" + "title": "createWebStoragePersister", + "path": "/plugins/createWebStoragePersister", + "editUrl": "/plugins/createWebStoragePersister.md" }, { - "title": "createAsyncStoragePersistor (Experimental)", - "path": "/plugins/createAsyncStoragePersistor", - "editUrl": "/plugins/createAsyncStoragePersistor.md" + "title": "createAsyncStoragePersister", + "path": "/plugins/createAsyncStoragePersister", + "editUrl": "/plugins/createAsyncStoragePersister.md" }, { "title": "broadcastQueryClient (Experimental)", diff --git a/docs/src/pages/guides/migrating-to-react-query-4.md b/docs/src/pages/guides/migrating-to-react-query-4.md index a6df6ef61b..01a7d79c84 100644 --- a/docs/src/pages/guides/migrating-to-react-query-4.md +++ b/docs/src/pages/guides/migrating-to-react-query-4.md @@ -132,6 +132,22 @@ If you were importing anything from `'react-query/react'` directly in your proje + import { QueryClientProvider } from 'react-query/reactjs'; ``` +### `persistQueryClient` and the corresponding persister plugins are no longer experimental and have been renamed + +The plugins `createWebStoragePersistor` and `createAsyncStoragePersistor` have been renamed to [`createWebStoragePersister`](/plugins/createWebStoragePersister) and [`createAsyncStoragePersister`](/plugins/createAsyncStoragePersister) respectively. The interface `Persistor` in `persistQueryClient` has also been renamed to `Persister`. Checkout [this stackexchange](https://english.stackexchange.com/questions/206893/persister-or-persistor) for the motivation of this change. + +Since these plugins are no longer experimental, their import paths have also been updated: + +```diff +- import { persistQueryClient } from 'react-query/persistQueryClient-experimental' +- import { createWebStoragePersistor } from 'react-query/createWebStoragePersistor-experimental' +- import { createAsyncStoragePersistor } from 'react-query/createAsyncStoragePersistor-experimental' + ++ import { persistQueryClient } from 'react-query/persistQueryClient' ++ import { createWebStoragePersister } from 'react-query/createWebStoragePersister' ++ import { createAsyncStoragePersister } from 'react-query/createAsyncStoragePersister' +``` + ## New Features 🚀 ### Mutation Cache Garbage Collection diff --git a/docs/src/pages/plugins/createAsyncStoragePersistor.md b/docs/src/pages/plugins/createAsyncStoragePersister.md similarity index 62% rename from docs/src/pages/plugins/createAsyncStoragePersistor.md rename to docs/src/pages/plugins/createAsyncStoragePersister.md index 99f2a2d5f9..cdd8650b64 100644 --- a/docs/src/pages/plugins/createAsyncStoragePersistor.md +++ b/docs/src/pages/plugins/createAsyncStoragePersister.md @@ -1,25 +1,23 @@ --- -id: createAsyncStoragePersistor -title: createAsyncStoragePersistor (Experimental) +id: createAsyncStoragePersister +title: createAsyncStoragePersister --- -> VERY IMPORTANT: This utility is currently in an experimental stage. This means that breaking changes will happen in minor AND patch releases. Use at your own risk. If you choose to rely on this in production in an experimental stage, please lock your version to a patch-level version to avoid unexpected breakages. - ## Installation -This utility comes packaged with `react-query` and is available under the `react-query/createAsyncStoragePersistor-experimental` import. +This utility comes packaged with `react-query` and is available under the `react-query/createAsyncStoragePersister` import. ## Usage -- Import the `createAsyncStoragePersistor` function -- Create a new asyncStoragePersistor +- Import the `createAsyncStoragePersister` function +- Create a new asyncStoragePersister - you can pass any `storage` to it that adheres to the `AsyncStorage` interface - the example below uses the async-storage from React Native - Pass it to the [`persistQueryClient`](./persistQueryClient) function ```ts import AsyncStorage from '@react-native-async-storage/async-storage' -import { persistQueryClient } from 'react-query/persistQueryClient-experimental' -import { createAsyncStoragePersistor } from 'react-query/createAsyncStoragePersistor-experimental' +import { persistQueryClient } from 'react-query/persistQueryClient' +import { createAsyncStoragePersister } from 'react-query/createAsyncStoragePersister' const queryClient = new QueryClient({ defaultOptions: { @@ -29,30 +27,30 @@ const queryClient = new QueryClient({ }, }) -const asyncStoragePersistor = createAsyncStoragePersistor({ +const asyncStoragePersister = createAsyncStoragePersister({ storage: AsyncStorage }) persistQueryClient({ queryClient, - persistor: asyncStoragePersistor, + persister: asyncStoragePersister, }) ``` ## API -### `createAsyncStoragePersistor` +### `createAsyncStoragePersister` -Call this function to create an asyncStoragePersistor that you can use later with `persistQueryClient`. +Call this function to create an asyncStoragePersister that you can use later with `persistQueryClient`. ```js -createAsyncStoragePersistor(options: CreateAsyncStoragePersistorOptions) +createAsyncStoragePersister(options: CreateAsyncStoragePersisterOptions) ``` ### `Options` ```ts -interface CreateAsyncStoragePersistorOptions { +interface CreateAsyncStoragePersisterOptions { /** The storage client used for setting an retrieving items from cache */ storage: AsyncStorage /** The key to use when storing the cache to localStorage */ diff --git a/docs/src/pages/plugins/createWebStoragePersistor.md b/docs/src/pages/plugins/createWebStoragePersister.md similarity index 54% rename from docs/src/pages/plugins/createWebStoragePersistor.md rename to docs/src/pages/plugins/createWebStoragePersister.md index 983ac336bf..58f9d93168 100644 --- a/docs/src/pages/plugins/createWebStoragePersistor.md +++ b/docs/src/pages/plugins/createWebStoragePersister.md @@ -1,23 +1,21 @@ --- -id: createWebStoragePersistor -title: createWebStoragePersistor (Experimental) +id: createWebStoragePersister +title: createWebStoragePersister --- -> VERY IMPORTANT: This utility is currently in an experimental stage. This means that breaking changes will happen in minor AND patch releases. Use at your own risk. If you choose to rely on this in production in an experimental stage, please lock your version to a patch-level version to avoid unexpected breakages. - ## Installation -This utility comes packaged with `react-query` and is available under the `react-query/createWebStoragePersistor-experimental` import. +This utility comes packaged with `react-query` and is available under the `react-query/createWebStoragePersister` import. ## Usage -- Import the `createWebStoragePersistor` function -- Create a new webStoragePersistor +- Import the `createWebStoragePersister` function +- Create a new webStoragePersister - Pass it to the [`persistQueryClient`](./persistQueryClient) function ```ts -import { persistQueryClient } from 'react-query/persistQueryClient-experimental' -import { createWebStoragePersistor } from 'react-query/createWebStoragePersistor-experimental' +import { persistQueryClient } from 'react-query/persistQueryClient' +import { createWebStoragePersister } from 'react-query/createWebStoragePersister' const queryClient = new QueryClient({ defaultOptions: { @@ -27,29 +25,29 @@ const queryClient = new QueryClient({ }, }) -const localStoragePersistor = createWebStoragePersistor({ storage: window.localStorage }) -// const sessionStoragePersistor = createWebStoragePersistor({ storage: window.sessionStorage }) +const localStoragePersister = createWebStoragePersister({ storage: window.localStorage }) +// const sessionStoragePersister = createWebStoragePersister({ storage: window.sessionStorage }) persistQueryClient({ queryClient, - persistor: localStoragePersistor, + persister: localStoragePersister, }) ``` ## API -### `createWebStoragePersistor` +### `createWebStoragePersister` -Call this function to create a webStoragePersistor that you can use later with `persistQueryClient`. +Call this function to create a webStoragePersister that you can use later with `persistQueryClient`. ```js -createWebStoragePersistor(options: CreateWebStoragePersistorOptions) +createWebStoragePersister(options: CreateWebStoragePersisterOptions) ``` ### `Options` ```ts -interface CreateWebStoragePersistorOptions { +interface CreateWebStoragePersisterOptions { /** The storage client used for setting an retrieving items from cache (window.localStorage or window.sessionStorage) */ storage: Storage /** The key to use when storing the cache */ diff --git a/docs/src/pages/plugins/persistQueryClient.md b/docs/src/pages/plugins/persistQueryClient.md index 1236d07867..969ea98804 100644 --- a/docs/src/pages/plugins/persistQueryClient.md +++ b/docs/src/pages/plugins/persistQueryClient.md @@ -1,28 +1,26 @@ --- id: persistQueryClient -title: persistQueryClient (Experimental) +title: persistQueryClient --- -> VERY IMPORTANT: This utility is currently in an experimental stage. This means that breaking changes will happen in minor AND patch releases. Use at your own risk. If you choose to rely on this in production in an experimental stage, please lock your version to a patch-level version to avoid unexpected breakages. +`persistQueryClient` is a utility for persisting the state of your queryClient and its caches for later use. Different **persisters** can be used to store your client and cache to many different storage layers. -`persistQueryClient` is a utility for persisting the state of your queryClient and its caches for later use. Different **persistors** can be used to store your client and cache to many different storage layers. +## Officially Supported Persisters -## Officially Supported Persistors - -- [createWebStoragePersistor (Experimental)](/plugins/createWebStoragePersistor) -- [createAsyncStoragePersistor (Experimental)](/plugins/createAsyncStoragePersistor) +- [createWebStoragePersister](/plugins/createWebStoragePersister) +- [createAsyncStoragePersister](/plugins/createAsyncStoragePersister) ## Installation -This utility comes packaged with `react-query` and is available under the `react-query/persistQueryClient-experimental` import. +This utility comes packaged with `react-query` and is available under the `react-query/persistQueryClient` import. ## Usage -Import the `persistQueryClient` function, and pass it your `QueryClient` instance (with a `cacheTime` set), and a Persistor interface (there are multiple persistor types you can use): +Import the `persistQueryClient` function, and pass it your `QueryClient` instance (with a `cacheTime` set), and a Persister interface (there are multiple persister types you can use): ```ts -import { persistQueryClient } from 'react-query/persistQueryClient-experimental' -import { createWebStoragePersistor } from 'react-query/createWebStoragePersistor-experimental' +import { persistQueryClient } from 'react-query/persistQueryClient' +import { createWebStoragePersister } from 'react-query/createWebStoragePersister' const queryClient = new QueryClient({ defaultOptions: { @@ -32,11 +30,11 @@ const queryClient = new QueryClient({ }, }) -const localStoragePersistor = createWebStoragePersistor({storage: window.localStorage}) +const localStoragePersister = createWebStoragePersister({storage: window.localStorage}) persistQueryClient({ queryClient, - persistor: localStoragePersistor, + persister: localStoragePersister, }) ``` @@ -52,11 +50,11 @@ You can also pass it `Infinity` to disable garbage collection behavior entirely. As you use your application: -- When your query/mutation cache is updated, it will be dehydrated and stored by the persistor you provided. **By default**, this action is throttled to happen at most every 1 second to save on potentially expensive writes to a persistor, but can be customized as you see fit. +- When your query/mutation cache is updated, it will be dehydrated and stored by the persister you provided. **By default**, this action is throttled to happen at most every 1 second to save on potentially expensive writes to a persister, but can be customized as you see fit. When you reload/bootstrap your app: -- Attempts to load a previously persisted dehydrated query/mutation cache from the persistor +- Attempts to load a previously persisted dehydrated query/mutation cache from the persister - If a cache is found that is older than the `maxAge` (which by default is 24 hours), it will be discarded. This can be customized as you see fit. ## Cache Busting @@ -64,17 +62,17 @@ When you reload/bootstrap your app: Sometimes you may make changes to your application or data that immediately invalidate any and all cached data. If and when this happens, you can pass a `buster` string option to `persistQueryClient`, and if the cache that is found does not also have that buster string, it will be discarded. ```ts -persistQueryClient({ queryClient, persistor, buster: buildHash }) +persistQueryClient({ queryClient, persister, buster: buildHash }) ``` ## API ### `persistQueryClient` -Pass this function a `QueryClient` instance and a persistor that will persist your cache. Both are **required** +Pass this function a `QueryClient` instance and a persister that will persist your cache. Both are **required** ```ts -persistQueryClient({ queryClient, persistor }) +persistQueryClient({ queryClient, persister }) ``` ### `Options` @@ -85,9 +83,9 @@ An object of options: interface PersistQueryClientOptions { /** The QueryClient to persist */ queryClient: QueryClient - /** The Persistor interface for storing and restoring the cache + /** The Persister interface for storing and restoring the cache * to/from a persisted location */ - persistor: Persistor + persister: Persister /** The max-allowed age of the cache. * If a persisted cache is found that is older than this * time, it will be discarded */ @@ -111,12 +109,12 @@ The default options are: } ``` -## Building a Persistor +## Building a Persister -Persistors have the following interface: +Persisters have the following interface: ```ts -export interface Persistor { +export interface Persister { persistClient(persistClient: PersistedClient): Promisable restoreClient(): Promisable removeClient(): Promisable @@ -133,4 +131,4 @@ export interface PersistedClient { } ``` -Satisfy all of these interfaces and you've got yourself a persistor! +Satisfy all of these interfaces and you've got yourself a persister! diff --git a/examples/basic/src/index.js b/examples/basic/src/index.js index 722e2bcc7c..019d856875 100644 --- a/examples/basic/src/index.js +++ b/examples/basic/src/index.js @@ -9,8 +9,23 @@ import { QueryClientProvider, } from "react-query"; import { ReactQueryDevtools } from "react-query/devtools"; +import { persistQueryClient } from 'react-query/persistQueryClient'; +import { createWebStoragePersister } from 'react-query/createWebStoragePersister' -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + cacheTime: 1000 * 60 * 60 * 24, // 24 hours + }, + }, +}) + +const localStoragePersister = createWebStoragePersister({storage: window.localStorage}) + +persistQueryClient({ + queryClient, + persister: localStoragePersister, +}) function App() { const [postId, setPostId] = React.useState(-1); diff --git a/package.json b/package.json index 6207edf8ec..0c927899a0 100644 --- a/package.json +++ b/package.json @@ -55,9 +55,9 @@ "es", "hydration", "devtools", - "persistQueryClient-experimental", - "createWebStoragePersistor-experimental", - "createAsyncStoragePersistor-experimental", + "persistQueryClient", + "createWebStoragePersister", + "createAsyncStoragePersister", "broadcastQueryClient-experimental", "lib", "reactjs", diff --git a/persistQueryClient-experimental/package.json b/persistQueryClient-experimental/package.json deleted file mode 100644 index f58433722d..0000000000 --- a/persistQueryClient-experimental/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "internal": true, - "main": "../lib/persistQueryClient-experimental/index.js", - "module": "../es/persistQueryClient-experimental/index.js", - "types": "../types/persistQueryClient-experimental/index.d.ts" -} diff --git a/persistQueryClient/package.json b/persistQueryClient/package.json new file mode 100644 index 0000000000..576ea12afb --- /dev/null +++ b/persistQueryClient/package.json @@ -0,0 +1,6 @@ +{ + "internal": true, + "main": "../lib/persistQueryClient/index.js", + "module": "../es/persistQueryClient/index.js", + "types": "../types/persistQueryClient/index.d.ts" +} diff --git a/rollup.config.js b/rollup.config.js index f11400c4c2..35fdd215aa 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -20,19 +20,19 @@ const inputSrcs = [ ['src/core/index.ts', 'ReactQueryCore', 'react-query-core'], ['src/devtools/index.ts', 'ReactQueryDevtools', 'react-query-devtools'], [ - 'src/persistQueryClient-experimental/index.ts', - 'ReactQueryPersistQueryClientExperimental', - 'persistQueryClient-experimental', + 'src/persistQueryClient/index.ts', + 'ReactQueryPersistQueryClient', + 'persistQueryClient', ], [ - 'src/createWebStoragePersistor-experimental/index.ts', - 'ReactQueryCreateWebStoragePersistorExperimental', - 'createWebStoragePersistor-experimental', + 'src/createWebStoragePersister/index.ts', + 'ReactQueryCreateWebStoragePersister', + 'createWebStoragePersister', ], [ - 'src/createAsyncStoragePersistor-experimental/index.ts', - 'ReactQueryCreateAsyncStoragePersistorExperimental', - 'createAsyncStoragePersistor-experimental', + 'src/createAsyncStoragePersister/index.ts', + 'ReactQueryCreateAsyncStoragePersister', + 'createAsyncStoragePersister', ], [ 'src/broadcastQueryClient-experimental/index.ts', diff --git a/src/createAsyncStoragePersistor-experimental/index.ts b/src/createAsyncStoragePersister/index.ts similarity index 91% rename from src/createAsyncStoragePersistor-experimental/index.ts rename to src/createAsyncStoragePersister/index.ts index a9d43a3c76..96731076c6 100644 --- a/src/createAsyncStoragePersistor-experimental/index.ts +++ b/src/createAsyncStoragePersister/index.ts @@ -1,4 +1,4 @@ -import { PersistedClient, Persistor } from '../persistQueryClient-experimental' +import { PersistedClient, Persister } from '../persistQueryClient' interface AsyncStorage { getItem: (key: string) => Promise @@ -6,7 +6,7 @@ interface AsyncStorage { removeItem: (key: string) => Promise } -interface CreateAsyncStoragePersistorOptions { +interface CreateAsyncStoragePersisterOptions { /** The storage client used for setting an retrieving items from cache */ storage: AsyncStorage /** The key to use when storing the cache */ @@ -26,13 +26,13 @@ interface CreateAsyncStoragePersistorOptions { deserialize?: (cachedString: string) => PersistedClient } -export const createAsyncStoragePersistor = ({ +export const createAsyncStoragePersister = ({ storage, key = `REACT_QUERY_OFFLINE_CACHE`, throttleTime = 1000, serialize = JSON.stringify, deserialize = JSON.parse, -}: CreateAsyncStoragePersistorOptions): Persistor => { +}: CreateAsyncStoragePersisterOptions): Persister => { return { persistClient: asyncThrottle( persistedClient => storage.setItem(key, serialize(persistedClient)), diff --git a/src/createWebStoragePersistor-experimental/index.ts b/src/createWebStoragePersister/index.ts similarity index 92% rename from src/createWebStoragePersistor-experimental/index.ts rename to src/createWebStoragePersister/index.ts index 5c4b8a0de6..e2f61c1d65 100644 --- a/src/createWebStoragePersistor-experimental/index.ts +++ b/src/createWebStoragePersister/index.ts @@ -1,7 +1,7 @@ import { noop } from '../core/utils' -import { PersistedClient, Persistor } from '../persistQueryClient-experimental' +import { PersistedClient, Persister } from '../persistQueryClient' -interface CreateWebStoragePersistorOptions { +interface CreateWebStoragePersisterOptions { /** The storage client used for setting an retrieving items from cache */ storage: Storage /** The key to use when storing the cache */ @@ -21,13 +21,13 @@ interface CreateWebStoragePersistorOptions { deserialize?: (cachedString: string) => PersistedClient } -export function createWebStoragePersistor({ +export function createWebStoragePersister({ storage, key = `REACT_QUERY_OFFLINE_CACHE`, throttleTime = 1000, serialize = JSON.stringify, deserialize = JSON.parse, -}: CreateWebStoragePersistorOptions): Persistor { +}: CreateWebStoragePersisterOptions): Persister { //try to save data to storage function trySave(persistedClient: PersistedClient) { try { diff --git a/src/createWebStoragePersistor-experimental/tests/storageIsFull.test.ts b/src/createWebStoragePersister/tests/storageIsFull.test.ts similarity index 87% rename from src/createWebStoragePersistor-experimental/tests/storageIsFull.test.ts rename to src/createWebStoragePersister/tests/storageIsFull.test.ts index 328884d4d1..87e9c69e27 100644 --- a/src/createWebStoragePersistor-experimental/tests/storageIsFull.test.ts +++ b/src/createWebStoragePersister/tests/storageIsFull.test.ts @@ -1,6 +1,6 @@ import { dehydrate, MutationCache, QueryCache, QueryClient } from '../../core' +import { createWebStoragePersister } from '../index' import { sleep } from '../../reactjs/tests/utils' -import { createWebStoragePersistor } from '../index' function getMockStorage(limitSize?: number) { const dataSet = new Map() @@ -33,14 +33,14 @@ function getMockStorage(limitSize?: number) { } as any) as Storage } -describe('createWebStoragePersistor ', () => { +describe('createWebStoragePersister ', () => { test('basic store and recover', async () => { const queryCache = new QueryCache() const mutationCache = new MutationCache() const queryClient = new QueryClient({ queryCache, mutationCache }) const storage = getMockStorage() - const webStoragePersistor = createWebStoragePersistor({ + const webStoragePersister = createWebStoragePersister({ throttleTime: 0, storage, }) @@ -58,9 +58,9 @@ describe('createWebStoragePersistor ', () => { timestamp: Date.now(), clientState: dehydrate(queryClient), } - webStoragePersistor.persistClient(persistClient) + webStoragePersister.persistClient(persistClient) await sleep(1) - const restoredClient = await webStoragePersistor.restoreClient() + const restoredClient = await webStoragePersister.restoreClient() expect(restoredClient).toEqual(persistClient) }) @@ -71,7 +71,7 @@ describe('createWebStoragePersistor ', () => { const N = 2000 const storage = getMockStorage(N * 5) // can save 4 items; - const webStoragePersistor = createWebStoragePersistor({ + const webStoragePersister = createWebStoragePersister({ throttleTime: 0, storage, }) @@ -92,9 +92,9 @@ describe('createWebStoragePersistor ', () => { timestamp: Date.now(), clientState: dehydrate(queryClient), } - webStoragePersistor.persistClient(persistClient) + webStoragePersister.persistClient(persistClient) await sleep(10) - const restoredClient = await webStoragePersistor.restoreClient() + const restoredClient = await webStoragePersister.restoreClient() expect(restoredClient?.clientState.queries.length).toEqual(4) expect( restoredClient?.clientState.queries.find(q => q.queryKey === 'A') @@ -110,9 +110,9 @@ describe('createWebStoragePersistor ', () => { timestamp: Date.now(), clientState: dehydrate(queryClient), } - webStoragePersistor.persistClient(updatedPersistClient) + webStoragePersister.persistClient(updatedPersistClient) await sleep(10) - const restoredClient2 = await webStoragePersistor.restoreClient() + const restoredClient2 = await webStoragePersister.restoreClient() expect(restoredClient2?.clientState.queries.length).toEqual(4) expect( restoredClient2?.clientState.queries.find(q => q.queryKey === 'A') @@ -129,7 +129,7 @@ describe('createWebStoragePersistor ', () => { const N = 2000 const storage = getMockStorage(N * 5) // can save 4 items; - const webStoragePersistor = createWebStoragePersistor({ + const webStoragePersister = createWebStoragePersister({ throttleTime: 0, storage, }) @@ -163,9 +163,9 @@ describe('createWebStoragePersistor ', () => { timestamp: Date.now(), clientState: dehydrate(queryClient), } - webStoragePersistor.persistClient(persistClient) + webStoragePersister.persistClient(persistClient) await sleep(10) - const restoredClient = await webStoragePersistor.restoreClient() + const restoredClient = await webStoragePersister.restoreClient() expect(restoredClient?.clientState.mutations.length).toEqual(1) expect(restoredClient?.clientState.queries.length).toEqual(3) expect( diff --git a/src/persistQueryClient-experimental/index.ts b/src/persistQueryClient/index.ts similarity index 87% rename from src/persistQueryClient-experimental/index.ts rename to src/persistQueryClient/index.ts index 6a9df26327..8380228d50 100644 --- a/src/persistQueryClient-experimental/index.ts +++ b/src/persistQueryClient/index.ts @@ -9,7 +9,7 @@ import { } from 'react-query' import { Promisable } from 'type-fest' -export interface Persistor { +export interface Persister { persistClient(persistClient: PersistedClient): Promisable restoreClient(): Promisable removeClient(): Promisable @@ -24,9 +24,9 @@ export interface PersistedClient { export interface PersistQueryClientOptions { /** The QueryClient to persist */ queryClient: QueryClient - /** The Persistor interface for storing and restoring the cache + /** The Persister interface for storing and restoring the cache * to/from a persisted location */ - persistor: Persistor + persister: Persister /** The max-allowed age of the cache. * If a persisted cache is found that is older than this * time, it will be discarded */ @@ -42,7 +42,7 @@ export interface PersistQueryClientOptions { export async function persistQueryClient({ queryClient, - persistor, + persister, maxAge = 1000 * 60 * 60 * 24, buster = '', hydrateOptions, @@ -57,24 +57,24 @@ export async function persistQueryClient({ clientState: dehydrate(queryClient, dehydrateOptions), } - persistor.persistClient(persistClient) + persister.persistClient(persistClient) } // Attempt restore try { - const persistedClient = await persistor.restoreClient() + const persistedClient = await persister.restoreClient() if (persistedClient) { if (persistedClient.timestamp) { const expired = Date.now() - persistedClient.timestamp > maxAge const busted = persistedClient.buster !== buster if (expired || busted) { - persistor.removeClient() + persister.removeClient() } else { hydrate(queryClient, persistedClient.clientState, hydrateOptions) } } else { - persistor.removeClient() + persister.removeClient() } } } catch (err) { @@ -82,7 +82,7 @@ export async function persistQueryClient({ getLogger().warn( 'Encountered an error attempting to restore client cache from persisted location. As a precaution, the persisted cache will be discarded.' ) - persistor.removeClient() + persister.removeClient() } // Subscribe to changes in the query cache to trigger the save diff --git a/tsconfig.types.json b/tsconfig.types.json index 820f702ad9..6f6510ecd5 100644 --- a/tsconfig.types.json +++ b/tsconfig.types.json @@ -12,9 +12,9 @@ "files": [ "./src/index.ts", "./src/devtools/index.ts", - "./src/persistQueryClient-experimental/index.ts", - "./src/createWebStoragePersistor-experimental/index.ts", - "./src/createAsyncStoragePersistor-experimental/index.ts", + "./src/persistQueryClient/index.ts", + "./src/createWebStoragePersister/index.ts", + "./src/createAsyncStoragePersister/index.ts", "./src/broadcastQueryClient-experimental/index.ts", "./src/ts3.8/index.ts" ], From a090fe5bab9d9ff0a70d3252832bd128dfea387d Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Fri, 19 Nov 2021 22:45:54 +0100 Subject: [PATCH 07/19] 2964 changes to on success callback (#2969) * feat(useQuery): onSuccess callback do not call onSuccess if update was done manually from setQueryData * feat(useQuery): onSuccess callback test that onSuccess is not called when setQueryData is used * feat(useQuery): onSuccess callback docs changes * feat(useQuery): onSuccess callback options spread is wrong - `updatedAt` is actually `dataUpdatedAt`. Oddly we didn't have a test, so I added one --- .../guides/migrating-to-react-query-4.md | 13 ++++ docs/src/pages/reference/QueryClient.md | 2 - docs/src/pages/reference/useQuery.md | 2 +- src/core/query.ts | 4 +- src/core/queryClient.ts | 2 +- src/core/queryObserver.ts | 2 +- src/core/tests/queryClient.test.tsx | 68 ++++++++++++++++++- 7 files changed, 86 insertions(+), 7 deletions(-) diff --git a/docs/src/pages/guides/migrating-to-react-query-4.md b/docs/src/pages/guides/migrating-to-react-query-4.md index 01a7d79c84..b10a0db62b 100644 --- a/docs/src/pages/guides/migrating-to-react-query-4.md +++ b/docs/src/pages/guides/migrating-to-react-query-4.md @@ -132,6 +132,19 @@ If you were importing anything from `'react-query/react'` directly in your proje + import { QueryClientProvider } from 'react-query/reactjs'; ``` +### `onSuccess` is no longer called from `setQueryData` + +This was confusing to many and also created infinite loops if `setQueryData` was called from within `onSuccess`. It was also a frequent source of error when combined with `staleTime`, because if data was read from the cache only, `onSuccess` was _not_ called. + +Similar to `onError` and `onSettled`, the `onSuccess` callback is now tied to a request being made. No request -> no callback. + +If you want to listen to changes of the `data` field, you can best do this with a `useEffect`, where `data` is part of the dependency Array. Since react-query ensures stable data through structural sharing, the effect will not execute with every background refetch, but only if something within data has changed: + +``` +const { data } = useQuery({ queryKey, queryFn }) +React.useEffect(() => mySideEffectHere(data), [data]) +``` + ### `persistQueryClient` and the corresponding persister plugins are no longer experimental and have been renamed The plugins `createWebStoragePersistor` and `createAsyncStoragePersistor` have been renamed to [`createWebStoragePersister`](/plugins/createWebStoragePersister) and [`createAsyncStoragePersister`](/plugins/createAsyncStoragePersister) respectively. The interface `Persistor` in `persistQueryClient` has also been renamed to `Persister`. Checkout [this stackexchange](https://english.stackexchange.com/questions/206893/persister-or-persistor) for the motivation of this change. diff --git a/docs/src/pages/reference/QueryClient.md b/docs/src/pages/reference/QueryClient.md index 42f29eadaf..ff2289d58f 100644 --- a/docs/src/pages/reference/QueryClient.md +++ b/docs/src/pages/reference/QueryClient.md @@ -205,8 +205,6 @@ This distinction is more a "convenience" for ts devs that know which structure w `setQueryData` is a synchronous function that can be used to immediately update a query's cached data. If the query does not exist, it will be created. **If the query is not utilized by a query hook in the default `cacheTime` of 5 minutes, the query will be garbage collected**. -After successful changing query's cached data via `setQueryData`, it will also trigger `onSuccess` callback from that query. - > The difference between using `setQueryData` and `fetchQuery` is that `setQueryData` is sync and assumes that you already synchronously have the data available. If you need to fetch the data asynchronously, it's suggested that you either refetch the query key or use `fetchQuery` to handle the asynchronous fetch. ```js diff --git a/docs/src/pages/reference/useQuery.md b/docs/src/pages/reference/useQuery.md index a2464cd2b7..e7e1ca89b9 100644 --- a/docs/src/pages/reference/useQuery.md +++ b/docs/src/pages/reference/useQuery.md @@ -137,7 +137,7 @@ const result = useQuery({ - If set to `['isStale']` for example, the component will not re-render when the `isStale` property changes. - `onSuccess: (data: TData) => void` - Optional - - This function will fire any time the query successfully fetches new data or the cache is updated via `setQueryData`. + - This function will fire any time the query successfully fetches new data. - `onError: (error: TError) => void` - Optional - This function will fire if the query encounters an error and will be passed the error. diff --git a/src/core/query.ts b/src/core/query.ts index 643825e381..52ba42c6d2 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -100,6 +100,7 @@ interface SuccessAction { data: TData | undefined type: 'success' dataUpdatedAt?: number + notifySuccess?: boolean } interface ErrorAction { @@ -202,7 +203,7 @@ export class Query< setData( updater: Updater, - options?: SetDataOptions + options?: SetDataOptions & { notifySuccess: boolean } ): TData { const prevData = this.state.data @@ -222,6 +223,7 @@ export class Query< data, type: 'success', dataUpdatedAt: options?.updatedAt, + notifySuccess: options?.notifySuccess, }) return data diff --git a/src/core/queryClient.ts b/src/core/queryClient.ts index 32cf43e160..2d837be6d1 100644 --- a/src/core/queryClient.ts +++ b/src/core/queryClient.ts @@ -136,7 +136,7 @@ export class QueryClient { const defaultedOptions = this.defaultQueryOptions(parsedOptions) return this.queryCache .build(this, defaultedOptions) - .setData(updater, options) + .setData(updater, { ...options, notifySuccess: false }) } setQueriesData( diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index fceea94f37..e41d73517c 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -679,7 +679,7 @@ export class QueryObserver< const notifyOptions: NotifyOptions = {} if (action.type === 'success') { - notifyOptions.onSuccess = true + notifyOptions.onSuccess = action.notifySuccess ?? true } else if (action.type === 'error' && !isCancelledError(action.error)) { notifyOptions.onError = true } diff --git a/src/core/tests/queryClient.test.tsx b/src/core/tests/queryClient.test.tsx index 16fe2936f7..79c1398b79 100644 --- a/src/core/tests/queryClient.test.tsx +++ b/src/core/tests/queryClient.test.tsx @@ -1,5 +1,15 @@ -import { sleep, queryKey, mockConsoleError } from '../../reactjs/tests/utils' +import { waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' +import React from 'react' + +import { + sleep, + queryKey, + mockConsoleError, + renderWithClient, +} from '../../reactjs/tests/utils' import { + useQuery, InfiniteQueryObserver, QueryCache, QueryClient, @@ -219,6 +229,62 @@ describe('queryClient', () => { expect(queryCache.find(key)!.state.data).toBe(newData) }) + + test('should not call onSuccess callback of active observers', async () => { + const key = queryKey() + const onSuccess = jest.fn() + + function Page() { + const state = useQuery(key, () => 'data', { onSuccess }) + return ( +
+
data: {state.data}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => rendered.getByText('data: data')) + rendered.getByRole('button', { name: /setQueryData/i }).click() + await waitFor(() => rendered.getByText('data: newData')) + + expect(onSuccess).toHaveBeenCalledTimes(1) + expect(onSuccess).toHaveBeenCalledWith('data') + }) + + test('should respect updatedAt', async () => { + const key = queryKey() + + function Page() { + const state = useQuery(key, () => 'data') + return ( +
+
data: {state.data}
+
dataUpdatedAt: {state.dataUpdatedAt}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => rendered.getByText('data: data')) + rendered.getByRole('button', { name: /setQueryData/i }).click() + await waitFor(() => rendered.getByText('data: newData')) + await waitFor(() => { + expect(rendered.getByText('dataUpdatedAt: 100')).toBeInTheDocument() + }) + }) }) describe('setQueriesData', () => { From 2624c42fec6c8ae4ad26d85a620cf5b57f2f9a3b Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Fri, 19 Nov 2021 23:02:40 +0100 Subject: [PATCH 08/19] 2919 query key array (#2988) * feat: query key array remove code that internally ensures that we get an Array, because it is now the expected interface, ensured by TypeScript * feat: query key array update tests to the new syntax * feat: query key array fix assertions, because there is no array wrapping happening internally anymore. The key you receive from the context is exactly the key you passed in * feat: query key array this test doesn't make much sense anymore * feat: query key array wrapping in an extra array doesn't yield the same results anymore since v4 because keys need to be an array * feat: query key array make docs adhere to new array key syntax * feat: query key array migration docs --- docs/src/pages/guides/caching.md | 14 +-- .../pages/guides/default-query-function.md | 5 +- docs/src/pages/guides/disabling-queries.md | 2 +- docs/src/pages/guides/filters.md | 6 +- docs/src/pages/guides/infinite-queries.md | 14 +-- docs/src/pages/guides/initial-query-data.md | 18 ++-- .../guides/invalidations-from-mutations.md | 4 +- .../guides/migrating-to-react-query-3.md | 16 +-- .../guides/migrating-to-react-query-4.md | 13 +++ docs/src/pages/guides/mutations.md | 12 +-- docs/src/pages/guides/optimistic-updates.md | 10 +- docs/src/pages/guides/parallel-queries.md | 6 +- .../pages/guides/placeholder-query-data.md | 6 +- docs/src/pages/guides/prefetching.md | 4 +- docs/src/pages/guides/queries.md | 8 +- docs/src/pages/guides/query-cancellation.md | 20 ++-- docs/src/pages/guides/query-functions.md | 16 ++- docs/src/pages/guides/query-invalidation.md | 10 +- docs/src/pages/guides/query-keys.md | 15 ++- docs/src/pages/guides/query-retries.md | 2 +- docs/src/pages/guides/ssr.md | 10 +- docs/src/pages/guides/testing.md | 4 +- .../pages/guides/window-focus-refetching.md | 2 +- docs/src/pages/overview.md | 2 +- docs/src/pages/quick-start.md | 4 +- .../pages/reference/InfiniteQueryObserver.md | 2 +- docs/src/pages/reference/QueryClient.md | 18 ++-- docs/src/pages/reference/QueryObserver.md | 2 +- docs/src/pages/reference/useInfiniteQuery.md | 4 +- docs/src/pages/reference/useQuery.md | 5 +- src/core/query.ts | 9 +- src/core/tests/hydration.test.tsx | 98 +++++++++---------- src/core/tests/infiniteQueryBehavior.test.tsx | 6 +- src/core/tests/mutationCache.test.tsx | 14 +-- src/core/tests/mutations.test.tsx | 8 +- src/core/tests/query.test.tsx | 12 ++- src/core/tests/queryCache.test.tsx | 5 +- src/core/tests/queryClient.test.tsx | 38 +++---- src/core/tests/utils.test.tsx | 4 +- src/core/types.ts | 10 +- src/core/utils.ts | 26 +---- .../tests/storageIsFull.test.ts | 42 ++++---- src/devtools/tests/devtools.test.tsx | 38 +++---- src/reactjs/tests/Hydrate.test.tsx | 24 ++--- src/reactjs/tests/ssr-hydration.test.tsx | 12 ++- src/reactjs/tests/suspense.test.tsx | 6 +- src/reactjs/tests/useInfiniteQuery.test.tsx | 14 +-- src/reactjs/tests/useIsMutating.test.tsx | 19 ++-- src/reactjs/tests/useQueries.test.tsx | 4 +- src/reactjs/tests/useQuery.test.tsx | 17 ++-- src/reactjs/tests/utils.tsx | 4 +- 51 files changed, 331 insertions(+), 333 deletions(-) diff --git a/docs/src/pages/guides/caching.md b/docs/src/pages/guides/caching.md index 2f8b605328..e200b53721 100644 --- a/docs/src/pages/guides/caching.md +++ b/docs/src/pages/guides/caching.md @@ -16,17 +16,17 @@ This caching example illustrates the story and lifecycle of: Let's assume we are using the default `cacheTime` of **5 minutes** and the default `staleTime` of `0`. -- A new instance of `useQuery('todos', fetchTodos)` mounts. +- A new instance of `useQuery(['todos'], fetchTodos)` mounts. - Since no other queries have been made with this query + variable combination, this query will show a hard loading state and make a network request to fetch the data. - - It will then cache the data using `'todos'` and `fetchTodos` as the unique identifiers for that cache. + - It will then cache the data using `['todos']` as the unique identifiers for that cache. - The hook will mark itself as stale after the configured `staleTime` (defaults to `0`, or immediately). -- A second instance of `useQuery('todos', fetchTodos)` mounts elsewhere. +- A second instance of `useQuery(['todos'], fetchTodos)` mounts elsewhere. - Because this exact data exists in the cache from the first instance of this query, that data is immediately returned from the cache. - A background refetch is triggered for both queries (but only one request), since a new instance appeared on screen. - Both instances are updated with the new data if the fetch is successful -- Both instances of the `useQuery('todos', fetchTodos)` query are unmounted and no longer in use. +- Both instances of the `useQuery(['todos'], fetchTodos)` query are unmounted and no longer in use. - Since there are no more active instances of this query, a cache timeout is set using `cacheTime` to delete and garbage collect the query (defaults to **5 minutes**). -- Before the cache timeout has completed another instance of `useQuery('todos', fetchTodos)` mounts. The query immediately returns the available cached value while the `fetchTodos` function is being run in the background to populate the query with a fresh value. -- The final instance of `useQuery('todos', fetchTodos)` unmounts. -- No more instances of `useQuery('todos', fetchTodos)` appear within **5 minutes**. +- Before the cache timeout has completed another instance of `useQuery(['todos'], fetchTodos)` mounts. The query immediately returns the available cached value while the `fetchTodos` function is being run in the background to populate the query with a fresh value. +- The final instance of `useQuery(['todos'], fetchTodos)` unmounts. +- No more instances of `useQuery(['todos'], fetchTodos)` appear within **5 minutes**. - This query and its data are deleted and garbage collected. diff --git a/docs/src/pages/guides/default-query-function.md b/docs/src/pages/guides/default-query-function.md index 0ec3364c1f..7561ae375d 100644 --- a/docs/src/pages/guides/default-query-function.md +++ b/docs/src/pages/guides/default-query-function.md @@ -7,7 +7,6 @@ If you find yourself wishing for whatever reason that you could just share the s ```js // Define a default query function that will receive the query key -// the queryKey is guaranteed to be an Array here const defaultQueryFn = async ({ queryKey }) => { const { data } = await axios.get(`https://jsonplaceholder.typicode.com${queryKey[0]}`); return data; @@ -32,14 +31,14 @@ function App() { // All you have to do now is pass a key! function Posts() { - const { status, data, error, isFetching } = useQuery('/posts') + const { status, data, error, isFetching } = useQuery(['/posts']) // ... } // You can even leave out the queryFn and just go straight into options function Post({ postId }) { - const { status, data, error, isFetching } = useQuery(`/posts/${postId}`, { + const { status, data, error, isFetching } = useQuery([`/posts/${postId}`], { enabled: !!postId, }) diff --git a/docs/src/pages/guides/disabling-queries.md b/docs/src/pages/guides/disabling-queries.md index 8162e850b8..ec28592be2 100644 --- a/docs/src/pages/guides/disabling-queries.md +++ b/docs/src/pages/guides/disabling-queries.md @@ -26,7 +26,7 @@ function Todos() { error, refetch, isFetching, - } = useQuery('todos', fetchTodoList, { + } = useQuery(['todos'], fetchTodoList, { enabled: false, }) diff --git a/docs/src/pages/guides/filters.md b/docs/src/pages/guides/filters.md index 67d19f8fc2..ac8897c93b 100644 --- a/docs/src/pages/guides/filters.md +++ b/docs/src/pages/guides/filters.md @@ -14,13 +14,13 @@ A query filter is an object with certain conditions to match a query with: await queryClient.cancelQueries() // Remove all inactive queries that begin with `posts` in the key -queryClient.removeQueries('posts', { type: 'inactive' }) +queryClient.removeQueries(['posts'], { type: 'inactive' }) // Refetch all active queries await queryClient.refetchQueries({ type: 'active' }) // Refetch all active queries that begin with `posts` in the key -await queryClient.refetchQueries('posts', { type: 'active' }) +await queryClient.refetchQueries(['posts'], { type: 'active' }) ``` A query filter object supports the following properties: @@ -51,7 +51,7 @@ A mutation filter is an object with certain conditions to match a mutation with: await queryClient.isMutating() // Filter mutations by mutationKey -await queryClient.isMutating({ mutationKey: "post" }) +await queryClient.isMutating({ mutationKey: ["post"] }) // Filter mutations using a predicate function await queryClient.isMutating({ predicate: (mutation) => mutation.options.variables?.id === 1 }) diff --git a/docs/src/pages/guides/infinite-queries.md b/docs/src/pages/guides/infinite-queries.md index 50d300bab6..7a5b812265 100644 --- a/docs/src/pages/guides/infinite-queries.md +++ b/docs/src/pages/guides/infinite-queries.md @@ -56,7 +56,7 @@ function Projects() { isFetching, isFetchingNextPage, status, - } = useInfiniteQuery('projects', fetchProjects, { + } = useInfiniteQuery(['projects'], fetchProjects, { getNextPageParam: (lastPage, pages) => lastPage.nextCursor, }) @@ -100,7 +100,7 @@ When an infinite query becomes `stale` and needs to be refetched, each group is If you only want to actively refetch a subset of all pages, you can pass the `refetchPage` function to `refetch` returned from `useInfiniteQuery`. ```js -const { refetch } = useInfiniteQuery('projects', fetchProjects, { +const { refetch } = useInfiniteQuery(['projects'], fetchProjects, { getNextPageParam: (lastPage, pages) => lastPage.nextCursor, }) @@ -132,7 +132,7 @@ function Projects() { isFetchingNextPage, fetchNextPage, hasNextPage, - } = useInfiniteQuery('projects', fetchProjects, { + } = useInfiniteQuery(['projects'], fetchProjects, { getNextPageParam: (lastPage, pages) => lastPage.nextCursor, }) @@ -146,7 +146,7 @@ function Projects() { Bi-directional lists can be implemented by using the `getPreviousPageParam`, `fetchPreviousPage`, `hasPreviousPage` and `isFetchingPreviousPage` properties and functions. ```js -useInfiniteQuery('projects', fetchProjects, { +useInfiniteQuery(['projects'], fetchProjects, { getNextPageParam: (lastPage, pages) => lastPage.nextCursor, getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor, }) @@ -157,7 +157,7 @@ useInfiniteQuery('projects', fetchProjects, { Sometimes you may want to show the pages in reversed order. If this is case, you can use the `select` option: ```js -useInfiniteQuery('projects', fetchProjects, { +useInfiniteQuery(['projects'], fetchProjects, { select: data => ({ pages: [...data.pages].reverse(), pageParams: [...data.pageParams].reverse(), @@ -170,7 +170,7 @@ useInfiniteQuery('projects', fetchProjects, { Manually removing first page: ```js -queryClient.setQueryData('projects', data => ({ +queryClient.setQueryData(['projects'], data => ({ pages: data.pages.slice(1), pageParams: data.pageParams.slice(1), })) @@ -183,7 +183,7 @@ const newPagesArray = oldPagesArray?.pages.map((page) => page.filter((val) => val.id !== updatedId) ) ?? [] -queryClient.setQueryData('projects', data => ({ +queryClient.setQueryData(['projects'], data => ({ pages: newPagesArray, pageParams: data.pageParams, })) diff --git a/docs/src/pages/guides/initial-query-data.md b/docs/src/pages/guides/initial-query-data.md index 5a3e9ef454..a634756112 100644 --- a/docs/src/pages/guides/initial-query-data.md +++ b/docs/src/pages/guides/initial-query-data.md @@ -19,7 +19,7 @@ There may be times when you already have the initial data for a query available ```js function Todos() { - const result = useQuery('todos', () => fetch('/todos'), { + const result = useQuery(['todos'], () => fetch('/todos'), { initialData: initialTodos, }) } @@ -34,7 +34,7 @@ By default, `initialData` is treated as totally fresh, as if it were just fetche ```js function Todos() { // Will show initialTodos immediately, but also immediately refetch todos after mount - const result = useQuery('todos', () => fetch('/todos'), { + const result = useQuery(['todos'], () => fetch('/todos'), { initialData: initialTodos, }) } @@ -45,7 +45,7 @@ By default, `initialData` is treated as totally fresh, as if it were just fetche ```js function Todos() { // Show initialTodos immediately, but won't refetch until another interaction event is encountered after 1000 ms - const result = useQuery('todos', () => fetch('/todos'), { + const result = useQuery(['todos'], () => fetch('/todos'), { initialData: initialTodos, staleTime: 1000, }) @@ -56,7 +56,7 @@ By default, `initialData` is treated as totally fresh, as if it were just fetche ```js function Todos() { // Show initialTodos immediately, but won't refetch until another interaction event is encountered after 1000 ms - const result = useQuery('todos', () => fetch('/todos'), { + const result = useQuery(['todos'], () => fetch('/todos'), { initialData: initialTodos, staleTime: 60 * 1000 // 1 minute // This could be 10 seconds ago or 10 minutes ago @@ -74,7 +74,7 @@ If the process for accessing a query's initial data is intensive or just not som ```js function Todos() { - const result = useQuery('todos', () => fetch('/todos'), { + const result = useQuery(['todos'], () => fetch('/todos'), { initialData: () => { return getExpensiveTodos() }, @@ -91,7 +91,7 @@ function Todo({ todoId }) { const result = useQuery(['todo', todoId], () => fetch('/todos'), { initialData: () => { // Use a todo from the 'todos' query as the initial data for this todo query - return queryClient.getQueryData('todos')?.find(d => d.id === todoId) + return queryClient.getQueryData(['todos'])?.find(d => d.id === todoId) }, }) } @@ -105,9 +105,9 @@ Getting initial data from the cache means the source query you're using to look function Todo({ todoId }) { const result = useQuery(['todo', todoId], () => fetch(`/todos/${todoId}`), { initialData: () => - queryClient.getQueryData('todos')?.find(d => d.id === todoId), + queryClient.getQueryData(['todos'])?.find(d => d.id === todoId), initialDataUpdatedAt: () => - queryClient.getQueryState('todos')?.dataUpdatedAt, + queryClient.getQueryState(['todos'])?.dataUpdatedAt, }) } ``` @@ -121,7 +121,7 @@ function Todo({ todoId }) { const result = useQuery(['todo', todoId], () => fetch(`/todos/${todoId}`), { initialData: () => { // Get the query state - const state = queryClient.getQueryState('todos') + const state = queryClient.getQueryState(['todos']) // If the query exists and has data that is no older than 10 seconds... if (state && Date.now() - state.dataUpdatedAt <= 10 * 1000) { diff --git a/docs/src/pages/guides/invalidations-from-mutations.md b/docs/src/pages/guides/invalidations-from-mutations.md index a8000d216a..2aa38f48fb 100644 --- a/docs/src/pages/guides/invalidations-from-mutations.md +++ b/docs/src/pages/guides/invalidations-from-mutations.md @@ -21,8 +21,8 @@ const queryClient = useQueryClient() // When this mutation succeeds, invalidate any queries with the `todos` or `reminders` query key const mutation = useMutation(addTodo, { onSuccess: () => { - queryClient.invalidateQueries('todos') - queryClient.invalidateQueries('reminders') + queryClient.invalidateQueries(['todos']) + queryClient.invalidateQueries(['reminders']) }, }) ``` diff --git a/docs/src/pages/guides/migrating-to-react-query-3.md b/docs/src/pages/guides/migrating-to-react-query-3.md index 12aba57b35..31ce396ca3 100644 --- a/docs/src/pages/guides/migrating-to-react-query-3.md +++ b/docs/src/pages/guides/migrating-to-react-query-3.md @@ -244,7 +244,7 @@ const { This allows for easier manipulation of the data and the page params, like, for example, removing the first page of data along with it's params: ```js -queryClient.setQueryData('projects', data => ({ +queryClient.setQueryData(['projects'], data => ({ pages: data.pages.slice(1), pageParams: data.pageParams.slice(1), })) @@ -358,7 +358,7 @@ Only re-render when the `data` or `error` properties change: import { useQuery } from 'react-query' function User() { - const { data } = useQuery('user', fetchUser, { + const { data } = useQuery(['user'], fetchUser, { notifyOnChangeProps: ['data', 'error'], }) return
Username: {data.username}
@@ -371,7 +371,7 @@ Prevent re-render when the `isStale` property changes: import { useQuery } from 'react-query' function User() { - const { data } = useQuery('user', fetchUser, { + const { data } = useQuery(['user'], fetchUser, { notifyOnChangePropsExclusions: ['isStale'], }) return
Username: {data.username}
@@ -459,7 +459,7 @@ The `useQuery` and `useInfiniteQuery` hooks now have a `select` option to select import { useQuery } from 'react-query' function User() { - const { data } = useQuery('user', fetchUser, { + const { data } = useQuery(['user'], fetchUser, { select: user => user.username, }) return
Username: {data}
@@ -556,10 +556,10 @@ const unsubscribe = observer.subscribe(result => { The `QueryClient.setQueryDefaults()` method can be used to set default options for specific queries: ```js -queryClient.setQueryDefaults('posts', { queryFn: fetchPosts }) +queryClient.setQueryDefaults(['posts'], { queryFn: fetchPosts }) function Component() { - const { data } = useQuery('posts') + const { data } = useQuery(['posts']) } ``` @@ -568,10 +568,10 @@ function Component() { The `QueryClient.setMutationDefaults()` method can be used to set default options for specific mutations: ```js -queryClient.setMutationDefaults('addPost', { mutationFn: addPost }) +queryClient.setMutationDefaults(['addPost'], { mutationFn: addPost }) function Component() { - const { mutate } = useMutation('addPost') + const { mutate } = useMutation(['addPost']) } ``` diff --git a/docs/src/pages/guides/migrating-to-react-query-4.md b/docs/src/pages/guides/migrating-to-react-query-4.md index b10a0db62b..44000c1e98 100644 --- a/docs/src/pages/guides/migrating-to-react-query-4.md +++ b/docs/src/pages/guides/migrating-to-react-query-4.md @@ -5,6 +5,19 @@ title: Migrating to React Query 4 ## Breaking Changes +### Query Keys (and Mutation Keys) need to be an Array + +In v3, Query and Mutation Keys could be a String or an Array. Internally, React Query has always worked with Array Keys only, and we've sometimes exposed this to consumers. For example, in the `queryFn`, you would always get the key as an Array to make working with [Default Query Functions](./default-query-function) easier. + +However, we have not followed this concept through to all apis. For example, when using the `predicate` function on [Query Filters](./filters) you would get the raw Query Key. This makes it difficult to work with such functions if you use Query Keys that are mixed Arrays and Strings. The same was true when using global callbacks. + +To streamline all apis, we've decided to make all keys Arrays only: + +```diff +- useQuery('todos', fetchTodos) ++ useQuery(['todos'], fetchTodos) +``` + ### Separate hydration exports have been removed With version [3.22.0](https://github.com/tannerlinsley/react-query/releases/tag/v3.22.0), hydration utilities moved into the react-query core. With v3, you could still use the old exports from `react-query/hydration`, but these exports have been removed with v4. diff --git a/docs/src/pages/guides/mutations.md b/docs/src/pages/guides/mutations.md index 379d7351ef..f592e0dc17 100644 --- a/docs/src/pages/guides/mutations.md +++ b/docs/src/pages/guides/mutations.md @@ -216,34 +216,34 @@ Mutations can be persisted to storage if needed and resumed at a later point. Th const queryClient = new QueryClient() // Define the "addTodo" mutation -queryClient.setMutationDefaults('addTodo', { +queryClient.setMutationDefaults(['addTodo'], { mutationFn: addTodo, onMutate: async (variables) => { // Cancel current queries for the todos list - await queryClient.cancelQueries('todos') + await queryClient.cancelQueries(['todos']) // Create optimistic todo const optimisticTodo = { id: uuid(), title: variables.title } // Add optimistic todo to todos list - queryClient.setQueryData('todos', old => [...old, optimisticTodo]) + queryClient.setQueryData(['todos'], old => [...old, optimisticTodo]) // Return context with the optimistic todo return { optimisticTodo } }, onSuccess: (result, variables, context) => { // Replace optimistic todo in the todos list with the result - queryClient.setQueryData('todos', old => old.map(todo => todo.id === context.optimisticTodo.id ? result : todo)) + queryClient.setQueryData(['todos'], old => old.map(todo => todo.id === context.optimisticTodo.id ? result : todo)) }, onError: (error, variables, context) => { // Remove optimistic todo from the todos list - queryClient.setQueryData('todos', old => old.filter(todo => todo.id !== context.optimisticTodo.id)) + queryClient.setQueryData(['todos'], old => old.filter(todo => todo.id !== context.optimisticTodo.id)) }, retry: 3, }) // Start mutation in some component: -const mutation = useMutation('addTodo') +const mutation = useMutation(['addTodo']) mutation.mutate({ title: 'title' }) // If the mutation has been paused because the device is for example offline, diff --git a/docs/src/pages/guides/optimistic-updates.md b/docs/src/pages/guides/optimistic-updates.md index b4bd3b4080..31518b3434 100644 --- a/docs/src/pages/guides/optimistic-updates.md +++ b/docs/src/pages/guides/optimistic-updates.md @@ -16,24 +16,24 @@ useMutation(updateTodo, { // When mutate is called: onMutate: async newTodo => { // Cancel any outgoing refetches (so they don't overwrite our optimistic update) - await queryClient.cancelQueries('todos') + await queryClient.cancelQueries(['todos']) // Snapshot the previous value - const previousTodos = queryClient.getQueryData('todos') + const previousTodos = queryClient.getQueryData(['todos']) // Optimistically update to the new value - queryClient.setQueryData('todos', old => [...old, newTodo]) + queryClient.setQueryData(['todos'], old => [...old, newTodo]) // Return a context object with the snapshotted value return { previousTodos } }, // If the mutation fails, use the context returned from onMutate to roll back onError: (err, newTodo, context) => { - queryClient.setQueryData('todos', context.previousTodos) + queryClient.setQueryData(['todos'], context.previousTodos) }, // Always refetch after error or success: onSettled: () => { - queryClient.invalidateQueries('todos') + queryClient.invalidateQueries(['todos']) }, }) ``` diff --git a/docs/src/pages/guides/parallel-queries.md b/docs/src/pages/guides/parallel-queries.md index 22926ee6e0..79dac5ce82 100644 --- a/docs/src/pages/guides/parallel-queries.md +++ b/docs/src/pages/guides/parallel-queries.md @@ -12,9 +12,9 @@ When the number of parallel queries does not change, there is **no extra effort* ```js function App () { // The following queries will execute in parallel - const usersQuery = useQuery('users', fetchUsers) - const teamsQuery = useQuery('teams', fetchTeams) - const projectsQuery = useQuery('projects', fetchProjects) + const usersQuery = useQuery(['users'], fetchUsers) + const teamsQuery = useQuery(['teams'], fetchTeams) + const projectsQuery = useQuery(['projects'], fetchProjects) ... } ``` diff --git a/docs/src/pages/guides/placeholder-query-data.md b/docs/src/pages/guides/placeholder-query-data.md index e9c2d24379..678197fa64 100644 --- a/docs/src/pages/guides/placeholder-query-data.md +++ b/docs/src/pages/guides/placeholder-query-data.md @@ -20,7 +20,7 @@ There are a few ways to supply placeholder data for a query to the cache before ```js function Todos() { - const result = useQuery('todos', () => fetch('/todos'), { + const result = useQuery(['todos'], () => fetch('/todos'), { placeholderData: placeholderTodos, }) } @@ -33,7 +33,7 @@ If the process for accessing a query's placeholder data is intensive or just not ```js function Todos() { const placeholderData = useMemo(() => generateFakeTodos(), []) - const result = useQuery('todos', () => fetch('/todos'), { placeholderData }) + const result = useQuery(['todos'], () => fetch('/todos'), { placeholderData }) } ``` @@ -47,7 +47,7 @@ function Todo({ blogPostId }) { placeholderData: () => { // Use the smaller/preview version of the blogPost from the 'blogPosts' query as the placeholder data for this blogPost query return queryClient - .getQueryData('blogPosts') + .getQueryData(['blogPosts']) ?.find(d => d.id === blogPostId) }, }) diff --git a/docs/src/pages/guides/prefetching.md b/docs/src/pages/guides/prefetching.md index 72ffdcd5e8..d1f04858cd 100644 --- a/docs/src/pages/guides/prefetching.md +++ b/docs/src/pages/guides/prefetching.md @@ -8,7 +8,7 @@ If you're lucky enough, you may know enough about what your users will do to be ```js const prefetchTodos = async () => { // The results of this query will be cached like a normal query - await queryClient.prefetchQuery('todos', fetchTodos) + await queryClient.prefetchQuery(['todos'], fetchTodos) } ``` @@ -21,5 +21,5 @@ const prefetchTodos = async () => { Alternatively, if you already have the data for your query synchronously available, you don't need to prefetch it. You can just use the [Query Client's `setQueryData` method](../reference/QueryClient#queryclientsetquerydata) to directly add or update a query's cached result by key. ```js -queryClient.setQueryData('todos', todos) +queryClient.setQueryData(['todos'], todos) ``` diff --git a/docs/src/pages/guides/queries.md b/docs/src/pages/guides/queries.md index 63c7b5eb7d..af83df3ba3 100644 --- a/docs/src/pages/guides/queries.md +++ b/docs/src/pages/guides/queries.md @@ -18,7 +18,7 @@ To subscribe to a query in your components or custom hooks, call the `useQuery` import { useQuery } from 'react-query' function App() { - const info = useQuery('todos', fetchTodoList) + const info = useQuery(['todos'], fetchTodoList) } ``` @@ -27,7 +27,7 @@ The **unique key** you provide is used internally for refetching, caching, and s The query results returned by `useQuery` contains all of the information about the query that you'll need for templating and any other usage of the data: ```js -const result = useQuery('todos', fetchTodoList) +const result = useQuery(['todos'], fetchTodoList) ``` The `result` object contains a few very important states you'll need to be aware of to be productive. A query can only be in one of the following states at any given moment: @@ -47,7 +47,7 @@ For **most** queries, it's usually sufficient to check for the `isLoading` state ```js function Todos() { - const { isLoading, isError, data, error } = useQuery('todos', fetchTodoList) + const { isLoading, isError, data, error } = useQuery(['todos'], fetchTodoList) if (isLoading) { return Loading... @@ -72,7 +72,7 @@ If booleans aren't your thing, you can always use the `status` state as well: ```js function Todos() { - const { status, data, error } = useQuery('todos', fetchTodoList) + const { status, data, error } = useQuery(['todos'], fetchTodoList) if (status === 'loading') { return Loading... diff --git a/docs/src/pages/guides/query-cancellation.md b/docs/src/pages/guides/query-cancellation.md index 2c00fe1625..0d36fced6e 100644 --- a/docs/src/pages/guides/query-cancellation.md +++ b/docs/src/pages/guides/query-cancellation.md @@ -20,7 +20,7 @@ However, if you consume the `AbortSignal` or attach a `cancel` function to your ## Using `fetch` ```js -const query = useQuery('todos', async ({ signal }) => { +const query = useQuery(['todos'], async ({ signal }) => { const todosResponse = await fetch('/todos', { // Pass the signal to one fetch signal, @@ -46,7 +46,7 @@ const query = useQuery('todos', async ({ signal }) => { ```js import axios from 'axios' -const query = useQuery('todos', ({ signal }) => +const query = useQuery(['todos'], ({ signal }) => axios.get('/todos', { // Pass the signal to `axios` signal, @@ -59,7 +59,7 @@ const query = useQuery('todos', ({ signal }) => ```js import axios from 'axios' -const query = useQuery('todos', ({ signal }) => { +const query = useQuery(['todos'], ({ signal }) => { // Create a new CancelToken source for this request const CancelToken = axios.CancelToken const source = CancelToken.source() @@ -81,7 +81,7 @@ const query = useQuery('todos', ({ signal }) => { ## Using `XMLHttpRequest` ```js -const query = useQuery('todos', ({ signal }) => { +const query = useQuery(['todos'], ({ signal }) => { return new Promise((resolve, reject) => { var oReq = new XMLHttpRequest() oReq.addEventListener('load', () => { @@ -102,7 +102,7 @@ const query = useQuery('todos', ({ signal }) => { An `AbortSignal` can be set in the `GraphQLClient` constructor. ```js -const query = useQuery('todos', ({ signal }) => { +const query = useQuery(['todos'], ({ signal }) => { const client = new GraphQLClient(endpoint, { signal, }); @@ -115,9 +115,7 @@ const query = useQuery('todos', ({ signal }) => { You might want to cancel a query manually. For example, if the request takes a long time to finish, you can allow the user to click a cancel button to stop the request. To do this, you just need to call `queryClient.cancelQueries(key)`, which will cancel the query and revert it back to its previous state. If `promise.cancel` is available, or you have consumed the `signal` passed to the query function, React Query will additionally also cancel the Promise. ```js -const [queryKey] = useState('todos') - -const query = useQuery(queryKey, await ({ signal }) => { +const query = useQuery(['todos'], await ({ signal }) => { const resp = fetch('/todos', { signal }) return resp.json() }) @@ -127,7 +125,7 @@ const queryClient = useQueryClient() return ( ) ``` @@ -143,7 +141,7 @@ To integrate with this feature, attach a `cancel` function to the promise return ```js import axios from 'axios' -const query = useQuery('todos', () => { +const query = useQuery(['todos'], () => { // Create a new CancelToken source for this request const CancelToken = axios.CancelToken const source = CancelToken.source() @@ -165,7 +163,7 @@ const query = useQuery('todos', () => { ## Using `fetch` with `cancel` function ```js -const query = useQuery('todos', () => { +const query = useQuery(['todos'], () => { // Create a new AbortController instance for this request const controller = new AbortController() // Get the abortController's signal diff --git a/docs/src/pages/guides/query-functions.md b/docs/src/pages/guides/query-functions.md index e31eb25489..a12d84e0b7 100644 --- a/docs/src/pages/guides/query-functions.md +++ b/docs/src/pages/guides/query-functions.md @@ -47,7 +47,7 @@ useQuery(['todos', todoId], async () => { ## Query Function Variables -Query keys are not just for uniquely identifying the data you are fetching, but are also conveniently passed into your query function and while not always necessary, this makes it possible to extract your query functions if needed: +Query keys are not just for uniquely identifying the data you are fetching, but are also conveniently passed into your query function as part of the QueryFunctionContext. While not always necessary, this makes it possible to extract your query functions if needed: ```js function Todos({ status, page }) { @@ -61,6 +61,20 @@ function fetchTodoList({ queryKey }) { } ``` +### QueryFunctionContext + +The `QueryFunctionContext` is the object passed to each query function. It consists of: + +- `queryKey: QueryKey`: [Query Keys](./query-keys) +- `pageParam: unknown | undefined` + - only for [Infinite Queries](./infinite-queries.md) + - the page parameter used to fetch the current page +- signal?: AbortSignal + - [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) instance provided by react-query + - Can be used for [Query Cancellation](./query-cancellation.md) +- `meta?: Record` + - an optional field you can fill with additional information about your query + ## Using a Query Object instead of parameters Anywhere the `[queryKey, queryFn, config]` signature is supported throughout React Query's API, you can also use an object to express the same configuration: diff --git a/docs/src/pages/guides/query-invalidation.md b/docs/src/pages/guides/query-invalidation.md index 2f6b2b6789..17f0d486a1 100644 --- a/docs/src/pages/guides/query-invalidation.md +++ b/docs/src/pages/guides/query-invalidation.md @@ -9,7 +9,7 @@ Waiting for queries to become stale before they are fetched again doesn't always // Invalidate every query in the cache queryClient.invalidateQueries() // Invalidate every query with a key that starts with `todos` -queryClient.invalidateQueries('todos') +queryClient.invalidateQueries(['todos']) ``` > Note: Where other libraries that use normalized caches would attempt to update local queries with the new data either imperatively or via schema inference, React Query gives you the tools to avoid the manual labor that comes with maintaining normalized caches and instead prescribes **targeted invalidation, background-refetching and ultimately atomic updates**. @@ -31,10 +31,10 @@ import { useQuery, useQueryClient } from 'react-query' // Get QueryClient from the context const queryClient = useQueryClient() -queryClient.invalidateQueries('todos') +queryClient.invalidateQueries(['todos']) // Both queries below will be invalidated -const todoListQuery = useQuery('todos', fetchTodoList) +const todoListQuery = useQuery(['todos'], fetchTodoList) const todoListQuery = useQuery(['todos', { page: 1 }], fetchTodoList) ``` @@ -47,13 +47,13 @@ queryClient.invalidateQueries(['todos', { type: 'done' }]) const todoListQuery = useQuery(['todos', { type: 'done' }], fetchTodoList) // However, the following query below will NOT be invalidated -const todoListQuery = useQuery('todos', fetchTodoList) +const todoListQuery = useQuery(['todos'], fetchTodoList) ``` The `invalidateQueries` API is very flexible, so even if you want to **only** invalidate `todos` queries that don't have any more variables or subkeys, you can pass an `exact: true` option to the `invalidateQueries` method: ```js -queryClient.invalidateQueries('todos', { exact: true }) +queryClient.invalidateQueries(['todos'], { exact: true }) // The query below will be invalidated const todoListQuery = useQuery(['todos'], fetchTodoList) diff --git a/docs/src/pages/guides/query-keys.md b/docs/src/pages/guides/query-keys.md index eb1cdc67b4..acc1a79e88 100644 --- a/docs/src/pages/guides/query-keys.md +++ b/docs/src/pages/guides/query-keys.md @@ -3,24 +3,24 @@ id: query-keys title: Query Keys --- -At its core, React Query manages query caching for you based on query keys. Query keys can be as simple as a string, or as complex as an array of many strings and nested objects. As long as the query key is serializable, and **unique to the query's data**, you can use it! +At its core, React Query manages query caching for you based on query keys. Query keys have to be an Array at the top level, and can be as simple as an Array with a single string, or as complex as an array of many strings and nested objects. As long as the query key is serializable, and **unique to the query's data**, you can use it! -## String-Only Query Keys +## Simple Query Keys -The simplest form of a key is actually not an array, but an individual string. When a string query key is passed, it is converted to an array internally with the string as the only item in the query key. This format is useful for: +The simplest form of a key is an array with constants values. This format is useful for: - Generic List/Index resources - Non-hierarchical resources ```js // A list of todos -useQuery('todos', ...) // queryKey === ['todos'] +useQuery(['todos'], ...) // Something else, whatever! -useQuery('somethingSpecial', ...) // queryKey === ['somethingSpecial'] +useQuery(['something', 'special'], ...) ``` -## Array Keys +## Array Keys with variables When a query needs more information to uniquely describe its data, you can use an array with a string and any number of serializable objects to describe it. This is useful for: @@ -32,15 +32,12 @@ When a query needs more information to uniquely describe its data, you can use a ```js // An individual todo useQuery(['todo', 5], ...) -// queryKey === ['todo', 5] // And individual todo in a "preview" format useQuery(['todo', 5, { preview: true }], ...) -// queryKey === ['todo', 5, { preview: true }] // A list of todos that are "done" useQuery(['todos', { type: 'done' }], ...) -// queryKey === ['todos', { type: 'done' }] ``` ## Query Keys are hashed deterministically! diff --git a/docs/src/pages/guides/query-retries.md b/docs/src/pages/guides/query-retries.md index 8b87e5b1f5..df44db6920 100644 --- a/docs/src/pages/guides/query-retries.md +++ b/docs/src/pages/guides/query-retries.md @@ -47,7 +47,7 @@ function App() { Though it is not recommended, you can obviously override the `retryDelay` function/integer in both the Provider and individual query options. If set to an integer instead of a function the delay will always be the same amount of time: ```js -const result = useQuery('todos', fetchTodoList, { +const result = useQuery(['todos'], fetchTodoList, { retryDelay: 1000, // Will always wait 1000ms to retry, regardless of how many retries }) ``` diff --git a/docs/src/pages/guides/ssr.md b/docs/src/pages/guides/ssr.md index 623b60161e..675551927f 100644 --- a/docs/src/pages/guides/ssr.md +++ b/docs/src/pages/guides/ssr.md @@ -31,7 +31,7 @@ export async function getStaticProps() { } function Posts(props) { - const { data } = useQuery('posts', getPosts, { initialData: props.posts }) + const { data } = useQuery(['posts'], getPosts, { initialData: props.posts }) // ... } @@ -95,11 +95,11 @@ export async function getStaticProps() { function Posts() { // This useQuery could just as well happen in some deeper child to // the "Posts"-page, data will be available immediately either way - const { data } = useQuery('posts', getPosts) + const { data } = useQuery(['posts'], getPosts) // This query was not prefetched on the server and will not start // fetching until on the client, both patterns are fine to mix - const { data: otherData } = useQuery('posts-2', getPosts) + const { data: otherData } = useQuery(['posts-2'], getPosts) // ... } @@ -126,9 +126,9 @@ This guide is at-best, a high level overview of how SSR with React Query should ```js import { dehydrate, Hydrate, QueryClient, QueryClientProvider } from 'react-query'; -function handleRequest (req, res) { +async function handleRequest (req, res) { const queryClient = new QueryClient() - await queryClient.prefetchQuery('key', fn) + await queryClient.prefetchQuery(['key'], fn) const dehydratedState = dehydrate(queryClient) const html = ReactDOM.renderToString( diff --git a/docs/src/pages/guides/testing.md b/docs/src/pages/guides/testing.md index 892fadf5b4..bb7b352484 100644 --- a/docs/src/pages/guides/testing.md +++ b/docs/src/pages/guides/testing.md @@ -21,7 +21,7 @@ Once installed, a simple test can be written. Given the following custom hook: ``` export function useCustomHook() { - return useQuery('customHook', () => 'Hello'); + return useQuery(['customHook'], () => 'Hello'); } ``` @@ -99,7 +99,7 @@ Given the following custom hook: ``` function useFetchData() { - return useQuery('fetchData', () => request('/api/data')); + return useQuery(['fetchData'], () => request('/api/data')); } ``` diff --git a/docs/src/pages/guides/window-focus-refetching.md b/docs/src/pages/guides/window-focus-refetching.md index 2d41e17653..8922070075 100644 --- a/docs/src/pages/guides/window-focus-refetching.md +++ b/docs/src/pages/guides/window-focus-refetching.md @@ -25,7 +25,7 @@ function App() { #### Disabling Per-Query ```js -useQuery('todos', fetchTodos, { refetchOnWindowFocus: false }) +useQuery(['todos'], fetchTodos, { refetchOnWindowFocus: false }) ``` ## Custom Window Focus Event diff --git a/docs/src/pages/overview.md b/docs/src/pages/overview.md index 885e29a6a2..5e32e0a240 100644 --- a/docs/src/pages/overview.md +++ b/docs/src/pages/overview.md @@ -60,7 +60,7 @@ export default function App() { } function Example() { - const { isLoading, error, data } = useQuery('repoData', () => + const { isLoading, error, data } = useQuery(['repoData'], () => fetch('https://api.github.com/repos/tannerlinsley/react-query').then(res => res.json() ) diff --git a/docs/src/pages/quick-start.md b/docs/src/pages/quick-start.md index c55349bd7d..d587a9a98d 100644 --- a/docs/src/pages/quick-start.md +++ b/docs/src/pages/quick-start.md @@ -36,13 +36,13 @@ function Todos() { const queryClient = useQueryClient() // Queries - const query = useQuery('todos', getTodos) + const query = useQuery(['todos'], getTodos) // Mutations const mutation = useMutation(postTodo, { onSuccess: () => { // Invalidate and refetch - queryClient.invalidateQueries('todos') + queryClient.invalidateQueries(['todos']) }, }) diff --git a/docs/src/pages/reference/InfiniteQueryObserver.md b/docs/src/pages/reference/InfiniteQueryObserver.md index 71d85f5811..45dd93744b 100644 --- a/docs/src/pages/reference/InfiniteQueryObserver.md +++ b/docs/src/pages/reference/InfiniteQueryObserver.md @@ -9,7 +9,7 @@ The `InfiniteQueryObserver` can be used to observe and switch between infinite q ```js const observer = new InfiniteQueryObserver(queryClient, { - queryKey: 'posts', + queryKey: ['posts'], queryFn: fetchPosts, getNextPageParam: (lastPage, allPages) => lastPage.nextCursor, getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor, diff --git a/docs/src/pages/reference/QueryClient.md b/docs/src/pages/reference/QueryClient.md index ff2289d58f..63140f8fae 100644 --- a/docs/src/pages/reference/QueryClient.md +++ b/docs/src/pages/reference/QueryClient.md @@ -18,7 +18,7 @@ const queryClient = new QueryClient({ }, }) -await queryClient.prefetchQuery('posts', fetchPosts) +await queryClient.prefetchQuery(['posts'], fetchPosts) ``` Its available methods are: @@ -270,7 +270,7 @@ The `invalidateQueries` method can be used to invalidate and refetch single or m - If you **want inactive queries to refetch** as well, use the `refetchTye: 'all'` option ```js -await queryClient.invalidateQueries('posts', { +await queryClient.invalidateQueries(['posts'], { exact, refetchType: 'active', }, { throwOnError, cancelRefetch }) @@ -343,7 +343,7 @@ The `cancelQueries` method can be used to cancel outgoing queries based on their This is most useful when performing optimistic updates since you will likely need to cancel any outgoing query refetches so they don't clobber your optimistic update when they resolve. ```js -await queryClient.cancelQueries('posts', { exact: true }) +await queryClient.cancelQueries(['posts'], { exact: true }) ``` **Options** @@ -471,7 +471,7 @@ queryClient.setDefaultOptions({ The `getQueryDefaults` method returns the default options which have been set for specific queries: ```js -const defaultOptions = queryClient.getQueryDefaults('posts') +const defaultOptions = queryClient.getQueryDefaults(['posts']) ``` ## `queryClient.setQueryDefaults` @@ -479,10 +479,10 @@ const defaultOptions = queryClient.getQueryDefaults('posts') `setQueryDefaults` can be used to set default options for specific queries: ```js -queryClient.setQueryDefaults('posts', { queryFn: fetchPosts }) +queryClient.setQueryDefaults(['posts'], { queryFn: fetchPosts }) function Component() { - const { data } = useQuery('posts') + const { data } = useQuery(['posts']) } ``` @@ -496,7 +496,7 @@ function Component() { The `getMutationDefaults` method returns the default options which have been set for specific mutations: ```js -const defaultOptions = queryClient.getMutationDefaults('addPost') +const defaultOptions = queryClient.getMutationDefaults(['addPost']) ``` ## `queryClient.setMutationDefaults` @@ -504,10 +504,10 @@ const defaultOptions = queryClient.getMutationDefaults('addPost') `setMutationDefaults` can be used to set default options for specific mutations: ```js -queryClient.setMutationDefaults('addPost', { mutationFn: addPost }) +queryClient.setMutationDefaults(['addPost'], { mutationFn: addPost }) function Component() { - const { data } = useMutation('addPost') + const { data } = useMutation(['addPost']) } ``` diff --git a/docs/src/pages/reference/QueryObserver.md b/docs/src/pages/reference/QueryObserver.md index 51813453d9..a2f625b905 100644 --- a/docs/src/pages/reference/QueryObserver.md +++ b/docs/src/pages/reference/QueryObserver.md @@ -8,7 +8,7 @@ title: QueryObserver The `QueryObserver` can be used to observe and switch between queries. ```js -const observer = new QueryObserver(queryClient, { queryKey: 'posts' }) +const observer = new QueryObserver(queryClient, { queryKey: ['posts'] }) const unsubscribe = observer.subscribe(result => { console.log(result) diff --git a/docs/src/pages/reference/useInfiniteQuery.md b/docs/src/pages/reference/useInfiniteQuery.md index 8ffaab4255..08d6c9cbf0 100644 --- a/docs/src/pages/reference/useInfiniteQuery.md +++ b/docs/src/pages/reference/useInfiniteQuery.md @@ -26,9 +26,7 @@ The options for `useInfiniteQuery` are identical to the [`useQuery` hook](/refer - `queryFn: (context: QueryFunctionContext) => Promise` - **Required, but only if no default query function has been defined** [`defaultQueryFn`](/guides/default-query-function) - The function that the query will use to request data. - - Receives a `QueryFunctionContext` object with the following variables: - - `queryKey: EnsuredQueryKey`: the queryKey, guaranteed to be an Array - - `pageParam: unknown | undefined` + - Receives a [QueryFunctionContext](../guides/query-functions#queryfunctioncontext) - Must return a promise that will either resolve data or throw an error. - Make sure you return the data *and* the `pageParam` if needed for use in the props below. - `getNextPageParam: (lastPage, allPages) => unknown | undefined` diff --git a/docs/src/pages/reference/useQuery.md b/docs/src/pages/reference/useQuery.md index e7e1ca89b9..b0017d893b 100644 --- a/docs/src/pages/reference/useQuery.md +++ b/docs/src/pages/reference/useQuery.md @@ -66,7 +66,7 @@ const result = useQuery({ **Options** -- `queryKey: string | unknown[]` +- `queryKey: unknown[]` - **Required** - The query key to use for this query. - The query key will be hashed into a stable hash. See [Query Keys](../guides/query-keys) for more information. @@ -74,8 +74,7 @@ const result = useQuery({ - `queryFn: (context: QueryFunctionContext) => Promise` - **Required, but only if no default query function has been defined** See [Default Query Function](../guides/default-query-function) for more information. - The function that the query will use to request data. - - Receives a `QueryFunctionContext` object with the following variables: - - `queryKey: EnsuredQueryKey`: the queryKey, guaranteed to be an Array + - Receives a [QueryFunctionContext](../guides/query-functions#queryfunctioncontext) - Must return a promise that will either resolve data or throw an error. - `enabled: boolean` - Set this to `false` to disable this query from automatically running. diff --git a/src/core/query.ts b/src/core/query.ts index 52ba42c6d2..7dfabec548 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -5,7 +5,6 @@ import { noop, replaceEqualDeep, timeUntilStale, - ensureQueryKeyArray, } from './utils' import type { InitialDataFunction, @@ -13,7 +12,6 @@ import type { QueryOptions, QueryStatus, QueryFunctionContext, - EnsuredQueryKey, QueryMeta, CancelOptions, SetDataOptions, @@ -66,7 +64,7 @@ export interface FetchContext< fetchFn: () => unknown | Promise fetchOptions?: FetchOptions options: QueryOptions - queryKey: EnsuredQueryKey + queryKey: TQueryKey state: QueryState meta: QueryMeta | undefined } @@ -374,12 +372,11 @@ export class Query< } } - const queryKey = ensureQueryKeyArray(this.queryKey) const abortController = getAbortController() // Create query function context const queryFnContext: QueryFunctionContext = { - queryKey, + queryKey: this.queryKey, pageParam: undefined, meta: this.meta, } @@ -408,7 +405,7 @@ export class Query< const context: FetchContext = { fetchOptions, options: this.options, - queryKey: queryKey, + queryKey: this.queryKey, state: this.state, fetchFn, meta: this.meta, diff --git a/src/core/tests/hydration.test.tsx b/src/core/tests/hydration.test.tsx index cc7ae08a80..29a44f1d26 100644 --- a/src/core/tests/hydration.test.tsx +++ b/src/core/tests/hydration.test.tsx @@ -12,12 +12,12 @@ describe('dehydration and rehydration', () => { test('should work with serializeable values', async () => { const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - await queryClient.prefetchQuery('string', () => fetchData('string')) - await queryClient.prefetchQuery('number', () => fetchData(1)) - await queryClient.prefetchQuery('boolean', () => fetchData(true)) - await queryClient.prefetchQuery('null', () => fetchData(null)) - await queryClient.prefetchQuery('array', () => fetchData(['string', 0])) - await queryClient.prefetchQuery('nested', () => + await queryClient.prefetchQuery(['string'], () => fetchData('string')) + await queryClient.prefetchQuery(['number'], () => fetchData(1)) + await queryClient.prefetchQuery(['boolean'], () => fetchData(true)) + await queryClient.prefetchQuery(['null'], () => fetchData(null)) + await queryClient.prefetchQuery(['array'], () => fetchData(['string', 0])) + await queryClient.prefetchQuery(['nested'], () => fetchData({ key: [{ nestedKey: 1 }] }) ) const dehydrated = dehydrate(queryClient) @@ -29,32 +29,32 @@ describe('dehydration and rehydration', () => { const hydrationCache = new QueryCache() const hydrationClient = new QueryClient({ queryCache: hydrationCache }) hydrate(hydrationClient, parsed) - expect(hydrationCache.find('string')?.state.data).toBe('string') - expect(hydrationCache.find('number')?.state.data).toBe(1) - expect(hydrationCache.find('boolean')?.state.data).toBe(true) - expect(hydrationCache.find('null')?.state.data).toBe(null) - expect(hydrationCache.find('array')?.state.data).toEqual(['string', 0]) - expect(hydrationCache.find('nested')?.state.data).toEqual({ + expect(hydrationCache.find(['string'])?.state.data).toBe('string') + expect(hydrationCache.find(['number'])?.state.data).toBe(1) + expect(hydrationCache.find(['boolean'])?.state.data).toBe(true) + expect(hydrationCache.find(['null'])?.state.data).toBe(null) + expect(hydrationCache.find(['array'])?.state.data).toEqual(['string', 0]) + expect(hydrationCache.find(['nested'])?.state.data).toEqual({ key: [{ nestedKey: 1 }], }) const fetchDataAfterHydration = jest.fn() - await hydrationClient.prefetchQuery('string', fetchDataAfterHydration, { + await hydrationClient.prefetchQuery(['string'], fetchDataAfterHydration, { staleTime: 1000, }) - await hydrationClient.prefetchQuery('number', fetchDataAfterHydration, { + await hydrationClient.prefetchQuery(['number'], fetchDataAfterHydration, { staleTime: 1000, }) - await hydrationClient.prefetchQuery('boolean', fetchDataAfterHydration, { + await hydrationClient.prefetchQuery(['boolean'], fetchDataAfterHydration, { staleTime: 1000, }) - await hydrationClient.prefetchQuery('null', fetchDataAfterHydration, { + await hydrationClient.prefetchQuery(['null'], fetchDataAfterHydration, { staleTime: 1000, }) - await hydrationClient.prefetchQuery('array', fetchDataAfterHydration, { + await hydrationClient.prefetchQuery(['array'], fetchDataAfterHydration, { staleTime: 1000, }) - await hydrationClient.prefetchQuery('nested', fetchDataAfterHydration, { + await hydrationClient.prefetchQuery(['nested'], fetchDataAfterHydration, { staleTime: 1000, }) expect(fetchDataAfterHydration).toHaveBeenCalledTimes(0) @@ -66,7 +66,7 @@ describe('dehydration and rehydration', () => { test('should not dehydrate queries if dehydrateQueries is set to false', async () => { const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - await queryClient.prefetchQuery('string', () => fetchData('string')) + await queryClient.prefetchQuery(['string'], () => fetchData('string')) const dehydrated = dehydrate(queryClient, { dehydrateQueries: false }) @@ -78,7 +78,7 @@ describe('dehydration and rehydration', () => { test('should use the cache time from the client', async () => { const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - await queryClient.prefetchQuery('string', () => fetchData('string'), { + await queryClient.prefetchQuery(['string'], () => fetchData('string'), { cacheTime: 50, }) const dehydrated = dehydrate(queryClient) @@ -92,9 +92,9 @@ describe('dehydration and rehydration', () => { const hydrationCache = new QueryCache() const hydrationClient = new QueryClient({ queryCache: hydrationCache }) hydrate(hydrationClient, parsed) - expect(hydrationCache.find('string')?.state.data).toBe('string') + expect(hydrationCache.find(['string'])?.state.data).toBe('string') await sleep(100) - expect(hydrationCache.find('string')).toBeTruthy() + expect(hydrationCache.find(['string'])).toBeTruthy() queryClient.clear() hydrationClient.clear() @@ -103,7 +103,7 @@ describe('dehydration and rehydration', () => { test('should be able to provide default options for the hydrated queries', async () => { const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - await queryClient.prefetchQuery('string', () => fetchData('string')) + await queryClient.prefetchQuery(['string'], () => fetchData('string')) const dehydrated = dehydrate(queryClient) const stringified = JSON.stringify(dehydrated) const parsed = JSON.parse(stringified) @@ -112,7 +112,7 @@ describe('dehydration and rehydration', () => { hydrate(hydrationClient, parsed, { defaultOptions: { queries: { retry: 10 } }, }) - expect(hydrationCache.find('string')?.options.retry).toBe(10) + expect(hydrationCache.find(['string'])?.options.retry).toBe(10) queryClient.clear() hydrationClient.clear() }) @@ -155,9 +155,9 @@ describe('dehydration and rehydration', () => { const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - await queryClient.prefetchQuery('success', () => fetchData('success')) - queryClient.prefetchQuery('loading', () => fetchData('loading', 10000)) - await queryClient.prefetchQuery('error', () => { + await queryClient.prefetchQuery(['success'], () => fetchData('success')) + queryClient.prefetchQuery(['loading'], () => fetchData('loading', 10000)) + await queryClient.prefetchQuery(['error'], () => { throw new Error() }) const dehydrated = dehydrate(queryClient) @@ -170,9 +170,9 @@ describe('dehydration and rehydration', () => { const hydrationClient = new QueryClient({ queryCache: hydrationCache }) hydrate(hydrationClient, parsed) - expect(hydrationCache.find('success')).toBeTruthy() - expect(hydrationCache.find('loading')).toBeFalsy() - expect(hydrationCache.find('error')).toBeFalsy() + expect(hydrationCache.find(['success'])).toBeTruthy() + expect(hydrationCache.find(['loading'])).toBeFalsy() + expect(hydrationCache.find(['error'])).toBeFalsy() queryClient.clear() hydrationClient.clear() @@ -182,16 +182,16 @@ describe('dehydration and rehydration', () => { test('should filter queries via shouldDehydrateQuery', async () => { const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - await queryClient.prefetchQuery('string', () => fetchData('string')) - await queryClient.prefetchQuery('number', () => fetchData(1)) + await queryClient.prefetchQuery(['string'], () => fetchData('string')) + await queryClient.prefetchQuery(['number'], () => fetchData(1)) const dehydrated = dehydrate(queryClient, { - shouldDehydrateQuery: query => query.queryKey !== 'string', + shouldDehydrateQuery: query => query.queryKey[0] !== 'string', }) // This is testing implementation details that can change and are not // part of the public API, but is important for keeping the payload small const dehydratedQuery = dehydrated?.queries.find( - query => query?.queryKey === 'string' + query => query?.queryKey[0] === 'string' ) expect(dehydratedQuery).toBeUndefined() @@ -203,8 +203,8 @@ describe('dehydration and rehydration', () => { const hydrationCache = new QueryCache() const hydrationClient = new QueryClient({ queryCache: hydrationCache }) hydrate(hydrationClient, parsed) - expect(hydrationCache.find('string')).toBeUndefined() - expect(hydrationCache.find('number')?.state.data).toBe(1) + expect(hydrationCache.find(['string'])).toBeUndefined() + expect(hydrationCache.find(['number'])?.state.data).toBe(1) queryClient.clear() hydrationClient.clear() @@ -213,7 +213,7 @@ describe('dehydration and rehydration', () => { test('should not overwrite query in cache if hydrated query is older', async () => { const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - await queryClient.prefetchQuery('string', () => + await queryClient.prefetchQuery(['string'], () => fetchData('string-older', 5) ) const dehydrated = dehydrate(queryClient) @@ -224,12 +224,12 @@ describe('dehydration and rehydration', () => { const parsed = JSON.parse(stringified) const hydrationCache = new QueryCache() const hydrationClient = new QueryClient({ queryCache: hydrationCache }) - await hydrationClient.prefetchQuery('string', () => + await hydrationClient.prefetchQuery(['string'], () => fetchData('string-newer', 5) ) hydrate(hydrationClient, parsed) - expect(hydrationCache.find('string')?.state.data).toBe('string-newer') + expect(hydrationCache.find(['string'])?.state.data).toBe('string-newer') queryClient.clear() hydrationClient.clear() @@ -238,7 +238,7 @@ describe('dehydration and rehydration', () => { test('should overwrite query in cache if hydrated query is newer', async () => { const hydrationCache = new QueryCache() const hydrationClient = new QueryClient({ queryCache: hydrationCache }) - await hydrationClient.prefetchQuery('string', () => + await hydrationClient.prefetchQuery(['string'], () => fetchData('string-older', 5) ) @@ -246,7 +246,7 @@ describe('dehydration and rehydration', () => { const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - await queryClient.prefetchQuery('string', () => + await queryClient.prefetchQuery(['string'], () => fetchData('string-newer', 5) ) const dehydrated = dehydrate(queryClient) @@ -256,7 +256,7 @@ describe('dehydration and rehydration', () => { const parsed = JSON.parse(stringified) hydrate(hydrationClient, parsed) - expect(hydrationCache.find('string')?.state.data).toBe('string-newer') + expect(hydrationCache.find(['string'])?.state.data).toBe('string-newer') queryClient.clear() hydrationClient.clear() @@ -278,7 +278,7 @@ describe('dehydration and rehydration', () => { const serverClient = new QueryClient() - serverClient.setMutationDefaults('addTodo', { + serverClient.setMutationDefaults(['addTodo'], { mutationFn: serverAddTodo, onMutate: serverOnMutate, onSuccess: serverOnSuccess, @@ -288,7 +288,7 @@ describe('dehydration and rehydration', () => { serverClient .executeMutation({ - mutationKey: 'addTodo', + mutationKey: ['addTodo'], variables: { text: 'text' }, }) .catch(() => undefined) @@ -316,7 +316,7 @@ describe('dehydration and rehydration', () => { }) const clientOnSuccess = jest.fn() - client.setMutationDefaults('addTodo', { + client.setMutationDefaults(['addTodo'], { mutationFn: clientAddTodo, onMutate: clientOnMutate, onSuccess: clientOnSuccess, @@ -351,14 +351,14 @@ describe('dehydration and rehydration', () => { const queryClient = new QueryClient() - queryClient.setMutationDefaults('addTodo', { + queryClient.setMutationDefaults(['addTodo'], { mutationFn: serverAddTodo, retry: false, }) queryClient .executeMutation({ - mutationKey: 'addTodo', + mutationKey: ['addTodo'], variables: { text: 'text' }, }) .catch(() => undefined) @@ -382,7 +382,7 @@ describe('dehydration and rehydration', () => { const queryClient = new QueryClient() - queryClient.setMutationDefaults('addTodo', { + queryClient.setMutationDefaults(['addTodo'], { mutationFn: serverAddTodo, retry: 1, retryDelay: 20, @@ -390,7 +390,7 @@ describe('dehydration and rehydration', () => { queryClient .executeMutation({ - mutationKey: 'addTodo', + mutationKey: ['addTodo'], variables: { text: 'text' }, }) .catch(() => undefined) diff --git a/src/core/tests/infiniteQueryBehavior.test.tsx b/src/core/tests/infiniteQueryBehavior.test.tsx index 8ab5389936..a3e57e30e4 100644 --- a/src/core/tests/infiniteQueryBehavior.test.tsx +++ b/src/core/tests/infiniteQueryBehavior.test.tsx @@ -80,7 +80,7 @@ describe('InfiniteQueryBehavior', () => { ) expect(queryFnSpy).toHaveBeenNthCalledWith(1, { - queryKey: [key], + queryKey: key, pageParam: undefined, meta: undefined, signal: abortSignal, @@ -92,7 +92,7 @@ describe('InfiniteQueryBehavior', () => { await observer.fetchNextPage() expect(queryFnSpy).toHaveBeenNthCalledWith(1, { - queryKey: [key], + queryKey: key, pageParam: 2, meta: undefined, signal: abortSignal, @@ -111,7 +111,7 @@ describe('InfiniteQueryBehavior', () => { }) expect(queryFnSpy).toHaveBeenNthCalledWith(1, { - queryKey: [key], + queryKey: key, pageParam: 2, meta: undefined, signal: abortSignal, diff --git a/src/core/tests/mutationCache.test.tsx b/src/core/tests/mutationCache.test.tsx index 37e3f3017b..75753d17a5 100644 --- a/src/core/tests/mutationCache.test.tsx +++ b/src/core/tests/mutationCache.test.tsx @@ -67,10 +67,10 @@ describe('mutationCache', () => { }) const [mutation] = testCache.getAll() expect(testCache.find({ mutationKey: key })).toEqual(mutation) - expect(testCache.find({ mutationKey: 'mutation', exact: false })).toEqual( - mutation - ) - expect(testCache.find({ mutationKey: 'unknown' })).toEqual(undefined) + expect( + testCache.find({ mutationKey: ['mutation'], exact: false }) + ).toEqual(mutation) + expect(testCache.find({ mutationKey: ['unknown'] })).toEqual(undefined) expect( testCache.find({ predicate: m => m.options.variables === 'vars' }) ).toEqual(mutation) @@ -92,16 +92,16 @@ describe('mutationCache', () => { mutationFn: () => Promise.resolve(), }) await testClient.executeMutation({ - mutationKey: 'b', + mutationKey: ['b'], mutationFn: () => Promise.resolve(), }) const [mutation1, mutation2] = testCache.getAll() expect( - testCache.findAll({ mutationKey: 'a', exact: false }) + testCache.findAll({ mutationKey: ['a'], exact: false }) ).toHaveLength(2) expect(testCache.find({ mutationKey: ['a', 1] })).toEqual(mutation1) - expect(testCache.findAll({ mutationKey: 'unknown' })).toEqual([]) + expect(testCache.findAll({ mutationKey: ['unknown'] })).toEqual([]) expect( testCache.findAll({ predicate: m => m.options.variables === 2 }) ).toEqual([mutation2]) diff --git a/src/core/tests/mutations.test.tsx b/src/core/tests/mutations.test.tsx index ad189c5189..af2676e286 100644 --- a/src/core/tests/mutations.test.tsx +++ b/src/core/tests/mutations.test.tsx @@ -371,14 +371,14 @@ describe('mutations', () => { }) const observer = new MutationObserver(queryClient, { - mutationKey: 'key', + mutationKey: ['key'], mutationFn, }) observer.mutate() const mutation = queryClient .getMutationCache() - .find({ mutationKey: 'key' })! + .find({ mutationKey: ['key'] })! await sleep(10) // Force current mutation retryer to be undefined @@ -393,7 +393,7 @@ describe('mutations', () => { test('reducer should return the state for an unknown action type', async () => { const observer = new MutationObserver(queryClient, { - mutationKey: 'key', + mutationKey: ['key'], mutationFn: async () => 'data', }) @@ -402,7 +402,7 @@ describe('mutations', () => { observer.mutate() const mutation = queryClient .getMutationCache() - .find({ mutationKey: 'key' })! + .find({ mutationKey: ['key'] })! const prevState = observer.getCurrentResult() spy.mockReset() diff --git a/src/core/tests/query.test.tsx b/src/core/tests/query.test.tsx index 5bd06adf2d..e44c8df7c2 100644 --- a/src/core/tests/query.test.tsx +++ b/src/core/tests/query.test.tsx @@ -190,7 +190,10 @@ describe('query', () => { const key = queryKey() const queryFn = jest - .fn, [QueryFunctionContext]>() + .fn< + Promise<'data'>, + [QueryFunctionContext>] + >() .mockResolvedValue('data') queryClient.prefetchQuery(key, queryFn) @@ -201,7 +204,7 @@ describe('query', () => { const args = queryFn.mock.calls[0]![0] expect(args).toBeDefined() expect(args.pageParam).toBeUndefined() - expect(args.queryKey).toEqual([key]) + expect(args.queryKey).toEqual(key) if (typeof AbortSignal === 'function') { expect(args.signal).toBeInstanceOf(AbortSignal) } else { @@ -277,7 +280,10 @@ describe('query', () => { test('should provide an AbortSignal to the queryFn that provides info about the cancellation state', async () => { const key = queryKey() - const queryFn = jest.fn, [QueryFunctionContext]>() + const queryFn = jest.fn< + Promise, + [QueryFunctionContext>] + >() const onAbort = jest.fn() const abortListener = jest.fn() let error diff --git a/src/core/tests/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx index 09fdec6fff..36ae34a7b8 100644 --- a/src/core/tests/queryCache.test.tsx +++ b/src/core/tests/queryCache.test.tsx @@ -89,7 +89,8 @@ describe('queryCache', () => { const query4 = queryCache.find(['posts', 1])! expect(queryCache.findAll(key1)).toEqual([query1]) - expect(queryCache.findAll([key1])).toEqual([query1]) + // wrapping in an extra array doesn't yield the same results anymore since v4 because keys need to be an array + expect(queryCache.findAll([key1])).toEqual([]) expect(queryCache.findAll()).toEqual([query1, query2, query3, query4]) expect(queryCache.findAll({})).toEqual([query1, query2, query3, query4]) expect(queryCache.findAll(key1, { type: 'inactive' })).toEqual([query1]) @@ -135,7 +136,7 @@ describe('queryCache', () => { expect( queryCache.findAll({ predicate: query => query === query3 }) ).toEqual([query3]) - expect(queryCache.findAll('posts')).toEqual([query4]) + expect(queryCache.findAll(['posts'])).toEqual([query4]) }) test('should return all the queries when no filters are defined', async () => { diff --git a/src/core/tests/queryClient.test.tsx b/src/core/tests/queryClient.test.tsx index 79c1398b79..fce37aba88 100644 --- a/src/core/tests/queryClient.test.tsx +++ b/src/core/tests/queryClient.test.tsx @@ -176,19 +176,6 @@ describe('queryClient', () => { expect(queryClient.getQueryData(key)).toBe('qux') }) - test('should use the same query when using similar string or array query keys', () => { - const key = queryKey() - queryClient.setQueryData(key, '1') - expect(queryClient.getQueryData(key)).toBe('1') - expect(queryClient.getQueryData([key])).toBe('1') - queryClient.setQueryData([key], '2') - expect(queryClient.getQueryData(key)).toBe('2') - expect(queryClient.getQueryData([key])).toBe('2') - queryClient.setQueryData(key, '1') - expect(queryClient.getQueryData(key)).toBe('1') - expect(queryClient.getQueryData([key])).toBe('1') - }) - test('should accept an update function', () => { const key = queryKey() @@ -292,7 +279,10 @@ describe('queryClient', () => { queryClient.setQueryData(['key', 1], 1) queryClient.setQueryData(['key', 2], 2) - const result = queryClient.setQueriesData('key', old => old! + 5) + const result = queryClient.setQueriesData( + ['key'], + old => old! + 5 + ) expect(result).toEqual([ [['key', 1], 6], @@ -318,10 +308,10 @@ describe('queryClient', () => { }) test('should not update non existing queries', () => { - const result = queryClient.setQueriesData('key', 'data') + const result = queryClient.setQueriesData(['key'], 'data') expect(result).toEqual([]) - expect(queryClient.getQueryData('key')).toBe(undefined) + expect(queryClient.getQueryData(['key'])).toBe(undefined) }) }) @@ -378,8 +368,8 @@ describe('queryClient', () => { describe('fetchQuery', () => { test('should not type-error with strict query key', async () => { type StrictData = 'data' - type StrictQueryKey = ['strict', string] - const key: StrictQueryKey = ['strict', queryKey()] + type StrictQueryKey = ['strict', ...ReturnType] + const key: StrictQueryKey = ['strict', ...queryKey()] const fetchFn: QueryFunction = () => Promise.resolve('data') @@ -490,8 +480,8 @@ describe('queryClient', () => { describe('fetchInfiniteQuery', () => { test('should not type-error with strict query key', async () => { type StrictData = string - type StrictQueryKey = ['strict', string] - const key: StrictQueryKey = ['strict', queryKey()] + type StrictQueryKey = ['strict', ...ReturnType] + const key: StrictQueryKey = ['strict', ...queryKey()] const data = { pages: ['data'], @@ -532,8 +522,8 @@ describe('queryClient', () => { describe('prefetchInfiniteQuery', () => { test('should not type-error with strict query key', async () => { type StrictData = 'data' - type StrictQueryKey = ['strict', string] - const key: StrictQueryKey = ['strict', queryKey()] + type StrictQueryKey = ['strict', ...ReturnType] + const key: StrictQueryKey = ['strict', ...queryKey()] const fetchFn: QueryFunction = () => Promise.resolve('data') @@ -572,8 +562,8 @@ describe('queryClient', () => { describe('prefetchQuery', () => { test('should not type-error with strict query key', async () => { type StrictData = 'data' - type StrictQueryKey = ['strict', string] - const key: StrictQueryKey = ['strict', queryKey()] + type StrictQueryKey = ['strict', ...ReturnType] + const key: StrictQueryKey = ['strict', ...queryKey()] const fetchFn: QueryFunction = () => Promise.resolve('data') diff --git a/src/core/tests/utils.test.tsx b/src/core/tests/utils.test.tsx index 13fda6b217..2297c2a6b7 100644 --- a/src/core/tests/utils.test.tsx +++ b/src/core/tests/utils.test.tsx @@ -341,14 +341,14 @@ describe('core/utils', () => { describe('parseMutationArgs', () => { it('should return mutation options', () => { - const options = { mutationKey: 'key' } + const options = { mutationKey: ['key'] } expect(parseMutationArgs(options)).toMatchObject(options) }) }) describe('matchMutation', () => { it('should return false if mutationKey options is undefined', () => { - const filters = { mutationKey: 'key1' } + const filters = { mutationKey: ['key1'] } const queryClient = new QueryClient() const mutation = new Mutation({ mutationId: 1, diff --git a/src/core/types.ts b/src/core/types.ts index 3e3817f339..74926e45fe 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -3,11 +3,7 @@ import type { QueryBehavior, Query } from './query' import type { RetryValue, RetryDelayValue } from './retryer' import type { QueryFilters, QueryTypeFilter } from './utils' -export type QueryKey = string | readonly unknown[] -export type EnsuredQueryKey = T extends string - ? [T] - : Exclude - +export type QueryKey = readonly unknown[] export type QueryFunction< T = unknown, TQueryKey extends QueryKey = QueryKey @@ -17,7 +13,7 @@ export interface QueryFunctionContext< TQueryKey extends QueryKey = QueryKey, TPageParam = any > { - queryKey: EnsuredQueryKey + queryKey: TQueryKey signal?: AbortSignal pageParam?: TPageParam meta: QueryMeta | undefined @@ -496,7 +492,7 @@ export type InfiniteQueryObserverResult = | InfiniteQueryObserverRefetchErrorResult | InfiniteQueryObserverSuccessResult -export type MutationKey = string | readonly unknown[] +export type MutationKey = readonly unknown[] export type MutationStatus = 'idle' | 'loading' | 'success' | 'error' diff --git a/src/core/utils.ts b/src/core/utils.ts index 65fbb7e949..578caf7084 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,6 +1,5 @@ import type { Mutation } from './mutation' import type { Query } from './query' -import { EnsuredQueryKey } from './types' import type { MutationFunction, MutationKey, @@ -87,14 +86,6 @@ export function isValidTimeout(value: unknown): value is number { return typeof value === 'number' && value >= 0 && value !== Infinity } -export function ensureQueryKeyArray( - value: T -): EnsuredQueryKey { - return (Array.isArray(value) - ? value - : ([value] as unknown)) as EnsuredQueryKey -} - export function difference(array1: T[], array2: T[]): T[] { return array1.filter(x => array2.indexOf(x) === -1) } @@ -254,17 +245,10 @@ export function hashQueryKeyByOptions( /** * Default query keys hash function. - */ -export function hashQueryKey(queryKey: QueryKey): string { - const asArray = ensureQueryKeyArray(queryKey) - return stableValueHash(asArray) -} - -/** * Hashes the value into a stable hash. */ -export function stableValueHash(value: any): string { - return JSON.stringify(value, (_, val) => +export function hashQueryKey(queryKey: QueryKey): string { + return JSON.stringify(queryKey, (_, val) => isPlainObject(val) ? Object.keys(val) .sort() @@ -280,7 +264,7 @@ export function stableValueHash(value: any): string { * Checks if key `b` partially matches with key `a`. */ export function partialMatchKey(a: QueryKey, b: QueryKey): boolean { - return partialDeepEqual(ensureQueryKeyArray(a), ensureQueryKeyArray(b)) + return partialDeepEqual(a, b) } /** @@ -385,8 +369,8 @@ function hasObjectPrototype(o: any): boolean { return Object.prototype.toString.call(o) === '[object Object]' } -export function isQueryKey(value: any): value is QueryKey { - return typeof value === 'string' || Array.isArray(value) +export function isQueryKey(value: unknown): value is QueryKey { + return Array.isArray(value) } export function isError(value: any): value is Error { diff --git a/src/createWebStoragePersister/tests/storageIsFull.test.ts b/src/createWebStoragePersister/tests/storageIsFull.test.ts index 87e9c69e27..d071c5d87d 100644 --- a/src/createWebStoragePersister/tests/storageIsFull.test.ts +++ b/src/createWebStoragePersister/tests/storageIsFull.test.ts @@ -45,11 +45,11 @@ describe('createWebStoragePersister ', () => { storage, }) - await queryClient.prefetchQuery('string', () => Promise.resolve('string')) - await queryClient.prefetchQuery('number', () => Promise.resolve(1)) - await queryClient.prefetchQuery('boolean', () => Promise.resolve(true)) - await queryClient.prefetchQuery('null', () => Promise.resolve(null)) - await queryClient.prefetchQuery('array', () => + await queryClient.prefetchQuery(['string'], () => Promise.resolve('string')) + await queryClient.prefetchQuery(['number'], () => Promise.resolve(1)) + await queryClient.prefetchQuery(['boolean'], () => Promise.resolve(true)) + await queryClient.prefetchQuery(['null'], () => Promise.resolve(null)) + await queryClient.prefetchQuery(['array'], () => Promise.resolve(['string', 0]) ) @@ -76,16 +76,16 @@ describe('createWebStoragePersister ', () => { storage, }) - await queryClient.prefetchQuery('A', () => Promise.resolve('A'.repeat(N))) + await queryClient.prefetchQuery(['A'], () => Promise.resolve('A'.repeat(N))) await sleep(1) - await queryClient.prefetchQuery('B', () => Promise.resolve('B'.repeat(N))) + await queryClient.prefetchQuery(['B'], () => Promise.resolve('B'.repeat(N))) await sleep(1) - await queryClient.prefetchQuery('C', () => Promise.resolve('C'.repeat(N))) + await queryClient.prefetchQuery(['C'], () => Promise.resolve('C'.repeat(N))) await sleep(1) - await queryClient.prefetchQuery('D', () => Promise.resolve('D'.repeat(N))) + await queryClient.prefetchQuery(['D'], () => Promise.resolve('D'.repeat(N))) await sleep(1) - await queryClient.prefetchQuery('E', () => Promise.resolve('E'.repeat(N))) + await queryClient.prefetchQuery(['E'], () => Promise.resolve('E'.repeat(N))) const persistClient = { buster: 'test-limit', @@ -97,14 +97,14 @@ describe('createWebStoragePersister ', () => { const restoredClient = await webStoragePersister.restoreClient() expect(restoredClient?.clientState.queries.length).toEqual(4) expect( - restoredClient?.clientState.queries.find(q => q.queryKey === 'A') + restoredClient?.clientState.queries.find(q => q.queryKey[0] === 'A') ).toBeUndefined() expect( - restoredClient?.clientState.queries.find(q => q.queryKey === 'B') + restoredClient?.clientState.queries.find(q => q.queryKey[0] === 'B') ).not.toBeUndefined() // update query Data - await queryClient.prefetchQuery('A', () => Promise.resolve('a'.repeat(N))) + await queryClient.prefetchQuery(['A'], () => Promise.resolve('a'.repeat(N))) const updatedPersistClient = { buster: 'test-limit', timestamp: Date.now(), @@ -115,10 +115,10 @@ describe('createWebStoragePersister ', () => { const restoredClient2 = await webStoragePersister.restoreClient() expect(restoredClient2?.clientState.queries.length).toEqual(4) expect( - restoredClient2?.clientState.queries.find(q => q.queryKey === 'A') + restoredClient2?.clientState.queries.find(q => q.queryKey[0] === 'A') ).toHaveProperty('state.data', 'a'.repeat(N)) expect( - restoredClient2?.clientState.queries.find(q => q.queryKey === 'B') + restoredClient2?.clientState.queries.find(q => q.queryKey[0] === 'B') ).toBeUndefined() }) @@ -137,7 +137,7 @@ describe('createWebStoragePersister ', () => { mutationCache.build( queryClient, { - mutationKey: 'MUTATIONS', + mutationKey: ['MUTATIONS'], mutationFn: () => Promise.resolve('M'.repeat(N)), }, { @@ -151,12 +151,12 @@ describe('createWebStoragePersister ', () => { } ) await sleep(1) - await queryClient.prefetchQuery('A', () => Promise.resolve('A'.repeat(N))) + await queryClient.prefetchQuery(['A'], () => Promise.resolve('A'.repeat(N))) await sleep(1) - await queryClient.prefetchQuery('B', () => Promise.resolve('B'.repeat(N))) - await queryClient.prefetchQuery('C', () => Promise.resolve('C'.repeat(N))) + await queryClient.prefetchQuery(['B'], () => Promise.resolve('B'.repeat(N))) + await queryClient.prefetchQuery(['C'], () => Promise.resolve('C'.repeat(N))) await sleep(1) - await queryClient.prefetchQuery('D', () => Promise.resolve('D'.repeat(N))) + await queryClient.prefetchQuery(['D'], () => Promise.resolve('D'.repeat(N))) const persistClient = { buster: 'test-limit-mutations', @@ -169,7 +169,7 @@ describe('createWebStoragePersister ', () => { expect(restoredClient?.clientState.mutations.length).toEqual(1) expect(restoredClient?.clientState.queries.length).toEqual(3) expect( - restoredClient?.clientState.queries.find(q => q.queryKey === 'A') + restoredClient?.clientState.queries.find(q => q.queryKey === ['A']) ).toBeUndefined() }) }) diff --git a/src/devtools/tests/devtools.test.tsx b/src/devtools/tests/devtools.test.tsx index b529052a03..fa84f296ee 100644 --- a/src/devtools/tests/devtools.test.tsx +++ b/src/devtools/tests/devtools.test.tsx @@ -20,7 +20,7 @@ describe('ReactQueryDevtools', () => { const { queryClient } = createQueryClient() function Page() { - const { data = 'default' } = useQuery('check', async () => { + const { data = 'default' } = useQuery(['check'], async () => { await sleep(10) return 'test' }) @@ -57,7 +57,7 @@ describe('ReactQueryDevtools', () => { function Page() { const { data = 'default' } = useQuery( - 'check', + ['check'], async () => { await sleep(100) return 'test' @@ -98,7 +98,7 @@ describe('ReactQueryDevtools', () => { screen.getByRole('button', { name: /open react query devtools/i }) ) - const currentQuery = queryCache.find('check') + const currentQuery = queryCache.find(['check']) // When the query is fetching then expect number of // fetching queries to be 1 @@ -140,7 +140,7 @@ describe('ReactQueryDevtools', () => { const { queryClient, queryCache } = createQueryClient() function Page() { - const { data = 'default' } = useQuery('check', async () => { + const { data = 'default' } = useQuery(['check'], async () => { await sleep(10) return 'test' }) @@ -158,7 +158,7 @@ describe('ReactQueryDevtools', () => { screen.getByRole('button', { name: /open react query devtools/i }) ) - const currentQuery = queryCache.find('check') + const currentQuery = queryCache.find(['check']) await screen.findByText(getByTextContent(`1${currentQuery?.queryHash}`)) @@ -175,17 +175,17 @@ describe('ReactQueryDevtools', () => { const { queryClient, queryCache } = createQueryClient() function Page() { - const fooResult = useQuery('foo', async () => { + const fooResult = useQuery(['foo'], async () => { await sleep(10) return 'foo-result' }) - const barResult = useQuery('bar', async () => { + const barResult = useQuery(['bar'], async () => { await sleep(10) return 'bar-result' }) - const bazResult = useQuery('baz', async () => { + const bazResult = useQuery(['baz'], async () => { await sleep(10) return 'baz-result' }) @@ -205,9 +205,9 @@ describe('ReactQueryDevtools', () => { screen.getByRole('button', { name: /open react query devtools/i }) ) - const fooQueryHash = queryCache.find('foo')?.queryHash ?? 'invalid hash' - const barQueryHash = queryCache.find('bar')?.queryHash ?? 'invalid hash' - const bazQueryHash = queryCache.find('baz')?.queryHash ?? 'invalid hash' + const fooQueryHash = queryCache.find(['foo'])?.queryHash ?? 'invalid hash' + const barQueryHash = queryCache.find(['bar'])?.queryHash ?? 'invalid hash' + const bazQueryHash = queryCache.find(['baz'])?.queryHash ?? 'invalid hash' await screen.findByText(fooQueryHash) screen.getByText(barQueryHash) @@ -231,7 +231,7 @@ describe('ReactQueryDevtools', () => { function Page() { const [enabled, setEnabled] = React.useState(false) const { data } = useQuery( - 'key', + ['key'], async () => { await sleep(10) return 'test' @@ -264,7 +264,7 @@ describe('ReactQueryDevtools', () => { const { queryClient } = createQueryClient() function Page() { - const { data } = useQuery('key', () => Promise.resolve('test'), { + const { data } = useQuery(['key'], () => Promise.resolve('test'), { enabled: false, }) @@ -301,18 +301,18 @@ describe('ReactQueryDevtools', () => { const { queryClient, queryCache } = createQueryClient() function Page() { - const query1Result = useQuery('query-1', async () => { + const query1Result = useQuery(['query-1'], async () => { await sleep(20) return 'query-1-result' }) - const query2Result = useQuery('query-2', async () => { + const query2Result = useQuery(['query-2'], async () => { await sleep(60) return 'query-2-result' }) const query3Result = useQuery( - 'query-3', + ['query-3'], async () => { await sleep(40) return 'query-3-result' @@ -335,9 +335,9 @@ describe('ReactQueryDevtools', () => { screen.getByRole('button', { name: /open react query devtools/i }) ) - const query1Hash = queryCache.find('query-1')?.queryHash ?? 'invalid hash' - const query2Hash = queryCache.find('query-2')?.queryHash ?? 'invalid hash' - const query3Hash = queryCache.find('query-3')?.queryHash ?? 'invalid hash' + const query1Hash = queryCache.find(['query-1'])?.queryHash ?? 'invalid hash' + const query2Hash = queryCache.find(['query-2'])?.queryHash ?? 'invalid hash' + const query3Hash = queryCache.find(['query-3'])?.queryHash ?? 'invalid hash' const sortSelect = screen.getByLabelText(/sort queries/i) let queries = [] diff --git a/src/reactjs/tests/Hydrate.test.tsx b/src/reactjs/tests/Hydrate.test.tsx index 3ea92d41c7..12924afa9d 100644 --- a/src/reactjs/tests/Hydrate.test.tsx +++ b/src/reactjs/tests/Hydrate.test.tsx @@ -16,13 +16,13 @@ import * as coreModule from '../../core/index' describe('React hydration', () => { const fetchData: (value: string) => Promise = value => new Promise(res => setTimeout(() => res(value), 10)) - const dataQuery: (key: string) => Promise = key => fetchData(key) + const dataQuery: (key: [string]) => Promise = key => fetchData(key[0]) let stringifiedState: string beforeAll(async () => { const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - await queryClient.prefetchQuery('string', () => dataQuery('string')) + await queryClient.prefetchQuery(['string'], () => dataQuery(['string'])) const dehydrated = dehydrate(queryClient) stringifiedState = JSON.stringify(dehydrated) queryClient.clear() @@ -36,7 +36,7 @@ describe('React hydration', () => { function Page() { useHydrate(dehydratedState) - const { data } = useQuery('string', () => dataQuery('string')) + const { data } = useQuery(['string'], () => dataQuery(['string'])) return (

{data}

@@ -62,7 +62,7 @@ describe('React hydration', () => { const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) - function Page({ queryKey }: { queryKey: string }) { + function Page({ queryKey }: { queryKey: [string] }) { const { data } = useQuery(queryKey, () => dataQuery(queryKey)) return (
@@ -74,7 +74,7 @@ describe('React hydration', () => { const rendered = render( - + ) @@ -86,11 +86,11 @@ describe('React hydration', () => { const intermediateClient = new QueryClient({ queryCache: intermediateCache, }) - await intermediateClient.prefetchQuery('string', () => - dataQuery('should change') + await intermediateClient.prefetchQuery(['string'], () => + dataQuery(['should change']) ) - await intermediateClient.prefetchQuery('added string', () => - dataQuery('added string') + await intermediateClient.prefetchQuery(['added string'], () => + dataQuery(['added string']) ) const dehydrated = dehydrate(intermediateClient) intermediateClient.clear() @@ -98,8 +98,8 @@ describe('React hydration', () => { rendered.rerender( - - + + ) @@ -120,7 +120,7 @@ describe('React hydration', () => { const queryClient = new QueryClient({ queryCache }) function Page() { - const { data } = useQuery('string', () => dataQuery('string')) + const { data } = useQuery(['string'], () => dataQuery(['string'])) return (

{data}

diff --git a/src/reactjs/tests/ssr-hydration.test.tsx b/src/reactjs/tests/ssr-hydration.test.tsx index f855b86352..d165543f28 100644 --- a/src/reactjs/tests/ssr-hydration.test.tsx +++ b/src/reactjs/tests/ssr-hydration.test.tsx @@ -36,7 +36,7 @@ describe('Server side rendering with de/rehydration', () => { // -- Shared part -- function SuccessComponent() { - const result = useQuery('success', () => fetchDataSuccess('success!')) + const result = useQuery(['success'], () => fetchDataSuccess('success!')) return ( ) @@ -47,7 +47,7 @@ describe('Server side rendering with de/rehydration', () => { const prefetchCache = new QueryCache() const prefetchClient = new QueryClient({ queryCache: prefetchCache }) - await prefetchClient.prefetchQuery('success', () => + await prefetchClient.prefetchQuery(['success'], () => fetchDataSuccess('success') ) const dehydratedStateServer = dehydrate(prefetchClient) @@ -101,7 +101,9 @@ describe('Server side rendering with de/rehydration', () => { // -- Shared part -- function ErrorComponent() { - const result = useQuery('error', () => fetchDataError(), { retry: false }) + const result = useQuery(['error'], () => fetchDataError(), { + retry: false, + }) return ( ) @@ -111,7 +113,7 @@ describe('Server side rendering with de/rehydration', () => { setIsServer(true) const prefetchCache = new QueryCache() const prefetchClient = new QueryClient({ queryCache: prefetchCache }) - await prefetchClient.prefetchQuery('error', () => fetchDataError()) + await prefetchClient.prefetchQuery(['error'], () => fetchDataError()) const dehydratedStateServer = dehydrate(prefetchClient) const renderCache = new QueryCache() const renderClient = new QueryClient({ queryCache: renderCache }) @@ -166,7 +168,7 @@ describe('Server side rendering with de/rehydration', () => { // -- Shared part -- function SuccessComponent() { - const result = useQuery('success', () => fetchDataSuccess('success!')) + const result = useQuery(['success'], () => fetchDataSuccess('success!')) return ( ) diff --git a/src/reactjs/tests/suspense.test.tsx b/src/reactjs/tests/suspense.test.tsx index 50a63e22d4..45e35d70d6 100644 --- a/src/reactjs/tests/suspense.test.tsx +++ b/src/reactjs/tests/suspense.test.tsx @@ -74,7 +74,7 @@ describe("useQuery's in Suspense mode", () => { function Page() { const [multiplier, setMultiplier] = React.useState(1) const state = useInfiniteQuery( - `${key}_${multiplier}`, + [`${key}_${multiplier}`], ({ pageParam = 1 }) => Number(pageParam * multiplier), { suspense: true, @@ -439,7 +439,7 @@ describe("useQuery's in Suspense mode", () => { const key1 = queryKey() const key2 = queryKey() - function Component(props: { queryKey: string }) { + function Component(props: { queryKey: Array }) { const result = useQuery( props.queryKey, async () => { @@ -766,7 +766,7 @@ describe("useQuery's in Suspense mode", () => { function Page() { const [nonce] = React.useState(0) - const queryKeys = `${key}-${succeed}` + const queryKeys = [`${key}-${succeed}`] const result = useQuery( queryKeys, async () => { diff --git a/src/reactjs/tests/useInfiniteQuery.test.tsx b/src/reactjs/tests/useInfiniteQuery.test.tsx index 540b44956d..37cb33449a 100644 --- a/src/reactjs/tests/useInfiniteQuery.test.tsx +++ b/src/reactjs/tests/useInfiniteQuery.test.tsx @@ -755,7 +755,7 @@ describe('useInfiniteQuery', () => { const abortListeners: jest.Mock[] = [] const fetchPage = jest.fn< Promise, - [QueryFunctionContext] + [QueryFunctionContext] >(async ({ pageParam = start, signal }) => { if (signal) { const onAbort = jest.fn() @@ -798,7 +798,7 @@ describe('useInfiniteQuery', () => { let callIndex = 0 const firstCtx = fetchPage.mock.calls[callIndex]![0] expect(firstCtx.pageParam).toBeUndefined() - expect(firstCtx.queryKey).toEqual([key]) + expect(firstCtx.queryKey).toEqual(key) if (typeof AbortSignal === 'function') { expect(firstCtx.signal).toBeInstanceOf(AbortSignal) expect(firstCtx.signal?.aborted).toBe(false) @@ -809,7 +809,7 @@ describe('useInfiniteQuery', () => { callIndex = 1 const secondCtx = fetchPage.mock.calls[callIndex]![0] expect(secondCtx.pageParam).toBe(11) - expect(secondCtx.queryKey).toEqual([key]) + expect(secondCtx.queryKey).toEqual(key) if (typeof AbortSignal === 'function') { expect(secondCtx.signal).toBeInstanceOf(AbortSignal) expect(secondCtx.signal?.aborted).toBe(true) @@ -820,7 +820,7 @@ describe('useInfiniteQuery', () => { callIndex = 2 const thirdCtx = fetchPage.mock.calls[callIndex]![0] expect(thirdCtx.pageParam).toBe(11) - expect(thirdCtx.queryKey).toEqual([key]) + expect(thirdCtx.queryKey).toEqual(key) if (typeof AbortSignal === 'function') { expect(thirdCtx.signal).toBeInstanceOf(AbortSignal) expect(thirdCtx.signal?.aborted).toBe(false) @@ -836,7 +836,7 @@ describe('useInfiniteQuery', () => { const abortListeners: jest.Mock[] = [] const fetchPage = jest.fn< Promise, - [QueryFunctionContext] + [QueryFunctionContext] >(async ({ pageParam = start, signal }) => { if (signal) { const onAbort = jest.fn() @@ -879,7 +879,7 @@ describe('useInfiniteQuery', () => { let callIndex = 0 const firstCtx = fetchPage.mock.calls[callIndex]![0] expect(firstCtx.pageParam).toBeUndefined() - expect(firstCtx.queryKey).toEqual([key]) + expect(firstCtx.queryKey).toEqual(key) if (typeof AbortSignal === 'function') { expect(firstCtx.signal).toBeInstanceOf(AbortSignal) expect(firstCtx.signal?.aborted).toBe(false) @@ -890,7 +890,7 @@ describe('useInfiniteQuery', () => { callIndex = 1 const secondCtx = fetchPage.mock.calls[callIndex]![0] expect(secondCtx.pageParam).toBe(11) - expect(secondCtx.queryKey).toEqual([key]) + expect(secondCtx.queryKey).toEqual(key) if (typeof AbortSignal === 'function') { expect(secondCtx.signal).toBeInstanceOf(AbortSignal) expect(secondCtx.signal?.aborted).toBe(false) diff --git a/src/reactjs/tests/useIsMutating.test.tsx b/src/reactjs/tests/useIsMutating.test.tsx index 10f56cdee3..33af902593 100644 --- a/src/reactjs/tests/useIsMutating.test.tsx +++ b/src/reactjs/tests/useIsMutating.test.tsx @@ -18,11 +18,11 @@ describe('useIsMutating', () => { } function Page() { - const { mutate: mutate1 } = useMutation('mutation1', async () => { + const { mutate: mutate1 } = useMutation(['mutation1'], async () => { await sleep(150) return 'data' }) - const { mutate: mutate2 } = useMutation('mutation2', async () => { + const { mutate: mutate2 } = useMutation(['mutation2'], async () => { await sleep(50) return 'data' }) @@ -46,17 +46,17 @@ describe('useIsMutating', () => { const queryClient = new QueryClient() function IsMutating() { - const isMutating = useIsMutating('mutation1') + const isMutating = useIsMutating(['mutation1']) isMutatings.push(isMutating) return null } function Page() { - const { mutate: mutate1 } = useMutation('mutation1', async () => { + const { mutate: mutate1 } = useMutation(['mutation1'], async () => { await sleep(100) return 'data' }) - const { mutate: mutate2 } = useMutation('mutation2', async () => { + const { mutate: mutate2 } = useMutation(['mutation2'], async () => { await sleep(100) return 'data' }) @@ -79,18 +79,19 @@ describe('useIsMutating', () => { function IsMutating() { const isMutating = useIsMutating({ - predicate: mutation => mutation.options.mutationKey === 'mutation1', + predicate: mutation => + mutation.options.mutationKey?.[0] === 'mutation1', }) isMutatings.push(isMutating) return null } function Page() { - const { mutate: mutate1 } = useMutation('mutation1', async () => { + const { mutate: mutate1 } = useMutation(['mutation1'], async () => { await sleep(100) return 'data' }) - const { mutate: mutate2 } = useMutation('mutation2', async () => { + const { mutate: mutate2 } = useMutation(['mutation2'], async () => { await sleep(100) return 'data' }) @@ -132,7 +133,7 @@ describe('useIsMutating', () => { function Page() { const [mounted, setMounted] = React.useState(true) - const { mutate: mutate1 } = useMutation('mutation1', async () => { + const { mutate: mutate1 } = useMutation(['mutation1'], async () => { await sleep(10) return 'data' }) diff --git a/src/reactjs/tests/useQueries.test.tsx b/src/reactjs/tests/useQueries.test.tsx index 423184b9aa..d1584f7101 100644 --- a/src/reactjs/tests/useQueries.test.tsx +++ b/src/reactjs/tests/useQueries.test.tsx @@ -685,11 +685,11 @@ describe('useQueries', () => { // Array as const does not throw error const result5 = useQueries([ { - queryKey: 'key1', + queryKey: ['key1'], queryFn: () => 'string', }, { - queryKey: 'key1', + queryKey: ['key1'], queryFn: () => 123, }, ] as const) diff --git a/src/reactjs/tests/useQuery.test.tsx b/src/reactjs/tests/useQuery.test.tsx index c0a9a77584..b8b6a3b704 100644 --- a/src/reactjs/tests/useQuery.test.tsx +++ b/src/reactjs/tests/useQuery.test.tsx @@ -91,13 +91,16 @@ describe('useQuery', () => { queryFn: getMyDataArrayKey, }) - const getMyDataStringKey: QueryFunction = async context => { + const getMyDataStringKey: QueryFunction< + MyData, + ['1'] + > = async context => { expectType<['1']>(context.queryKey) return Number(context.queryKey[0]) + 42 } useQuery({ - queryKey: '1', + queryKey: ['1'], queryFn: getMyDataStringKey, }) } @@ -2426,10 +2429,10 @@ describe('useQuery', () => { it('should not pass stringified variables to query function', async () => { const key = queryKey() const variables = { number: 5, boolean: false, object: {}, array: [] } - type QueryKey = [string, typeof variables] - const states: UseQueryResult[] = [] + type CustomQueryKey = [typeof key, typeof variables] + const states: UseQueryResult[] = [] - const queryFn = async (ctx: QueryFunctionContext) => { + const queryFn = async (ctx: QueryFunctionContext) => { await sleep(10) return ctx.queryKey } @@ -3715,7 +3718,7 @@ describe('useQuery', () => { it('should accept an empty string as query key', async () => { function Page() { - const result = useQuery('', ctx => ctx.queryKey) + const result = useQuery([''], ctx => ctx.queryKey) return <>{JSON.stringify(result.data)} } @@ -3988,7 +3991,7 @@ describe('useQuery', () => { const key = queryKey() const states: UseQueryResult[] = [] - const queryFn: QueryFunction = async ctx => { + const queryFn: QueryFunction = async ctx => { const [, limit] = ctx.queryKey const value = limit % 2 && ctx.signal ? 'abort' : `data ${limit}` await sleep(10) diff --git a/src/reactjs/tests/utils.tsx b/src/reactjs/tests/utils.tsx index 314c3183be..b594a1f117 100644 --- a/src/reactjs/tests/utils.tsx +++ b/src/reactjs/tests/utils.tsx @@ -37,9 +37,9 @@ export function mockConsoleError() { } let queryKeyCount = 0 -export function queryKey(): string { +export function queryKey(): Array { queryKeyCount++ - return `query_${queryKeyCount}` + return [`query_${queryKeyCount}`] } export function sleep(timeout: number): Promise { From 5b464dae9a18ed10e18d80cbdf386ec44b761dc9 Mon Sep 17 00:00:00 2001 From: Rene Dellefont Date: Sat, 20 Nov 2021 11:36:07 -0500 Subject: [PATCH 09/19] feat(QueryObserver): track queries as default (#2987) * feat(Query Options): remove notifyOnChangePropsExclusion - remove related code from queryObserver - remove type def - remove related tests * docs(Query Options): update notifyOnChangePropsExclusion sections - remove from api references - add to v4 migration guide * feat(QueryObserver): "tracked" as default behavior - remove "tracked" completely if notifyOnChangeProps is not defined, behave as v3 "tracked" - add `notifyOnChangeProps: 'all' to opt out of the smart tracking TODO: Now that default behavior has changed, work out the failed tests. Which parts to change for current ones and possibly write new ones. * test(useQuery): adjust tests to pass for notifyOnChangeProps udpate * test(useInfiniteQuery): adjust tests to pass for notifyOnChangeProps udpate * test(QueryResetErrorBoundary): adjust tests to pass for notifyOnChangeProps udpate * refactor(QueryObserver): use nullish coalescing operator much cleaner than the negated if I started with * test(QueryResetErrorBoundary): remove "tracked" from test * revert: test(QueryResetErrorBoundary): adjust tests to pass for notifyOnChaneProps udpate This reverts commit a34b4720675dad5ee6ebde401639f328c0c83122. The changes are not necessary after PR #2993 fix. * refactor(QueryObserver): combine prop checks * docs(notifyOnChangeProps): update docs to reflect new api --- docs/src/pages/comparison.md | 2 +- .../guides/migrating-to-react-query-4.md | 10 ++ docs/src/pages/reference/QueryClient.md | 2 +- docs/src/pages/reference/useQuery.md | 10 +- src/core/queryObserver.ts | 19 +-- src/core/types.ts | 9 +- .../tests/QueryResetErrorBoundary.test.tsx | 1 - src/reactjs/tests/useInfiniteQuery.test.tsx | 16 ++- src/reactjs/tests/useQuery.test.tsx | 120 +++++------------- src/reactjs/useBaseQuery.ts | 2 +- 10 files changed, 70 insertions(+), 121 deletions(-) diff --git a/docs/src/pages/comparison.md b/docs/src/pages/comparison.md index 4a3691ed4c..dfa06c741d 100644 --- a/docs/src/pages/comparison.md +++ b/docs/src/pages/comparison.md @@ -64,7 +64,7 @@ Feature/Capability Key: > **1 Lagged Query Data** - React Query provides a way to continue to see an existing query's data while the next query loads (similar to the same UX that suspense will soon provide natively). This is extremely important when writing pagination UIs or infinite loading UIs where you do not want to show a hard loading state whenever a new query is requested. Other libraries do not have this capability and render a hard loading state for the new query (unless it has been prefetched), while the new query loads. -> **2 Render Optimization** - React Query has excellent rendering performance. It will only re-render your components when a query is updated. For example because it has new data, or to indicate it is fetching. React Query also batches updates together to make sure your application only re-renders once when multiple components are using the same query. If you are only interested in the `data` or `error` properties, you can reduce the number of renders even more by setting `notifyOnChangeProps` to `['data', 'error']`. Set `notifyOnChangeProps: 'tracked'` to automatically track which fields are accessed and only re-render if one of them changes. +> **2 Render Optimization** - React Query has excellent rendering performance. By default, it will automatically track which fields are accessed and only re-render if one of them changes. If you would like to opt-out of this optimization, setting `notifyOnChangeProps` to `'all'` will re-render your components whenever the query is updated. For example because it has new data, or to indicate it is fetching. React Query also batches updates together to make sure your application only re-renders once when multiple components are using the same query. If you are only interested in the `data` or `error` properties, you can reduce the number of renders even more by setting `notifyOnChangeProps` to `['data', 'error']`. > **3 Partial query matching** - Because React Query uses deterministic query key serialization, this allows you to manipulate variable groups of queries without having to know each individual query-key that you want to match, eg. you can refetch every query that starts with `todos` in its key, regardless of variables, or you can target specific queries with (or without) variables or nested properties, and even use a filter function to only match queries that pass your specific conditions. diff --git a/docs/src/pages/guides/migrating-to-react-query-4.md b/docs/src/pages/guides/migrating-to-react-query-4.md index 44000c1e98..539813f1d4 100644 --- a/docs/src/pages/guides/migrating-to-react-query-4.md +++ b/docs/src/pages/guides/migrating-to-react-query-4.md @@ -27,6 +27,16 @@ With version [3.22.0](https://github.com/tannerlinsley/react-query/releases/tag/ + import { dehydrate, hydrate, useHydrate, Hydrate } from 'react-query' ``` +### `notifyOnChangeProps` property no longer accepts `"tracked"` as a value + +The `notifyOnChangeProps` option no longer accepts a `"tracked"` value. Instead, `useQuery` defaults to tracking properties. All queries using `notifyOnChangeProps: "tracked"` should be updated by removing this option. + +If you would like to bypass this in any queries to emulate the v3 default behavior of re-rendering whenever a query changes, `notifyOnChangeProps` now accepts an `"all"` value to opt-out of the default smart tracking optimization. + +### `notifyOnChangePropsExclusion` has been removed + +In v4, `notifyOnChangeProps` defaults to the `"tracked"` behavior of v3 instead of `undefined`. Now that `"tracked"` is the default behavior for v4, it no longer makes sense to include this config option. + ### Consistent behavior for `cancelRefetch` The `cancelRefetch` can be passed to all functions that imperatively fetch a query, namely: diff --git a/docs/src/pages/reference/QueryClient.md b/docs/src/pages/reference/QueryClient.md index 63140f8fae..b195e050d0 100644 --- a/docs/src/pages/reference/QueryClient.md +++ b/docs/src/pages/reference/QueryClient.md @@ -91,7 +91,7 @@ try { **Options** -The options for `fetchQuery` are exactly the same as those of [`useQuery`](./useQuery), except the following: `enabled, refetchInterval, refetchIntervalInBackground, refetchOnWindowFocus, refetchOnReconnect, notifyOnChangeProps, notifyOnChangePropsExclusions, onSuccess, onError, onSettled, useErrorBoundary, select, suspense, keepPreviousData, placeholderData`; which are strictly for useQuery and useInfiniteQuery. You can check the [source code](https://github.com/tannerlinsley/react-query/blob/361935a12cec6f36d0bd6ba12e84136c405047c5/src/core/types.ts#L83) for more clarity. +The options for `fetchQuery` are exactly the same as those of [`useQuery`](./useQuery), except the following: `enabled, refetchInterval, refetchIntervalInBackground, refetchOnWindowFocus, refetchOnReconnect, notifyOnChangeProps, onSuccess, onError, onSettled, useErrorBoundary, select, suspense, keepPreviousData, placeholderData`; which are strictly for useQuery and useInfiniteQuery. You can check the [source code](https://github.com/tannerlinsley/react-query/blob/361935a12cec6f36d0bd6ba12e84136c405047c5/src/core/types.ts#L83) for more clarity. **Returns** diff --git a/docs/src/pages/reference/useQuery.md b/docs/src/pages/reference/useQuery.md index b0017d893b..5afeee10ae 100644 --- a/docs/src/pages/reference/useQuery.md +++ b/docs/src/pages/reference/useQuery.md @@ -35,7 +35,6 @@ const { keepPreviousData, meta, notifyOnChangeProps, - notifyOnChangePropsExclusions, onError, onSettled, onSuccess, @@ -125,15 +124,12 @@ const result = useQuery({ - If set to `true`, the query will refetch on reconnect if the data is stale. - If set to `false`, the query will not refetch on reconnect. - If set to `"always"`, the query will always refetch on reconnect. -- `notifyOnChangeProps: string[] | "tracked"` +- `notifyOnChangeProps: string[] | "all"` - Optional - If set, the component will only re-render if any of the listed properties change. - If set to `['data', 'error']` for example, the component will only re-render when the `data` or `error` properties change. - - If set to `"tracked"`, access to properties will be tracked, and the component will only re-render when one of the tracked properties change. -- `notifyOnChangePropsExclusions: string[]` - - Optional - - If set, the component will not re-render if any of the listed properties change. - - If set to `['isStale']` for example, the component will not re-render when the `isStale` property changes. + - If set to `"all"`, the component will opt-out of smart tracking and re-render whenever a query is updated. + - By default, access to properties will be tracked, and the component will only re-render when one of the tracked properties change. - `onSuccess: (data: TData) => void` - Optional - This function will fire any time the query successfully fetches new data. diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index 2ef23baffc..0cc8d3b8c1 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -610,27 +610,22 @@ export class QueryObserver< return true } - const { notifyOnChangeProps, notifyOnChangePropsExclusions } = this.options + const { notifyOnChangeProps } = this.options - if (!notifyOnChangeProps && !notifyOnChangePropsExclusions) { - return true - } - - if (notifyOnChangeProps === 'tracked' && !this.trackedProps.length) { + if ( + notifyOnChangeProps === 'all' || + (!notifyOnChangeProps && !this.trackedProps.length) + ) { return true } - const includedProps = - notifyOnChangeProps === 'tracked' - ? this.trackedProps - : notifyOnChangeProps + const includedProps = notifyOnChangeProps ?? this.trackedProps return Object.keys(result).some(key => { const typedKey = key as keyof QueryObserverResult const changed = result[typedKey] !== prevResult[typedKey] const isIncluded = includedProps?.some(x => x === key) - const isExcluded = notifyOnChangePropsExclusions?.some(x => x === key) - return changed && !isExcluded && (!includedProps || isIncluded) + return changed && (!includedProps || isIncluded) }) } diff --git a/src/core/types.ts b/src/core/types.ts index 74926e45fe..e9bf309ae4 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -154,13 +154,10 @@ export interface QueryObserverOptions< /** * If set, the component will only re-render if any of the listed properties change. * When set to `['data', 'error']`, the component will only re-render when the `data` or `error` properties change. - * When set to `tracked`, access to properties will be tracked, and the component will only re-render when one of the tracked properties change. + * When set to `'all'`, the component will re-render whenever a query is updated. + * By default, access to properties will be tracked, and the component will only re-render when one of the tracked properties change. */ - notifyOnChangeProps?: Array | 'tracked' - /** - * If set, the component will not re-render if any of the listed properties change. - */ - notifyOnChangePropsExclusions?: Array + notifyOnChangeProps?: Array | 'all' /** * This callback will fire any time the query successfully fetches new data or the cache is updated via `setQueryData`. */ diff --git a/src/reactjs/tests/QueryResetErrorBoundary.test.tsx b/src/reactjs/tests/QueryResetErrorBoundary.test.tsx index 5fc1e366fe..5e304c6f99 100644 --- a/src/reactjs/tests/QueryResetErrorBoundary.test.tsx +++ b/src/reactjs/tests/QueryResetErrorBoundary.test.tsx @@ -613,7 +613,6 @@ describe('QueryErrorResetBoundary', () => { { retry: false, useErrorBoundary: true, - notifyOnChangeProps: 'tracked', } ) return
{data}
diff --git a/src/reactjs/tests/useInfiniteQuery.test.tsx b/src/reactjs/tests/useInfiniteQuery.test.tsx index 37cb33449a..54865a8c8c 100644 --- a/src/reactjs/tests/useInfiniteQuery.test.tsx +++ b/src/reactjs/tests/useInfiniteQuery.test.tsx @@ -186,6 +186,7 @@ describe('useInfiniteQuery', () => { { getNextPageParam: () => 1, keepPreviousData: true, + notifyOnChangeProps: 'all', } ) @@ -306,6 +307,7 @@ describe('useInfiniteQuery', () => { pages: [...data.pages].reverse(), pageParams: [...data.pageParams].reverse(), }), + notifyOnChangeProps: 'all', } ) @@ -359,6 +361,7 @@ describe('useInfiniteQuery', () => { }, { getPreviousPageParam: firstPage => firstPage - 1, + notifyOnChangeProps: 'all', } ) @@ -423,8 +426,10 @@ describe('useInfiniteQuery', () => { const states: UseInfiniteQueryResult[] = [] function Page() { - const state = useInfiniteQuery(key, ({ pageParam = 10 }) => - Number(pageParam) + const state = useInfiniteQuery( + key, + ({ pageParam = 10 }) => Number(pageParam), + { notifyOnChangeProps: 'all' } ) states.push(state) @@ -516,6 +521,7 @@ describe('useInfiniteQuery', () => { { getPreviousPageParam: firstPage => firstPage - 1, getNextPageParam: lastPage => lastPage + 1, + notifyOnChangeProps: 'all', } ) @@ -608,6 +614,7 @@ describe('useInfiniteQuery', () => { ({ pageParam = 10 }) => Number(pageParam) * multiplier.current, { getNextPageParam: lastPage => lastPage + 1, + notifyOnChangeProps: 'all', } ) @@ -687,6 +694,7 @@ describe('useInfiniteQuery', () => { }, { getNextPageParam: lastPage => lastPage + 1, + notifyOnChangeProps: 'all', } ) @@ -913,6 +921,7 @@ describe('useInfiniteQuery', () => { }, { getNextPageParam: lastPage => lastPage + 1, + notifyOnChangeProps: 'all', } ) @@ -1014,6 +1023,7 @@ describe('useInfiniteQuery', () => { }, { getNextPageParam: lastPage => lastPage + 1, + notifyOnChangeProps: 'all', } ) @@ -1080,6 +1090,7 @@ describe('useInfiniteQuery', () => { }, { getNextPageParam: lastPage => lastPage + 1, + notifyOnChangeProps: 'all', } ) @@ -1169,6 +1180,7 @@ describe('useInfiniteQuery', () => { { initialData: { pages: [1], pageParams: [1] }, getNextPageParam: lastPage => lastPage + 1, + notifyOnChangeProps: 'all', } ) diff --git a/src/reactjs/tests/useQuery.test.tsx b/src/reactjs/tests/useQuery.test.tsx index b8b6a3b704..a86778e14a 100644 --- a/src/reactjs/tests/useQuery.test.tsx +++ b/src/reactjs/tests/useQuery.test.tsx @@ -236,6 +236,7 @@ describe('useQuery', () => { return (

Status: {state.status}

+
Failure Count: {state.failureCount}
) } @@ -373,7 +374,10 @@ describe('useQuery', () => { const onSuccess = jest.fn() function Page() { - const state = useQuery(key, () => 'data', { onSuccess }) + const state = useQuery(key, () => 'data', { + onSuccess, + notifyOnChangeProps: 'all', + }) states.push(state) @@ -738,6 +742,7 @@ describe('useQuery', () => { }, { cacheTime: 0, + notifyOnChangeProps: 'all', } ) @@ -920,54 +925,12 @@ describe('useQuery', () => { consoleMock.mockRestore() }) - it('should re-render when dataUpdatedAt changes but data remains the same', async () => { - const key = queryKey() - const states: UseQueryResult[] = [] - - function Page() { - const state = useQuery(key, () => 'test', { - notifyOnChangePropsExclusions: [ - 'data', - 'isFetching', - 'isLoading', - 'isRefetching', - 'isSuccess', - 'status', - ], - }) - - states.push(state) - - const { refetch } = state - - React.useEffect(() => { - setActTimeout(() => { - refetch() - }, 5) - }, [refetch]) - - return null - } - - renderWithClient(queryClient, ) - - await sleep(10) - - expect(states.length).toBe(3) - expect(states[0]).toMatchObject({ data: undefined, isFetching: true }) - expect(states[1]).toMatchObject({ data: 'test', isFetching: false }) - expect(states[2]).toMatchObject({ data: 'test', isFetching: false }) - expect(states[1]?.dataUpdatedAt).not.toBe(states[2]?.dataUpdatedAt) - }) - it('should track properties and only re-render when a tracked property changes', async () => { const key = queryKey() const states: UseQueryResult[] = [] function Page() { - const state = useQuery(key, () => 'test', { - notifyOnChangeProps: 'tracked', - }) + const state = useQuery(key, () => 'test') states.push(state) @@ -995,46 +958,13 @@ describe('useQuery', () => { expect(states[1]).toMatchObject({ data: 'test' }) }) - it('should not re-render if a tracked prop changes, but it was excluded', async () => { - const key = queryKey() - const states: UseQueryResult[] = [] - - function Page() { - const state = useQuery(key, () => 'test', { - notifyOnChangeProps: 'tracked', - notifyOnChangePropsExclusions: ['data'], - }) - - states.push(state) - - return ( -
-

{state.data ?? 'null'}

-
- ) - } - - const rendered = renderWithClient(queryClient, ) - - await waitFor(() => rendered.getByText('null')) - expect(states.length).toBe(1) - expect(states[0]).toMatchObject({ data: undefined }) - - await queryClient.refetchQueries(key) - await waitFor(() => rendered.getByText('null')) - expect(states.length).toBe(1) - expect(states[0]).toMatchObject({ data: undefined }) - }) - it('should always re-render if we are tracking props but not using any', async () => { const key = queryKey() let renderCount = 0 const states: UseQueryResult[] = [] function Page() { - const state = useQuery(key, () => 'test', { - notifyOnChangeProps: 'tracked', - }) + const state = useQuery(key, () => 'test') states.push(state) @@ -1065,7 +995,7 @@ describe('useQuery', () => { function Page() { const [, rerender] = React.useState({}) - const state = useQuery(key, () => ++count) + const state = useQuery(key, () => ++count, { notifyOnChangeProps: 'all' }) states.push(state) @@ -1106,7 +1036,7 @@ describe('useQuery', () => { let count = 0 function Page() { - const state = useQuery(key, () => ++count) + const state = useQuery(key, () => ++count, { notifyOnChangeProps: 'all' }) states.push(state) @@ -1157,10 +1087,14 @@ describe('useQuery', () => { let count = 0 function Page() { - const state = useQuery(key, () => { - count++ - return count === 1 ? result1 : result2 - }) + const state = useQuery( + key, + () => { + count++ + return count === 1 ? result1 : result2 + }, + { notifyOnChangeProps: 'all' } + ) states.push(state) @@ -1533,6 +1467,7 @@ describe('useQuery', () => {

data: {state.data}

error: {state.error?.message}

+

previous data: {state.isPreviousData}

) } @@ -1694,7 +1629,7 @@ describe('useQuery', () => { await sleep(10) return count }, - { enabled: false, keepPreviousData: true } + { enabled: false, keepPreviousData: true, notifyOnChangeProps: 'all' } ) states.push(state) @@ -1783,7 +1718,7 @@ describe('useQuery', () => { await sleep(10) return count }, - { enabled: false, keepPreviousData: true } + { enabled: false, keepPreviousData: true, notifyOnChangeProps: 'all' } ) states.push(state) @@ -1853,7 +1788,7 @@ describe('useQuery', () => { const states: UseQueryResult[] = [] function FirstComponent() { - const state = useQuery(key, () => 1) + const state = useQuery(key, () => 1, { notifyOnChangeProps: 'all' }) const refetch = state.refetch states.push(state) @@ -1868,7 +1803,7 @@ describe('useQuery', () => { } function SecondComponent() { - useQuery(key, () => 2) + useQuery(key, () => 2, { notifyOnChangeProps: 'all' }) return null } @@ -3637,7 +3572,12 @@ describe('useQuery', () => { states.push(queryInfo) - return
count: {queryInfo.data}
+ return ( +
+

count: {queryInfo.data}

+

refetch: {queryInfo.isRefetching}

+
+ ) } const rendered = renderWithClient(queryClient, ) @@ -4195,7 +4135,7 @@ describe('useQuery', () => { count++ return count }, - { staleTime: Infinity, enabled: false } + { staleTime: Infinity, enabled: false, notifyOnChangeProps: 'all' } ) states.push(state) diff --git a/src/reactjs/useBaseQuery.ts b/src/reactjs/useBaseQuery.ts index 3adb64e053..6853467dca 100644 --- a/src/reactjs/useBaseQuery.ts +++ b/src/reactjs/useBaseQuery.ts @@ -143,7 +143,7 @@ export function useBaseQuery< } // Handle result property usage tracking - if (defaultedOptions.notifyOnChangeProps === 'tracked') { + if (!defaultedOptions.notifyOnChangeProps) { result = observer.trackResult(result, defaultedOptions) } From a701340efd5fe5751cd4b317be88430533f4d0d7 Mon Sep 17 00:00:00 2001 From: Prateek Surana Date: Mon, 22 Nov 2021 13:58:20 +0530 Subject: [PATCH 10/19] refactor: Remove deprecated promise cancel (#2996) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :fire: Remove the cancel method on promise for cancelling promise * ✅ Fix query client tests * ✅ Update query and useQuery tests * ✅ Update use infinite query tests * 📝 Update migartion guide * :bug: Fix linking in documentation * :pencil: Fix grammatical errors in docs Co-authored-by: Dominik Dorfmeister * :refactor: Use abortSignal for query cancellation in InfiniteQueryBehavior * 🚨 Fix lint errors * :recycle: Move define signal property to a separate function Co-authored-by: Dominik Dorfmeister --- .../guides/migrating-to-react-query-4.md | 4 ++ src/core/infiniteQueryBehavior.ts | 16 ++------ src/core/query.ts | 32 ++++++++++------ src/core/retryer.ts | 21 ----------- src/core/tests/query.test.tsx | 37 ------------------- src/core/tests/queryClient.test.tsx | 28 +++++++++----- src/reactjs/tests/useInfiniteQuery.test.tsx | 9 +++-- src/reactjs/tests/useQuery.test.tsx | 9 +++-- 8 files changed, 56 insertions(+), 100 deletions(-) diff --git a/docs/src/pages/guides/migrating-to-react-query-4.md b/docs/src/pages/guides/migrating-to-react-query-4.md index 539813f1d4..99a930bf08 100644 --- a/docs/src/pages/guides/migrating-to-react-query-4.md +++ b/docs/src/pages/guides/migrating-to-react-query-4.md @@ -184,6 +184,10 @@ Since these plugins are no longer experimental, their import paths have also bee + import { createAsyncStoragePersister } from 'react-query/createAsyncStoragePersister' ``` +### The `cancel` method on promises is no longer supported + +The [old `cancel` method](../guides/query-cancellation#old-cancel-function) that allowed you to define a `cancel` function on promises, which was then used by the library to support query cancellation, has been removed. We recommend to use the [newer API](../guides/query-cancellation) (introduced with v3.30.0) for query cancellation that uses the [`AbortController` API](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) internally and provides you with an [`AbortSignal` instance](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) for your query function to support query cancellation. + ## New Features 🚀 ### Mutation Cache Garbage Collection diff --git a/src/core/infiniteQueryBehavior.ts b/src/core/infiniteQueryBehavior.ts index 0bd46be8e0..3fe93a0377 100644 --- a/src/core/infiniteQueryBehavior.ts +++ b/src/core/infiniteQueryBehavior.ts @@ -1,5 +1,5 @@ import type { QueryBehavior } from './query' -import { isCancelable } from './retryer' + import type { InfiniteData, QueryFunctionContext, @@ -73,11 +73,6 @@ export function infiniteQueryBehavior< buildNewPages(pages, param, page, previous) ) - if (isCancelable(queryFnResult)) { - const promiseAsAny = promise as any - promiseAsAny.cancel = queryFnResult.cancel - } - return promise } @@ -148,15 +143,10 @@ export function infiniteQueryBehavior< pageParams: newPageParams, })) - const finalPromiseAsAny = finalPromise as any - - finalPromiseAsAny.cancel = () => { + context.signal?.addEventListener('abort', () => { cancelled = true abortController?.abort() - if (isCancelable(promise)) { - promise.cancel() - } - } + }) return finalPromise } diff --git a/src/core/query.ts b/src/core/query.ts index 7dfabec548..20c096f3a3 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -63,6 +63,7 @@ export interface FetchContext< > { fetchFn: () => unknown | Promise fetchOptions?: FetchOptions + signal?: AbortSignal options: QueryOptions queryKey: TQueryKey state: QueryState @@ -316,7 +317,7 @@ export class Query< // If the transport layer does not support cancellation // we'll let the query continue so the result can be cached if (this.retryer) { - if (this.retryer.isTransportCancelable || this.abortSignalConsumed) { + if (this.abortSignalConsumed) { this.retryer.cancel({ revert: true }) } else { this.retryer.cancelRetry() @@ -381,16 +382,23 @@ export class Query< meta: this.meta, } - Object.defineProperty(queryFnContext, 'signal', { - enumerable: true, - get: () => { - if (abortController) { - this.abortSignalConsumed = true - return abortController.signal - } - return undefined - }, - }) + // Adds an enumerable signal property to the object that + // which sets abortSignalConsumed to true when the signal + // is read. + const addSignalProperty = (object: unknown) => { + Object.defineProperty(object, 'signal', { + enumerable: true, + get: () => { + if (abortController) { + this.abortSignalConsumed = true + return abortController.signal + } + return undefined + }, + }) + } + + addSignalProperty(queryFnContext) // Create fetch function const fetchFn = () => { @@ -411,6 +419,8 @@ export class Query< meta: this.meta, } + addSignalProperty(context) + if (this.options.behavior?.onFetch) { this.options.behavior?.onFetch(context) } diff --git a/src/core/retryer.ts b/src/core/retryer.ts index d1c6e162d0..82e4d53792 100644 --- a/src/core/retryer.ts +++ b/src/core/retryer.ts @@ -34,15 +34,6 @@ type RetryDelayFunction = ( function defaultRetryDelay(failureCount: number) { return Math.min(1000 * 2 ** failureCount, 30000) } - -interface Cancelable { - cancel(): void -} - -export function isCancelable(value: any): value is Cancelable { - return typeof value?.cancel === 'function' -} - export class CancelledError { revert?: boolean silent?: boolean @@ -65,7 +56,6 @@ export class Retryer { failureCount: number isPaused: boolean isResolved: boolean - isTransportCancelable: boolean promise: Promise private abort?: () => void @@ -86,7 +76,6 @@ export class Retryer { this.failureCount = 0 this.isPaused = false this.isResolved = false - this.isTransportCancelable = false this.promise = new Promise((outerResolve, outerReject) => { promiseResolve = outerResolve promiseReject = outerReject @@ -144,19 +133,9 @@ export class Retryer { reject(new CancelledError(cancelOptions)) this.abort?.() - - // Cancel transport if supported - if (isCancelable(promiseOrValue)) { - try { - promiseOrValue.cancel() - } catch {} - } } } - // Check if the transport layer support cancellation - this.isTransportCancelable = isCancelable(promiseOrValue) - Promise.resolve(promiseOrValue) .then(resolve) .catch(error => { diff --git a/src/core/tests/query.test.tsx b/src/core/tests/query.test.tsx index e44c8df7c2..1900feb5f0 100644 --- a/src/core/tests/query.test.tsx +++ b/src/core/tests/query.test.tsx @@ -335,43 +335,6 @@ describe('query', () => { expect(isCancelledError(error)).toBe(true) }) - test('should call cancel() fn if it was provided and not continue when last observer unsubscribed', async () => { - const key = queryKey() - - const cancel = jest.fn() - - queryClient.prefetchQuery(key, async () => { - const promise = new Promise((resolve, reject) => { - sleep(100).then(() => resolve('data')) - cancel.mockImplementation(() => { - reject(new Error('Cancelled')) - }) - }) as any - promise.cancel = cancel - return promise - }) - - await sleep(10) - - // Subscribe and unsubscribe to simulate cancellation because the last observer unsubscribed - const observer = new QueryObserver(queryClient, { - queryKey: key, - enabled: false, - }) - const unsubscribe = observer.subscribe() - unsubscribe() - - await sleep(100) - - const query = queryCache.find(key)! - - expect(cancel).toHaveBeenCalled() - expect(query.state).toMatchObject({ - data: undefined, - status: 'idle', - }) - }) - test('should not continue if explicitly cancelled', async () => { const key = queryKey() diff --git a/src/core/tests/queryClient.test.tsx b/src/core/tests/queryClient.test.tsx index fce37aba88..b3ebe2f804 100644 --- a/src/core/tests/queryClient.test.tsx +++ b/src/core/tests/queryClient.test.tsx @@ -983,7 +983,7 @@ describe('queryClient', () => { test('should cancel ongoing fetches if cancelRefetch option is set (default value)', async () => { const key = queryKey() - const cancelFn = jest.fn() + const abortFn = jest.fn() let fetchCount = 0 const observer = new QueryObserver(queryClient, { queryKey: key, @@ -992,25 +992,29 @@ describe('queryClient', () => { }) observer.subscribe() - queryClient.fetchQuery(key, () => { + queryClient.fetchQuery(key, ({ signal }) => { const promise = new Promise(resolve => { fetchCount++ setTimeout(() => resolve(5), 10) + if (signal) { + signal.addEventListener('abort', abortFn) + } }) - // @ts-expect-error - promise.cancel = cancelFn + return promise }) await queryClient.refetchQueries() observer.destroy() - expect(cancelFn).toHaveBeenCalledTimes(1) + if (typeof AbortSignal === 'function') { + expect(abortFn).toHaveBeenCalledTimes(1) + } expect(fetchCount).toBe(2) }) test('should not cancel ongoing fetches if cancelRefetch option is set to false', async () => { const key = queryKey() - const cancelFn = jest.fn() + const abortFn = jest.fn() let fetchCount = 0 const observer = new QueryObserver(queryClient, { queryKey: key, @@ -1019,19 +1023,23 @@ describe('queryClient', () => { }) observer.subscribe() - queryClient.fetchQuery(key, () => { + queryClient.fetchQuery(key, ({ signal }) => { const promise = new Promise(resolve => { fetchCount++ setTimeout(() => resolve(5), 10) + if (signal) { + signal.addEventListener('abort', abortFn) + } }) - // @ts-expect-error - promise.cancel = cancelFn + return promise }) await queryClient.refetchQueries(undefined, { cancelRefetch: false }) observer.destroy() - expect(cancelFn).toHaveBeenCalledTimes(0) + if (typeof AbortSignal === 'function') { + expect(abortFn).toHaveBeenCalledTimes(0) + } expect(fetchCount).toBe(1) }) }) diff --git a/src/reactjs/tests/useInfiniteQuery.test.tsx b/src/reactjs/tests/useInfiniteQuery.test.tsx index 54865a8c8c..76ed72df6e 100644 --- a/src/reactjs/tests/useInfiniteQuery.test.tsx +++ b/src/reactjs/tests/useInfiniteQuery.test.tsx @@ -1794,14 +1794,13 @@ describe('useInfiniteQuery', () => { const key = queryKey() let cancelFn: jest.Mock = jest.fn() - const queryFn = () => { + const queryFn = ({ signal }: { signal?: AbortSignal }) => { const promise = new Promise((resolve, reject) => { cancelFn = jest.fn(() => reject('Cancelled')) + signal?.addEventListener('abort', cancelFn) sleep(10).then(() => resolve('OK')) }) - ;(promise as any).cancel = cancelFn - return promise } @@ -1823,6 +1822,8 @@ describe('useInfiniteQuery', () => { await waitFor(() => rendered.getByText('off')) - expect(cancelFn).toHaveBeenCalled() + if (typeof AbortSignal === 'function') { + expect(cancelFn).toHaveBeenCalled() + } }) }) diff --git a/src/reactjs/tests/useQuery.test.tsx b/src/reactjs/tests/useQuery.test.tsx index a86778e14a..94d059f2c8 100644 --- a/src/reactjs/tests/useQuery.test.tsx +++ b/src/reactjs/tests/useQuery.test.tsx @@ -3895,14 +3895,13 @@ describe('useQuery', () => { const key = queryKey() let cancelFn: jest.Mock = jest.fn() - const queryFn = () => { + const queryFn = ({ signal }: { signal?: AbortSignal }) => { const promise = new Promise((resolve, reject) => { cancelFn = jest.fn(() => reject('Cancelled')) + signal?.addEventListener('abort', cancelFn) sleep(10).then(() => resolve('OK')) }) - ;(promise as any).cancel = cancelFn - return promise } @@ -3924,7 +3923,9 @@ describe('useQuery', () => { await waitFor(() => rendered.getByText('off')) - expect(cancelFn).toHaveBeenCalled() + if (typeof AbortSignal === 'function') { + expect(cancelFn).toHaveBeenCalled() + } }) it('should cancel the query if the signal was consumed and there are no more subscriptions', async () => { From b12bc2c2677581d383ca75d42488296ce38f1ddf Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Fri, 26 Nov 2021 21:28:36 +0100 Subject: [PATCH 11/19] remove test that doesn't make sense anymore - we don't allow different falsy query keys now --- src/core/tests/queriesObserver.test.tsx | 28 ------------------------- 1 file changed, 28 deletions(-) diff --git a/src/core/tests/queriesObserver.test.tsx b/src/core/tests/queriesObserver.test.tsx index 2cb59f0a12..c4e5739735 100644 --- a/src/core/tests/queriesObserver.test.tsx +++ b/src/core/tests/queriesObserver.test.tsx @@ -55,34 +55,6 @@ describe('queriesObserver', () => { expect(observerResult).toMatchObject([{ data: 1 }, { data: 2 }]) }) - test('should return same value for multiple falsy query keys', async () => { - const queryFn1 = jest.fn().mockReturnValue(1) - const queryFn2 = jest.fn().mockReturnValue(2) - const observer = new QueriesObserver(queryClient, [ - { queryKey: undefined, queryFn: queryFn1 }, - ]) - const results: QueryObserverResult[][] = [] - results.push(observer.getCurrentResult()) - const unsubscribe = observer.subscribe(result => { - results.push(result) - }) - await sleep(1) - observer.setQueries([ - { queryKey: undefined, queryFn: queryFn1 }, - { queryKey: '', queryFn: queryFn2 }, - ]) - await sleep(1) - unsubscribe() - expect(results.length).toBe(4) - expect(results[0]).toMatchObject([{ status: 'idle', data: undefined }]) - expect(results[1]).toMatchObject([{ status: 'loading', data: undefined }]) - expect(results[2]).toMatchObject([{ status: 'success', data: 1 }]) - expect(results[3]).toMatchObject([ - { status: 'success', data: 1 }, - { status: 'success', data: 1 }, - ]) - }) - test('should update when a query updates', async () => { const key1 = queryKey() const key2 = queryKey() From bc116d37defa4ee4bd03028a300612889314e36d Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Wed, 1 Dec 2021 23:21:59 +0100 Subject: [PATCH 12/19] 2927 offline queries (#3006) * feat(useQuery): offline queries remove defaultQueryObserverOptions because it is the same as defaultQueryOptions and we can just use that * feat(useQuery): offline queries setup dependent default values, to make it easier to work with them * feat(useQuery): offline queries basic changes to retryer: - pause the query before fetching depending upon networkMode - pause retries depending upon networkRetry * feat(useQuery): offline queries move networkRetry and networkMode defaults to the retryer creation, because we need the same for mutations * feat(useQuery): offline queries decouple focus and online manager: we're now informing caches of a focus when we're focussed, and about an online event if we come online; if the retryer continues, it can then decide to not fetch depending on our networkMode * feat(useQuery): offline queries expose isPaused on the queryResult and make sure isFetching is false when we are paused * feat(useQuery): offline queries knowing if we can fetch depends on if we are paused or not, as other conditions should apply also, rename options (not sure if that will stick though) * feat(useQuery): offline queries adjust existing tests for isPaused being exposed * feat(useQuery): offline queries fix existing test by setting options to emulate the previous behaviour, otherwise, with `mockNavigatorOnline` being set to false right from the start, the mutation would never fire off * feat(useQuery): offline queries adapt onOnline, onFocus tests to new behavior: they are now decoupled, and onOnline is always called even when not focused and vice versa. The retryer should make sure to not continue fetching if necessary * feat(useQuery): offline queries first test for networkMode * feat(useQuery): offline queries isFetching and isPaused are now derived and stored together in a fetchingState enum (idle, fetching, paused) * feat(useQuery): offline queries better networkMode api: online, always, offlineFirst (basically always but with paused retries) * feat(useQuery): offline queries more tests for networkMode: online * feat(useQuery): offline queries more tests for networkMode: online * feat(useQuery): offline queries tests for networkMode: always * feat(useQuery): offline queries fix tests that were influencing each other by using proper jest mocks for online and visibility state * add paused queries to the devtools.tsx * feat(useQuery): offline queries never stop pausing when continueFn is called. Initially, I only had this guard for when it's called from the outside, e.g. for onWindowFocus while still being offline, but we need this always because otherwise query cancellation can potentially continue a paused query * feat(useQuery): offline queries okay, pausing multiple times was a bad idea, continueFn() will be called eventually anyways * feat(useQuery): offline queries attempt at offline toggle button * feat(useQuery): offline queries different icons, padding, color * feat(useQuery): offline queries i messed up the icon order * feat(useQuery): offline queries guard against illegal state transitions: paused queries can unmount or get cancelled, in which case we shouldn't continue them, even if we dispatch the continue event * feat(useQuery): offline queries fix devtools tests, account for paused queries * Revert "feat(useQuery): offline queries" This reverts commit a647f64a051ca4c02a872e7871b4b2ce49aeda2c. * feat(useQuery): offline queries keep the do-not-start logic out of the run function, and thus out of promiseOrValue. if the promise has already been resolved in the meantime, e.g. because of a `cancel`, the run method will just do nothing, while the previous logic would've started to fetch * feat(useQuery): offline queries show inactive as higher priority than paused * feat(useQuery): offline queries make sure that optimistic results don't show an intermediate fetching state, but go opmistically to paused instead * feat(useQuery): offline queries real result needs to match optimistic result * feat(useQuery): offline queries stupid mistake * feat(useQuery): offline queries keep status color and status label in sync * feat(useQuery): offline queries make networkMode param mandatory for canFetch (and default to online internally) so that we can't screw that up again * feat(useQuery): offline queries make sure test "finishes" to avoid prints to the console if another test goes online again * feat(useQuery): offline queries move cancel function to the top, as it's no longer dependent on the promise since the `.cancel` function is gone; all we need is to abort the signal and reject the promise of the retryer * feat(useQuery): offline queries inline canContinue, because it's now only called if the query is in paused state anyways * feat(useQuery): offline queries avoid the impossible state by not calling config.onContinue for already resolved queries, as that would put them right into fetching state again, without actually fetching * feat(useQuery): offline queries let resolved querie continue, but don't put them in fetching state * feat(useQuery): offline queries fix merge conflict and invert condition because no-negated-condition * feat(useQuery): offline queries add test for abort signal consumed - different results expected for node < 15 where we don't have AbortController, thus can't consume the signal * feat(useQuery): offline queries online queries should not fetch if paused and we go online when cancelled and no refetchOnReconnect * feat(useQuery): offline queries gc test * feat(useQuery): offline queries offlineFirst test * feat(useQuery): offline queries mock useMediaQuery to get rid of unnecessary check in devtools - if window is defined, `matchMedia` is also defined * feat(useQuery): offline queries use a higher retryDelay to make test more stable, otherwise, it might start retrying before we "go offline" * feat(useQuery): offline queries improve devtools test: check if onClick props are being called * feat(useQuery): offline queries add devtools test for offline mock * feat(useQuery): offline queries offline mutations test * feat(useQuery): offline queries network mode docs (unfinished) * feat(useQuery): offline queries network mode docs * feat(useQuery): offline queries fix merge conflicts --- docs/src/manifests/manifest.json | 5 + .../guides/migrating-to-react-query-4.md | 43 +- docs/src/pages/guides/network-mode.md | 46 + docs/src/pages/reference/useMutation.md | 8 + docs/src/pages/reference/useQuery.md | 19 +- src/core/infiniteQueryObserver.ts | 5 +- src/core/mutation.ts | 1 + src/core/queriesObserver.ts | 4 +- src/core/query.ts | 39 +- src/core/queryClient.ts | 67 +- src/core/queryObserver.ts | 36 +- src/core/retryer.ts | 61 +- src/core/tests/hydration.test.tsx | 5 +- src/core/tests/query.test.tsx | 12 +- src/core/tests/queryClient.test.tsx | 47 +- src/core/types.ts | 21 +- src/devtools/devtools.tsx | 84 +- src/devtools/tests/devtools.test.tsx | 89 +- src/devtools/theme.tsx | 1 + src/devtools/useMediaQuery.ts | 6 +- src/devtools/utils.ts | 8 +- src/reactjs/tests/useInfiniteQuery.test.tsx | 4 + src/reactjs/tests/useMutation.test.tsx | 72 +- src/reactjs/tests/useQuery.test.tsx | 809 +++++++++++++++++- src/reactjs/tests/utils.tsx | 12 +- src/reactjs/types.ts | 2 + src/reactjs/useBaseQuery.ts | 8 +- src/reactjs/useMutation.ts | 3 +- src/reactjs/useQueries.ts | 8 +- src/reactjs/utils.ts | 9 +- 30 files changed, 1355 insertions(+), 179 deletions(-) create mode 100644 docs/src/pages/guides/network-mode.md diff --git a/docs/src/manifests/manifest.json b/docs/src/manifests/manifest.json index c0347bf931..fd3cdf8274 100644 --- a/docs/src/manifests/manifest.json +++ b/docs/src/manifests/manifest.json @@ -75,6 +75,11 @@ "path": "/guides/query-functions", "editUrl": "/guides/query-functions.md" }, + { + "title": "Network Mode", + "path": "/guides/network-mode", + "editUrl": "/guides/network-mode.md" + }, { "title": "Parallel Queries", "path": "/guides/parallel-queries", diff --git a/docs/src/pages/guides/migrating-to-react-query-4.md b/docs/src/pages/guides/migrating-to-react-query-4.md index 99a930bf08..5656c353d1 100644 --- a/docs/src/pages/guides/migrating-to-react-query-4.md +++ b/docs/src/pages/guides/migrating-to-react-query-4.md @@ -20,7 +20,7 @@ To streamline all apis, we've decided to make all keys Arrays only: ### Separate hydration exports have been removed -With version [3.22.0](https://github.com/tannerlinsley/react-query/releases/tag/v3.22.0), hydration utilities moved into the react-query core. With v3, you could still use the old exports from `react-query/hydration`, but these exports have been removed with v4. +With version [3.22.0](https://github.com/tannerlinsley/react-query/releases/tag/v3.22.0), hydration utilities moved into the React Query core. With v3, you could still use the old exports from `react-query/hydration`, but these exports have been removed with v4. ```diff - import { dehydrate, hydrate, useHydrate, Hydrate } from 'react-query/hydration' @@ -29,7 +29,7 @@ With version [3.22.0](https://github.com/tannerlinsley/react-query/releases/tag/ ### `notifyOnChangeProps` property no longer accepts `"tracked"` as a value -The `notifyOnChangeProps` option no longer accepts a `"tracked"` value. Instead, `useQuery` defaults to tracking properties. All queries using `notifyOnChangeProps: "tracked"` should be updated by removing this option. +The `notifyOnChangeProps` option no longer accepts a `"tracked"` value. Instead, `useQuery` defaults to tracking properties. All queries using `notifyOnChangeProps: "tracked"` should be updated by removing this option. If you would like to bypass this in any queries to emulate the v3 default behavior of re-rendering whenever a query changes, `notifyOnChangeProps` now accepts an `"all"` value to opt-out of the default smart tracking optimization. @@ -140,7 +140,7 @@ The `MutationCacheNotifyEvent` uses the same types as the `QueryCacheNotifyEvent ### The `src/react` directory was renamed to `src/reactjs` -Previously, react-query had a directory named `react` which imported from the `react` module. This could cause problems with some Jest configurations, resulting in errors when running tests like: +Previously, React Query had a directory named `react` which imported from the `react` module. This could cause problems with some Jest configurations, resulting in errors when running tests like: ``` TypeError: Cannot read property 'createContext' of undefined @@ -161,7 +161,7 @@ This was confusing to many and also created infinite loops if `setQueryData` was Similar to `onError` and `onSettled`, the `onSuccess` callback is now tied to a request being made. No request -> no callback. -If you want to listen to changes of the `data` field, you can best do this with a `useEffect`, where `data` is part of the dependency Array. Since react-query ensures stable data through structural sharing, the effect will not execute with every background refetch, but only if something within data has changed: +If you want to listen to changes of the `data` field, you can best do this with a `useEffect`, where `data` is part of the dependency Array. Since React Query ensures stable data through structural sharing, the effect will not execute with every background refetch, but only if something within data has changed: ``` const { data } = useQuery({ queryKey, queryFn }) @@ -188,8 +188,43 @@ Since these plugins are no longer experimental, their import paths have also bee The [old `cancel` method](../guides/query-cancellation#old-cancel-function) that allowed you to define a `cancel` function on promises, which was then used by the library to support query cancellation, has been removed. We recommend to use the [newer API](../guides/query-cancellation) (introduced with v3.30.0) for query cancellation that uses the [`AbortController` API](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) internally and provides you with an [`AbortSignal` instance](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) for your query function to support query cancellation. +### Queries and mutations, per default, need network connection to run + +Please read the [New Features announcement](#proper-offline-support) about online / offline support, and also the dedicated page about [Network mode](../guides/network-mode) + +Even though React Query is an Async State Manager that can be used for anything that produces a Promise, it is most often used for data fetching in combination with data fetching libraries. That is why, per default, queries and mutations will be `paused` if there is no network connection. If you want to opt-in to the previous behavior, you can globally set `networkMode: offlineFirst` for both queries and mutations: + +```js +new QueryClient({ + defaultOptions: { + queries: { + networkMode: 'offlineFirst' + }, + mutations: { + networkmode: 'offlineFirst' + } + } +}) + +``` + ## New Features 🚀 +### Proper offline support + +In v3, React Query has always fired off queries and mutations, but then taken the assumption that if you want to retry it, you need to be connected to the internet. This has led to several confusing situations: + +- You are offline and mount a query - it goes to loading state, the request fails, and it stays in loading state until you go online again, even though it is not really fetching. +- Similarly, if you are offline and have retries turned off, your query will just fire and fail, and the query goes to error state. +- You are offline and want to fire off a query that doesn't necessarily need network connection (because you _can_ use React Query for something other than data fetching), but it fails for some other reason. That query will now be paused until you go online again. +- Window focus refetching didn't do anything at all if you were offline. + +With v4, React Query introduces a new `networkMode` to tackle all these issues. Please read the dedicated page about the new [Network mode](../guides/network-mode) for more information. + ### Mutation Cache Garbage Collection Mutations can now also be garbage collected automatically, just like queries. The default `cacheTime` for mutations is also set to 5 minutes. + +### Tracked Queries per default + +React Query defaults to "tracking" query properties, which should give you a nice boost in render optimization. The feature has existed since [v3.6.0](https://github.com/tannerlinsley/react-query/releases/tag/v3.6.0) and has now become the default behavior with v4. diff --git a/docs/src/pages/guides/network-mode.md b/docs/src/pages/guides/network-mode.md new file mode 100644 index 0000000000..3a977ff2d2 --- /dev/null +++ b/docs/src/pages/guides/network-mode.md @@ -0,0 +1,46 @@ +--- +id: network-mode +title: Network Mode +--- + +React Query provides three different network modes to distinguish how [Queries](./queries) and [Mutations](./mutations) should behave if you have no network connection. This mode can be set for each Query / Mutation individually, or globally via the query / mutation defaults. + +Since React Query is most often used for data fetching in combination with data fetching libraries, the default network mode is [online](#network-mode-online). + +## Network Mode: online + +In this mode, Queries and Mutations will not fire unless you have network connection. This is the default mode. If a fetch is initiated for a query, it will always stay in the `state` (`loading`, `idle`, `error`, `success`) it is in if the fetch cannot be made because there is no network connection. However, a `fetchStatus` is exposed additionally. This can be either: + +- `fetching`: The `queryFn` is really executing - a request is in-flight. +- `paused`: The query is not executing - it is `paused` until you have connection again +- `idle`: The query is not fetching and not paused + +The flags `isFetching` and `isPaused` are derived from this state and exposed for convenience. + +> Keep in mind that it might not be enough to check for `loading` state to show a loading spinner. Queries can be in `state: 'loading'`, but `fetchStatus: 'paused'` if they are mounting for the first time, and you have no network connection. + +If a query runs because you are online, but you go offline while the fetch is still happening, React Query will also pause the retry mechanism. Paused queries will then continue to run once you re-gain network connection. This is independent of `refetchOnReconnect` (which also defaults to `true` in this mode), because it is not a `refetch`, but rather a `continue`. If the query has been [cancelled](./query-cancellation) in the meantime, it will not continue. + +## Network Mode: always + +In this mode, React Query will always fetch and ignore the online / offline state. This is likely the mode you want to choose if you use React Query in an environment where you don't need an active network connection for your Queries to work - e.g. if you just read from `AsyncStorage`, or if you just want to return `Promise.resolve(5)` from your `queryFn`. + +- Queries will never be `paused` because you have no network connection. +- Retries will also not pause - your Query will go to `error` state if it fails. +- `refetchOnReconnect` defaults to `false` in this mode, because reconnecting to the network is not a good indicator anymore that stale queries should be refetched. You can still turn it on if you want. + +## Network Mode: offlineFirst + +This mode is the middle ground between the first two options, where React Query will run the `queryFn` once, but then pause retries. This is very handy if you have a serviceWorker that intercepts a request for caching like in an [offline-first PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Offline_Service_workers), or if you use HTTP caching via the [Cache-Control header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#the_cache-control_header). + +In those situations, the first fetch might succeed because it comes from an offline storage / cache. However, if there is a cache miss, the network request will go out and fail, in which case this mode behaves like an `online` query - pausing retries. + +## Devtools + +The [React Query Devtools](../devtools) will show Queries in a `paused` state if they would be fetching, but there is no network connection. There is also a toggle button to _Mock offline behavior_. Please note that this button will _not_ actually mess with your network connection (you can do that in the browser devtools), but it will set the [OnlineManager](../reference/onlineManager) in an offline state. + +## Signature + +- `networkMode: 'online' | 'always' | 'offlineFirst` + - optional + - defaults to `'online'` diff --git a/docs/src/pages/reference/useMutation.md b/docs/src/pages/reference/useMutation.md index 6f4571dcbb..07d82110ca 100644 --- a/docs/src/pages/reference/useMutation.md +++ b/docs/src/pages/reference/useMutation.md @@ -19,6 +19,7 @@ const { } = useMutation(mutationFn, { cacheTime, mutationKey, + networkMode, onError, onMutate, onSettled, @@ -46,6 +47,10 @@ mutate(variables, { - `mutationKey: string` - Optional - A mutation key can be set to inherit defaults set with `queryClient.setMutationDefaults` or to identify the mutation in the devtools. +- `networkMode: 'online' | 'always' | 'offlineFirst` + - optional + - defaults to `'online'` + - see [Network Mode](../guides/network-mode) for more information. - `onMutate: (variables: TVariables) => Promise | TContext | void` - Optional - This function will fire before the mutation function is fired and is passed the same variables the mutation function would receive @@ -98,6 +103,9 @@ mutate(variables, { - `error` if the last mutation attempt resulted in an error. - `success` if the last mutation attempt was successful. - `isIdle`, `isLoading`, `isSuccess`, `isError`: boolean variables derived from `status` +- `isPaused: boolean` + - will be `true` if the mutation has been `paused` + - see [Network Mode](../guides/network-mode) for more information. - `data: undefined | unknown` - Defaults to `undefined` - The last successfully resolved data for the query. diff --git a/docs/src/pages/reference/useQuery.md b/docs/src/pages/reference/useQuery.md index 5afeee10ae..972a6fe419 100644 --- a/docs/src/pages/reference/useQuery.md +++ b/docs/src/pages/reference/useQuery.md @@ -14,6 +14,7 @@ const { isFetched, isFetchedAfterMount, isFetching, + isPaused, isIdle, isLoading, isLoadingError, @@ -26,11 +27,13 @@ const { refetch, remove, status, + fetchStatus, } = useQuery(queryKey, queryFn?, { cacheTime, enabled, + networkMode, initialData, - initialDataUpdatedAt + initialDataUpdatedAt, isDataEqual, keepPreviousData, meta, @@ -78,6 +81,10 @@ const result = useQuery({ - `enabled: boolean` - Set this to `false` to disable this query from automatically running. - Can be used for [Dependent Queries](../guides/dependent-queries). +- `networkMode: 'online' | 'always' | 'offlineFirst` + - optional + - defaults to `'online'` + - see [Network Mode](../guides/network-mode) for more information. - `retry: boolean | number | (failureCount: number, error: TError) => boolean` - If `false`, failed queries will not retry by default. - If `true`, failed queries will retry infinitely. @@ -218,9 +225,15 @@ const result = useQuery({ - `isFetchedAfterMount: boolean` - Will be `true` if the query has been fetched after the component mounted. - This property can be used to not show any previously cached data. +- `fetchStatus: FetchStatus` + - `fetching`: Is `true` whenever the queryFn is executing, which includes initial `loading` as well as background refetches. + - `paused`: The query wanted to fetch, but has been `paused` + - `idle`: The query is not fetching + - see [Network Mode](../guides/network-mode) for more information. - `isFetching: boolean` - - Is `true` whenever a request is in-flight, which includes initial `loading` as well as background refetches. - - Will be `true` if the query is currently fetching, including background fetching. + - A derived boolean from the `fetchStatus` variable above, provided for convenience. +- `isPaused: boolean` + - A derived boolean from the `fetchStatus` variable above, provided for convenience. - `isRefetching: boolean` - Is `true` whenever a background refetch is in-flight, which _does not_ include initial `loading` - Is the same as `isFetching && !isLoading` diff --git a/src/core/infiniteQueryObserver.ts b/src/core/infiniteQueryObserver.ts index 54d0833a7c..dc0a6a6021 100644 --- a/src/core/infiniteQueryObserver.ts +++ b/src/core/infiniteQueryObserver.ts @@ -133,9 +133,10 @@ export class InfiniteQueryObserver< hasNextPage: hasNextPage(options, state.data?.pages), hasPreviousPage: hasPreviousPage(options, state.data?.pages), isFetchingNextPage: - state.isFetching && state.fetchMeta?.fetchMore?.direction === 'forward', + state.fetchStatus === 'fetching' && + state.fetchMeta?.fetchMore?.direction === 'forward', isFetchingPreviousPage: - state.isFetching && + state.fetchStatus === 'fetching' && state.fetchMeta?.fetchMore?.direction === 'backward', } } diff --git a/src/core/mutation.ts b/src/core/mutation.ts index f44f9b50f6..fdab5538d9 100644 --- a/src/core/mutation.ts +++ b/src/core/mutation.ts @@ -277,6 +277,7 @@ export class Mutation< }, retry: this.options.retry ?? 0, retryDelay: this.options.retryDelay, + networkMode: this.options.networkMode, }) return this.retryer.promise diff --git a/src/core/queriesObserver.ts b/src/core/queriesObserver.ts index 1a32d51e6e..c7807015ea 100644 --- a/src/core/queriesObserver.ts +++ b/src/core/queriesObserver.ts @@ -74,7 +74,7 @@ export class QueriesObserver extends Subscribable { ): QueryObserverMatch[] { const prevObservers = this.observers const defaultedQueryOptions = queries.map(options => - this.client.defaultQueryObserverOptions(options) + this.client.defaultQueryOptions(options) ) const matchingObservers: QueryObserverMatch[] = defaultedQueryOptions.flatMap( @@ -125,7 +125,7 @@ export class QueriesObserver extends Subscribable { } private getObserver(options: QueryObserverOptions): QueryObserver { - const defaultedOptions = this.client.defaultQueryObserverOptions(options) + const defaultedOptions = this.client.defaultQueryOptions(options) const currentObserver = this.observersMap[defaultedOptions.queryHash!] return currentObserver ?? new QueryObserver(this.client, defaultedOptions) } diff --git a/src/core/query.ts b/src/core/query.ts index 8e589de4cc..4d30855b43 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -15,12 +15,13 @@ import type { QueryMeta, CancelOptions, SetDataOptions, + FetchStatus, } from './types' import type { QueryCache } from './queryCache' import type { QueryObserver } from './queryObserver' import { notifyManager } from './notifyManager' import { getLogger } from './logger' -import { Retryer, isCancelledError } from './retryer' +import { Retryer, isCancelledError, canFetch } from './retryer' import { Removable } from './removable' // TYPES @@ -49,10 +50,9 @@ export interface QueryState { errorUpdatedAt: number fetchFailureCount: number fetchMeta: any - isFetching: boolean isInvalidated: boolean - isPaused: boolean status: QueryStatus + fetchStatus: FetchStatus } export interface FetchContext< @@ -198,12 +198,10 @@ export class Query< protected optionalRemove() { if (!this.observers.length) { - if (this.state.isFetching) { - if (this.hadObservers) { - this.scheduleGc() - } - } else { + if (this.state.fetchStatus === 'idle') { this.cache.remove(this) + } else if (this.hadObservers) { + this.scheduleGc() } } } @@ -265,7 +263,7 @@ export class Query< } isFetching(): boolean { - return this.state.isFetching + return this.state.fetchStatus === 'fetching' } isStale(): boolean { @@ -358,7 +356,7 @@ export class Query< options?: QueryOptions, fetchOptions?: FetchOptions ): Promise { - if (this.state.isFetching) { + if (this.state.fetchStatus !== 'idle') { if (this.state.dataUpdatedAt && fetchOptions?.cancelRefetch) { // Silently cancel current fetch if the user wants to cancel refetches this.cancel({ silent: true }) @@ -441,7 +439,7 @@ export class Query< // Set to fetching state if not already in it if ( - !this.state.isFetching || + this.state.fetchStatus === 'idle' || this.state.fetchMeta !== context.fetchOptions?.meta ) { this.dispatch({ type: 'fetch', meta: context.fetchOptions?.meta }) @@ -495,6 +493,7 @@ export class Query< }, retry: context.options.retry, retryDelay: context.options.retryDelay, + networkMode: context.options.networkMode, }) this.promise = this.retryer.promise @@ -541,10 +540,9 @@ export class Query< errorUpdatedAt: 0, fetchFailureCount: 0, fetchMeta: null, - isFetching: false, isInvalidated: false, - isPaused: false, status: hasData ? 'success' : 'idle', + fetchStatus: 'idle', } } @@ -561,20 +559,21 @@ export class Query< case 'pause': return { ...state, - isPaused: true, + fetchStatus: 'paused', } case 'continue': return { ...state, - isPaused: false, + fetchStatus: 'fetching', } case 'fetch': return { ...state, fetchFailureCount: 0, fetchMeta: action.meta ?? null, - isFetching: true, - isPaused: false, + fetchStatus: canFetch(this.options.networkMode) + ? 'fetching' + : 'paused', status: !state.dataUpdatedAt ? 'loading' : state.status, } case 'success': @@ -585,9 +584,8 @@ export class Query< dataUpdatedAt: action.dataUpdatedAt ?? Date.now(), error: null, fetchFailureCount: 0, - isFetching: false, isInvalidated: false, - isPaused: false, + fetchStatus: 'idle', status: 'success', } case 'error': @@ -603,8 +601,7 @@ export class Query< errorUpdateCount: state.errorUpdateCount + 1, errorUpdatedAt: Date.now(), fetchFailureCount: state.fetchFailureCount + 1, - isFetching: false, - isPaused: false, + fetchStatus: 'idle', status: 'error', } case 'invalidate': diff --git a/src/core/queryClient.ts b/src/core/queryClient.ts index 2d837be6d1..e498cc106c 100644 --- a/src/core/queryClient.ts +++ b/src/core/queryClient.ts @@ -36,7 +36,7 @@ import { focusManager } from './focusManager' import { onlineManager } from './onlineManager' import { notifyManager } from './notifyManager' import { infiniteQueryBehavior } from './infiniteQueryBehavior' -import { CancelOptions } from './types' +import { CancelOptions, DefaultedQueryObserverOptions } from './types' // TYPES @@ -77,13 +77,13 @@ export class QueryClient { mount(): void { this.unsubscribeFocus = focusManager.subscribe(() => { - if (focusManager.isFocused() && onlineManager.isOnline()) { + if (focusManager.isFocused()) { this.mutationCache.onFocus() this.queryCache.onFocus() } }) this.unsubscribeOnline = onlineManager.subscribe(() => { - if (focusManager.isFocused() && onlineManager.isOnline()) { + if (onlineManager.isOnline()) { this.mutationCache.onOnline() this.queryCache.onOnline() } @@ -592,16 +592,30 @@ export class QueryClient { TQueryData, TQueryKey extends QueryKey >( - options?: QueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey - > - ): QueryObserverOptions { + options?: + | QueryObserverOptions + | DefaultedQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + > + ): DefaultedQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + > { if (options?._defaulted) { - return options + return options as DefaultedQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + > } const defaultedOptions = { @@ -609,13 +623,7 @@ export class QueryClient { ...this.getQueryDefaults(options?.queryKey), ...options, _defaulted: true, - } as QueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey - > + } if (!defaultedOptions.queryHash && defaultedOptions.queryKey) { defaultedOptions.queryHash = hashQueryKeyByOptions( @@ -624,25 +632,22 @@ export class QueryClient { ) } - return defaultedOptions - } + // dependent default values + if (typeof defaultedOptions.refetchOnReconnect === 'undefined') { + defaultedOptions.refetchOnReconnect = + defaultedOptions.networkMode !== 'always' + } + if (typeof defaultedOptions.useErrorBoundary === 'undefined') { + defaultedOptions.useErrorBoundary = !!defaultedOptions.suspense + } - defaultQueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey extends QueryKey - >( - options?: QueryObserverOptions< + return defaultedOptions as DefaultedQueryObserverOptions< TQueryFnData, TError, TData, TQueryData, TQueryKey > - ): QueryObserverOptions { - return this.defaultQueryOptions(options) } defaultMutationOptions>( diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index 0cc8d3b8c1..8e1a7b826c 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -1,4 +1,4 @@ -import { RefetchPageFilters } from './types' +import { DefaultedQueryObserverOptions, RefetchPageFilters } from './types' import { isServer, isValidTimeout, @@ -23,7 +23,7 @@ import type { QueryClient } from './queryClient' import { focusManager } from './focusManager' import { Subscribable } from './subscribable' import { getLogger } from './logger' -import { isCancelledError } from './retryer' +import { canFetch, isCancelledError } from './retryer' type QueryObserverListener = ( result: QueryObserverResult @@ -144,7 +144,7 @@ export class QueryObserver< const prevOptions = this.options const prevQuery = this.currentQuery - this.options = this.client.defaultQueryObserverOptions(options) + this.options = this.client.defaultQueryOptions(options) if ( typeof this.options.enabled !== 'undefined' && @@ -210,19 +210,11 @@ export class QueryObserver< TQueryKey > ): QueryObserverResult { - const defaultedOptions = this.client.defaultQueryObserverOptions(options) + const defaultedOptions = this.client.defaultQueryOptions(options) const query = this.client .getQueryCache() - .build( - this.client, - defaultedOptions as QueryOptions< - TQueryFnData, - TError, - TQueryData, - TQueryKey - > - ) + .build(this.client, defaultedOptions) return this.createResult(query, defaultedOptions) } @@ -233,7 +225,7 @@ export class QueryObserver< trackResult( result: QueryObserverResult, - defaultedOptions: QueryObserverOptions< + defaultedOptions: DefaultedQueryObserverOptions< TQueryFnData, TError, TData, @@ -260,7 +252,7 @@ export class QueryObserver< }) }) - if (defaultedOptions.useErrorBoundary || defaultedOptions.suspense) { + if (defaultedOptions.useErrorBoundary) { trackProp('error') } @@ -313,7 +305,7 @@ export class QueryObserver< TQueryKey > ): Promise> { - const defaultedOptions = this.client.defaultQueryObserverOptions(options) + const defaultedOptions = this.client.defaultQueryOptions(options) const query = this.client .getQueryCache() @@ -462,7 +454,7 @@ export class QueryObserver< : this.previousQueryResult const { state } = query - let { dataUpdatedAt, error, errorUpdatedAt, isFetching, status } = state + let { dataUpdatedAt, error, errorUpdatedAt, fetchStatus, status } = state let isPreviousData = false let isPlaceholderData = false let data: TData | undefined @@ -477,7 +469,9 @@ export class QueryObserver< mounted && shouldFetchOptionally(query, prevQuery, options, prevOptions) if (fetchOnMount || fetchOptionally) { - isFetching = true + fetchStatus = canFetch(query.options.networkMode) + ? 'fetching' + : 'paused' if (!dataUpdatedAt) { status = 'loading' } @@ -573,8 +567,11 @@ export class QueryObserver< } } + const isFetching = fetchStatus === 'fetching' + const result: QueryObserverBaseResult = { status, + fetchStatus, isLoading: status === 'loading', isSuccess: status === 'success', isError: status === 'error', @@ -588,9 +585,10 @@ export class QueryObserver< isFetchedAfterMount: state.dataUpdateCount > queryInitialState.dataUpdateCount || state.errorUpdateCount > queryInitialState.errorUpdateCount, - isFetching, + isFetching: isFetching, isRefetching: isFetching && status !== 'loading', isLoadingError: status === 'error' && state.dataUpdatedAt === 0, + isPaused: fetchStatus === 'paused', isPlaceholderData, isPreviousData, isRefetchError: status === 'error' && state.dataUpdatedAt !== 0, diff --git a/src/core/retryer.ts b/src/core/retryer.ts index 2d8575b072..537f1b28ab 100644 --- a/src/core/retryer.ts +++ b/src/core/retryer.ts @@ -1,7 +1,7 @@ import { focusManager } from './focusManager' import { onlineManager } from './onlineManager' import { sleep } from './utils' -import { CancelOptions } from './types' +import { CancelOptions, NetworkMode } from './types' // TYPES @@ -15,11 +15,12 @@ interface RetryerConfig { onContinue?: () => void retry?: RetryValue retryDelay?: RetryDelayValue + networkMode: NetworkMode | undefined } export type RetryValue = boolean | number | ShouldRetryFunction -type ShouldRetryFunction = ( +type ShouldRetryFunction = ( failureCount: number, error: TError ) => boolean @@ -34,6 +35,13 @@ type RetryDelayFunction = ( function defaultRetryDelay(failureCount: number) { return Math.min(1000 * 2 ** failureCount, 30000) } + +export function canFetch(networkMode: NetworkMode | undefined): boolean { + return (networkMode ?? 'online') === 'online' + ? onlineManager.isOnline() + : true +} + export class CancelledError { revert?: boolean silent?: boolean @@ -59,24 +67,34 @@ export class Retryer { isResolved: boolean promise: Promise - private abort?: () => void - constructor(config: RetryerConfig) { let cancelRetry = false - let cancelFn: ((options?: CancelOptions) => void) | undefined let continueFn: ((value?: unknown) => void) | undefined let promiseResolve: (data: TData) => void let promiseReject: (error: TError) => void - this.abort = config.abort - this.cancel = cancelOptions => cancelFn?.(cancelOptions) + this.cancel = (cancelOptions?: CancelOptions): void => { + if (!this.isResolved) { + reject(new CancelledError(cancelOptions)) + + config.abort?.() + } + } this.cancelRetry = () => { cancelRetry = true } + this.continueRetry = () => { cancelRetry = false } - this.continue = () => continueFn?.() + + const shouldPause = () => + !focusManager.isFocused() || + (config.networkMode !== 'always' && !onlineManager.isOnline()) + + this.continue = () => { + continueFn?.() + } this.failureCount = 0 this.isPaused = false this.isResolved = false @@ -105,13 +123,19 @@ export class Retryer { const pause = () => { return new Promise(continueResolve => { - continueFn = continueResolve + continueFn = value => { + if (this.isResolved || !shouldPause()) { + return continueResolve(value) + } + } this.isPaused = true config.onPause?.() }).then(() => { continueFn = undefined this.isPaused = false - config.onContinue?.() + if (!this.isResolved) { + config.onContinue?.() + } }) } @@ -131,15 +155,6 @@ export class Retryer { promiseOrValue = Promise.reject(error) } - // Create callback to cancel this fetch - cancelFn = cancelOptions => { - if (!this.isResolved) { - reject(new CancelledError(cancelOptions)) - - this.abort?.() - } - } - Promise.resolve(promiseOrValue) .then(resolve) .catch(error => { @@ -175,7 +190,7 @@ export class Retryer { sleep(delay) // Pause if the document is not visible or when the device is offline .then(() => { - if (!focusManager.isFocused() || !onlineManager.isOnline()) { + if (shouldPause()) { return pause() } }) @@ -190,6 +205,10 @@ export class Retryer { } // Start loop - run() + if (canFetch(config.networkMode)) { + run() + } else { + pause().then(run) + } } } diff --git a/src/core/tests/hydration.test.tsx b/src/core/tests/hydration.test.tsx index 29a44f1d26..85a4998012 100644 --- a/src/core/tests/hydration.test.tsx +++ b/src/core/tests/hydration.test.tsx @@ -265,7 +265,7 @@ describe('dehydration and rehydration', () => { test('should be able to dehydrate mutations and continue on hydration', async () => { const consoleMock = jest.spyOn(console, 'error') consoleMock.mockImplementation(() => undefined) - mockNavigatorOnLine(false) + const onlineMock = mockNavigatorOnLine(false) const serverAddTodo = jest .fn() @@ -302,7 +302,7 @@ describe('dehydration and rehydration', () => { // --- - mockNavigatorOnLine(true) + onlineMock.mockReturnValue(true) const parsed = JSON.parse(stringified) const client = new QueryClient() @@ -339,6 +339,7 @@ describe('dehydration and rehydration', () => { client.clear() consoleMock.mockRestore() + onlineMock.mockRestore() }) test('should not dehydrate mutations if dehydrateMutations is set to false', async () => { diff --git a/src/core/tests/query.test.tsx b/src/core/tests/query.test.tsx index 115071fbe5..21d3fe2888 100644 --- a/src/core/tests/query.test.tsx +++ b/src/core/tests/query.test.tsx @@ -46,10 +46,8 @@ describe('query', () => { it('should continue retry after focus regain and resolve all promises', async () => { const key = queryKey() - const originalVisibilityState = document.visibilityState - // make page unfocused - mockVisibilityState('hidden') + const visibilityMock = mockVisibilityState('hidden') let count = 0 let result @@ -83,7 +81,7 @@ describe('query', () => { expect(result).toBeUndefined() // Reset visibilityState to original value - mockVisibilityState(originalVisibilityState) + visibilityMock.mockRestore() window.dispatchEvent(new FocusEvent('focus')) // There should not be a result yet @@ -144,10 +142,8 @@ describe('query', () => { it('should throw a CancelledError when a paused query is cancelled', async () => { const key = queryKey() - const originalVisibilityState = document.visibilityState - // make page unfocused - mockVisibilityState('hidden') + const visibilityMock = mockVisibilityState('hidden') let count = 0 let result @@ -182,7 +178,7 @@ describe('query', () => { expect(isCancelledError(result)).toBe(true) // Reset visibilityState to original value - mockVisibilityState(originalVisibilityState) + visibilityMock.mockRestore() window.dispatchEvent(new FocusEvent('focus')) }) diff --git a/src/core/tests/queryClient.test.tsx b/src/core/tests/queryClient.test.tsx index ede24c9410..c6d9d58e7d 100644 --- a/src/core/tests/queryClient.test.tsx +++ b/src/core/tests/queryClient.test.tsx @@ -1228,7 +1228,7 @@ describe('queryClient', () => { }) describe('focusManager and onlineManager', () => { - test('should not notify queryCache and mutationCache if not focused or online', async () => { + test('should notify queryCache and mutationCache if focused', async () => { const testClient = new QueryClient() testClient.mount() @@ -1254,23 +1254,50 @@ describe('queryClient', () => { expect(mutationCacheOnFocusSpy).not.toHaveBeenCalled() focusManager.setFocused(true) - onlineManager.setOnline(false) + expect(queryCacheOnFocusSpy).toHaveBeenCalledTimes(1) + expect(queryCacheOnFocusSpy).toHaveBeenCalledTimes(1) + expect(queryCacheOnOnlineSpy).not.toHaveBeenCalled() expect(mutationCacheOnOnlineSpy).not.toHaveBeenCalled() - focusManager.setFocused(true) + queryCacheOnFocusSpy.mockRestore() + mutationCacheOnFocusSpy.mockRestore() + queryCacheOnOnlineSpy.mockRestore() + mutationCacheOnOnlineSpy.mockRestore() + }) + + test('should notify queryCache and mutationCache if online', async () => { + const testClient = new QueryClient() + testClient.mount() + + const queryCacheOnFocusSpy = jest.spyOn( + testClient.getQueryCache(), + 'onFocus' + ) + const queryCacheOnOnlineSpy = jest.spyOn( + testClient.getQueryCache(), + 'onOnline' + ) + const mutationCacheOnFocusSpy = jest.spyOn( + testClient.getMutationCache(), + 'onFocus' + ) + const mutationCacheOnOnlineSpy = jest.spyOn( + testClient.getMutationCache(), + 'onOnline' + ) + onlineManager.setOnline(false) expect(queryCacheOnOnlineSpy).not.toHaveBeenCalled() - expect(mutationCacheOnOnlineSpy).not.toHaveBeenCalled() - - focusManager.setFocused(false) - onlineManager.setOnline(true) expect(queryCacheOnOnlineSpy).not.toHaveBeenCalled() - expect(mutationCacheOnOnlineSpy).not.toHaveBeenCalled() - testClient.unmount() onlineManager.setOnline(true) - focusManager.setFocused(true) + expect(queryCacheOnOnlineSpy).toHaveBeenCalledTimes(1) + expect(queryCacheOnOnlineSpy).toHaveBeenCalledTimes(1) + + expect(mutationCacheOnFocusSpy).not.toHaveBeenCalled() + expect(mutationCacheOnFocusSpy).not.toHaveBeenCalled() + queryCacheOnFocusSpy.mockRestore() mutationCacheOnFocusSpy.mockRestore() queryCacheOnOnlineSpy.mockRestore() diff --git a/src/core/types.ts b/src/core/types.ts index e9bf309ae4..e57e06d79d 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -44,6 +44,8 @@ export interface InfiniteData { export type QueryMeta = Record +export type NetworkMode = 'online' | 'always' | 'offlineFirst' + export interface QueryOptions< TQueryFnData = unknown, TError = unknown, @@ -58,6 +60,7 @@ export interface QueryOptions< */ retry?: RetryValue retryDelay?: RetryDelayValue + networkMode?: NetworkMode cacheTime?: number isDataEqual?: (oldData: TData | undefined, newData: TData) => boolean queryFn?: QueryFunction @@ -136,7 +139,7 @@ export interface QueryObserverOptions< * If set to `true`, the query will refetch on reconnect if the data is stale. * If set to `false`, the query will not refetch on reconnect. * If set to `'always'`, the query will always refetch on reconnect. - * Defaults to `true`. + * Defaults to the value of `networkOnline` (`true`) */ refetchOnReconnect?: boolean | 'always' /** @@ -205,6 +208,18 @@ export interface QueryObserverOptions< optimisticResults?: boolean } +type WithRequired = Omit & Required> +export type DefaultedQueryObserverOptions< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey +> = WithRequired< + QueryObserverOptions, + 'useErrorBoundary' | 'refetchOnReconnect' +> + export interface InfiniteQueryObserverOptions< TQueryFnData = unknown, TError = unknown, @@ -288,6 +303,7 @@ export interface FetchPreviousPageOptions extends ResultOptions { } export type QueryStatus = 'idle' | 'loading' | 'error' | 'success' +export type FetchStatus = 'fetching' | 'paused' | 'idle' export interface QueryObserverBaseResult { data: TData | undefined @@ -302,6 +318,7 @@ export interface QueryObserverBaseResult { isIdle: boolean isLoading: boolean isLoadingError: boolean + isPaused: boolean isPlaceholderData: boolean isPreviousData: boolean isRefetchError: boolean @@ -313,6 +330,7 @@ export interface QueryObserverBaseResult { ) => Promise> remove: () => void status: QueryStatus + fetchStatus: FetchStatus } export interface QueryObserverIdleResult @@ -529,6 +547,7 @@ export interface MutationOptions< ) => Promise | void retry?: RetryValue retryDelay?: RetryDelayValue + networkMode?: NetworkMode cacheTime?: number _defaulted?: boolean meta?: MutationMeta diff --git a/src/devtools/devtools.tsx b/src/devtools/devtools.tsx index 00a226de10..7476205a77 100644 --- a/src/devtools/devtools.tsx +++ b/src/devtools/devtools.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { Query, useQueryClient } from 'react-query' +import { Query, useQueryClient, onlineManager } from 'react-query' import { matchSorter } from 'match-sorter' import useLocalStorage from './useLocalStorage' import { useIsMounted, useSafeState } from './utils' @@ -275,7 +275,7 @@ export function ReactQueryDevtools({ {...(otherCloseButtonProps as unknown)} onClick={e => { setIsOpen(false) - onCloseClick && onCloseClick(e) + onCloseClick?.(e) }} style={{ position: 'fixed', @@ -314,7 +314,7 @@ export function ReactQueryDevtools({ aria-expanded="false" onClick={e => { setIsOpen(true) - onToggleClick && onToggleClick(e) + onToggleClick?.(e) }} style={{ background: 'none', @@ -357,7 +357,13 @@ export function ReactQueryDevtools({ } const getStatusRank = (q: Query) => - q.state.isFetching ? 0 : !q.getObserversCount() ? 3 : q.isStale() ? 2 : 1 + q.state.fetchStatus !== 'idle' + ? 0 + : !q.getObserversCount() + ? 3 + : q.isStale() + ? 2 + : 1 const sortFns: Record number> = { 'Status > Last Updated': (a, b) => @@ -433,6 +439,8 @@ export const ReactQueryDevtoolsPanel = React.forwardRef< .length const hasFetching = queries.filter(q => getQueryStatusLabel(q) === 'fetching') .length + const hasPaused = queries.filter(q => getQueryStatusLabel(q) === 'paused') + .length const hasStale = queries.filter(q => getQueryStatusLabel(q) === 'stale') .length const hasInactive = queries.filter(q => getQueryStatusLabel(q) === 'inactive') @@ -457,6 +465,8 @@ export const ReactQueryDevtoolsPanel = React.forwardRef< promise?.catch(noop) } + const [isMockOffline, setMockOffline] = React.useState(false) + return ( fetching ({hasFetching})
{' '} + + paused ({hasPaused}) + {' '} setSortDesc(old => !old)} style={{ padding: '.3em .4em', + marginRight: '.5em', }} > {sortDesc ? '⬇ Desc' : '⬆ Asc'} + ) : null}
@@ -799,7 +873,7 @@ export const ReactQueryDevtoolsPanel = React.forwardRef< +
+ error:{' '} + {mutation.error instanceof Error ? mutation.error.message : 'null'}, + status: {mutation.status}, isPaused: {String(mutation.isPaused)} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => { + expect( + rendered.getByText('error: null, status: idle, isPaused: false') + ).toBeInTheDocument() + }) + + rendered.getByRole('button', { name: /mutate/i }).click() + + await waitFor(() => { + expect( + rendered.getByText('error: null, status: loading, isPaused: true') + ).toBeInTheDocument() + }) + + expect(count).toBe(0) + + onlineMock.mockReturnValue(true) + window.dispatchEvent(new Event('online')) + + await sleep(100) + + await waitFor(() => { + expect( + rendered.getByText('error: oops, status: error, isPaused: false') + ).toBeInTheDocument() + }) + + expect(count).toBe(2) + + consoleMock.mockRestore() + onlineMock.mockRestore() + }) + it('should be able to retry a mutation when online', async () => { const consoleMock = mockConsoleError() - mockNavigatorOnLine(false) + const onlineMock = mockNavigatorOnLine(false) let count = 0 const states: UseMutationResult[] = [] @@ -395,6 +461,7 @@ describe('useMutation', () => { { retry: 1, retryDelay: 5, + networkMode: 'offlineFirst', } ) @@ -437,7 +504,7 @@ describe('useMutation', () => { failureCount: 1, }) - mockNavigatorOnLine(true) + onlineMock.mockReturnValue(true) window.dispatchEvent(new Event('online')) await sleep(50) @@ -456,6 +523,7 @@ describe('useMutation', () => { }) consoleMock.mockRestore() + onlineMock.mockRestore() }) it('should not change state if unmounted', async () => { diff --git a/src/reactjs/tests/useQuery.test.tsx b/src/reactjs/tests/useQuery.test.tsx index 63f7a4fb24..b6e8a01614 100644 --- a/src/reactjs/tests/useQuery.test.tsx +++ b/src/reactjs/tests/useQuery.test.tsx @@ -1,4 +1,5 @@ import { act, waitFor, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' import React from 'react' import { @@ -10,6 +11,7 @@ import { renderWithClient, setActTimeout, Blink, + mockNavigatorOnLine, } from './utils' import { useQuery, @@ -176,6 +178,7 @@ describe('useQuery', () => { isFetched: false, isFetchedAfterMount: false, isFetching: true, + isPaused: false, isIdle: false, isLoading: true, isLoadingError: false, @@ -188,6 +191,7 @@ describe('useQuery', () => { refetch: expect.any(Function), remove: expect.any(Function), status: 'loading', + fetchStatus: 'fetching', }) expect(states[1]).toEqual({ @@ -200,6 +204,7 @@ describe('useQuery', () => { isFetched: true, isFetchedAfterMount: true, isFetching: false, + isPaused: false, isIdle: false, isLoading: false, isLoadingError: false, @@ -212,6 +217,7 @@ describe('useQuery', () => { refetch: expect.any(Function), remove: expect.any(Function), status: 'success', + fetchStatus: 'idle', }) }) @@ -255,6 +261,7 @@ describe('useQuery', () => { isFetched: false, isFetchedAfterMount: false, isFetching: true, + isPaused: false, isIdle: false, isLoading: true, isLoadingError: false, @@ -267,6 +274,7 @@ describe('useQuery', () => { refetch: expect.any(Function), remove: expect.any(Function), status: 'loading', + fetchStatus: 'fetching', }) expect(states[1]).toEqual({ @@ -279,6 +287,7 @@ describe('useQuery', () => { isFetched: false, isFetchedAfterMount: false, isFetching: true, + isPaused: false, isIdle: false, isLoading: true, isLoadingError: false, @@ -291,6 +300,7 @@ describe('useQuery', () => { refetch: expect.any(Function), remove: expect.any(Function), status: 'loading', + fetchStatus: 'fetching', }) expect(states[2]).toEqual({ @@ -303,6 +313,7 @@ describe('useQuery', () => { isFetched: true, isFetchedAfterMount: true, isFetching: false, + isPaused: false, isIdle: false, isLoading: false, isLoadingError: true, @@ -315,6 +326,7 @@ describe('useQuery', () => { refetch: expect.any(Function), remove: expect.any(Function), status: 'error', + fetchStatus: 'idle', }) consoleMock.mockRestore() @@ -3137,10 +3149,8 @@ describe('useQuery', () => { const consoleMock = mockConsoleError() - const originalVisibilityState = document.visibilityState - // make page unfocused - mockVisibilityState('hidden') + const visibilityMock = mockVisibilityState('hidden') let count = 0 @@ -3179,7 +3189,7 @@ describe('useQuery', () => { act(() => { // reset visibilityState to original value - mockVisibilityState(originalVisibilityState) + visibilityMock.mockRestore() window.dispatchEvent(new FocusEvent('focus')) }) @@ -3235,8 +3245,7 @@ describe('useQuery', () => { const consoleMock = mockConsoleError() // make page unfocused - const originalVisibilityState = document.visibilityState - mockVisibilityState('hidden') + const visibilityMock = mockVisibilityState('hidden') // set data in cache to check if the hook query fn is actually called queryClient.setQueryData(key, 'prefetched') @@ -3256,7 +3265,7 @@ describe('useQuery', () => { act(() => { // reset visibilityState to original value - mockVisibilityState(originalVisibilityState) + visibilityMock.mockRestore() window.dispatchEvent(new FocusEvent('focus')) }) @@ -4504,4 +4513,790 @@ describe('useQuery', () => { consoleMock.mockRestore() }) + + describe('networkMode online', () => { + it('online queries should not start fetching if you are offline', async () => { + const onlineMock = mockNavigatorOnLine(false) + + const key = queryKey() + const states: Array = [] + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return 'data' + }, + }) + + React.useEffect(() => { + states.push(state.fetchStatus) + }) + + return ( +
+
+ status: {state.status}, isPaused: {String(state.isPaused)} +
+
data: {state.data}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => rendered.getByText('status: loading, isPaused: true')) + + onlineMock.mockReturnValue(true) + window.dispatchEvent(new Event('online')) + + await waitFor(() => + rendered.getByText('status: success, isPaused: false') + ) + await waitFor(() => { + expect(rendered.getByText('data: data')).toBeInTheDocument() + }) + + expect(states).toEqual(['paused', 'fetching', 'idle']) + + onlineMock.mockRestore() + }) + + it('online queries should not refetch if you are offline', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return 'data' + count + }, + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus}, + failureCount: {state.failureCount} +
+
data: {state.data}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => rendered.getByText('data: data1')) + + const onlineMock = mockNavigatorOnLine(false) + rendered.getByRole('button', { name: /invalidate/i }).click() + + await waitFor(() => + rendered.getByText( + 'status: success, fetchStatus: paused, failureCount: 0' + ) + ) + + onlineMock.mockReturnValue(true) + window.dispatchEvent(new Event('online')) + + await waitFor(() => + rendered.getByText( + 'status: success, fetchStatus: fetching, failureCount: 0' + ) + ) + await waitFor(() => + rendered.getByText( + 'status: success, fetchStatus: idle, failureCount: 0' + ) + ) + + await waitFor(() => { + expect(rendered.getByText('data: data2')).toBeInTheDocument() + }) + + onlineMock.mockRestore() + }) + + it('online queries should not refetch if you are offline and refocus', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return 'data' + count + }, + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus} +
+
data: {state.data}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => rendered.getByText('data: data1')) + + const onlineMock = mockNavigatorOnLine(false) + rendered.getByRole('button', { name: /invalidate/i }).click() + + await waitFor(() => + rendered.getByText('status: success, fetchStatus: paused') + ) + + window.dispatchEvent(new FocusEvent('focus')) + await sleep(15) + + await waitFor(() => + expect(rendered.queryByText('data: data2')).not.toBeInTheDocument() + ) + expect(count).toBe(1) + onlineMock.mockRestore() + }) + + it('online queries should not refetch while already paused', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return 'data' + count + }, + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus} +
+
data: {state.data}
+ +
+ ) + } + + const onlineMock = mockNavigatorOnLine(false) + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => + rendered.getByText('status: loading, fetchStatus: paused') + ) + + rendered.getByRole('button', { name: /invalidate/i }).click() + + await sleep(15) + + // invalidation should not trigger a refetch + await waitFor(() => + rendered.getByText('status: loading, fetchStatus: paused') + ) + + expect(count).toBe(0) + onlineMock.mockRestore() + }) + + it('online queries should not refetch while already paused if data is in the cache', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return 'data' + count + }, + initialData: 'initial', + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus} +
+
data: {state.data}
+ +
+ ) + } + + const onlineMock = mockNavigatorOnLine(false) + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => + rendered.getByText('status: success, fetchStatus: paused') + ) + await waitFor(() => { + expect(rendered.getByText('data: initial')).toBeInTheDocument() + }) + + rendered.getByRole('button', { name: /invalidate/i }).click() + + await sleep(15) + + // invalidation should not trigger a refetch + await waitFor(() => + rendered.getByText('status: success, fetchStatus: paused') + ) + + expect(count).toBe(0) + onlineMock.mockRestore() + }) + + it('online queries should not get stuck in fetching state when pausing multiple times', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return 'data' + count + }, + initialData: 'initial', + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus} +
+
data: {state.data}
+ +
+ ) + } + + const onlineMock = mockNavigatorOnLine(false) + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => + rendered.getByText('status: success, fetchStatus: paused') + ) + await waitFor(() => { + expect(rendered.getByText('data: initial')).toBeInTheDocument() + }) + + // triggers one pause + rendered.getByRole('button', { name: /invalidate/i }).click() + + await sleep(15) + + await waitFor(() => + rendered.getByText('status: success, fetchStatus: paused') + ) + + // triggers a second pause + act(() => { + window.dispatchEvent(new FocusEvent('focus')) + }) + + onlineMock.mockReturnValue(true) + act(() => { + window.dispatchEvent(new Event('online')) + }) + + await waitFor(() => + rendered.getByText('status: success, fetchStatus: idle') + ) + await waitFor(() => { + expect(rendered.getByText('data: data1')).toBeInTheDocument() + }) + + expect(count).toBe(1) + onlineMock.mockRestore() + }) + + it('online queries should pause retries if you are offline', async () => { + const key = queryKey() + const consoleMock = mockConsoleError() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + throw new Error('failed' + count) + }, + retry: 2, + retryDelay: 10, + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus}, + failureCount: {state.failureCount} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => + rendered.getByText( + 'status: loading, fetchStatus: fetching, failureCount: 1' + ) + ) + + const onlineMock = mockNavigatorOnLine(false) + + await sleep(20) + + await waitFor(() => + rendered.getByText( + 'status: loading, fetchStatus: paused, failureCount: 1' + ) + ) + + expect(count).toBe(1) + + onlineMock.mockReturnValue(true) + window.dispatchEvent(new Event('online')) + + await waitFor(() => + rendered.getByText('status: error, fetchStatus: idle, failureCount: 3') + ) + + expect(count).toBe(3) + + onlineMock.mockRestore() + consoleMock.mockRestore() + }) + + it('online queries should fetch if paused and we go online even if already unmounted (because not cancelled)', async () => { + const key = queryKey() + let count = 0 + + function Component() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return 'data' + count + }, + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus} +
+
data: {state.data}
+
+ ) + } + + function Page() { + const [show, setShow] = React.useState(true) + + return ( +
+ {show && } + +
+ ) + } + + const onlineMock = mockNavigatorOnLine(false) + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => + rendered.getByText('status: loading, fetchStatus: paused') + ) + + rendered.getByRole('button', { name: /hide/i }).click() + + onlineMock.mockReturnValue(true) + window.dispatchEvent(new Event('online')) + + await sleep(15) + + expect(queryClient.getQueryState(key)).toMatchObject({ + fetchStatus: 'idle', + status: 'success', + }) + + expect(count).toBe(1) + + onlineMock.mockRestore() + }) + + it('online queries should not fetch if paused and we go online when cancelled and no refetchOnReconnect', async () => { + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return 'data' + count + }, + refetchOnReconnect: false, + }) + + return ( +
+ +
+ status: {state.status}, fetchStatus: {state.fetchStatus} +
+
data: {state.data}
+
+ ) + } + + const onlineMock = mockNavigatorOnLine(false) + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => + rendered.getByText('status: loading, fetchStatus: paused') + ) + + rendered.getByRole('button', { name: /cancel/i }).click() + + await waitFor(() => rendered.getByText('status: idle, fetchStatus: idle')) + + expect(count).toBe(0) + + onlineMock.mockReturnValue(true) + window.dispatchEvent(new Event('online')) + + await sleep(15) + + await waitFor(() => rendered.getByText('status: idle, fetchStatus: idle')) + + expect(count).toBe(0) + + onlineMock.mockRestore() + }) + + it('online queries should not fetch if paused and we go online if already unmounted when signal consumed', async () => { + const key = queryKey() + let count = 0 + + function Component() { + const state = useQuery({ + queryKey: key, + queryFn: async ({ signal }) => { + count++ + await sleep(10) + return `${signal ? 'signal' : 'data'}${count}` + }, + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus} +
+
data: {state.data}
+
+ ) + } + + function Page() { + const [show, setShow] = React.useState(true) + + return ( +
+ {show && } + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => + rendered.getByText('status: success, fetchStatus: idle') + ) + + const onlineMock = mockNavigatorOnLine(false) + + rendered.getByRole('button', { name: /invalidate/i }).click() + + await waitFor(() => + rendered.getByText('status: success, fetchStatus: paused') + ) + + rendered.getByRole('button', { name: /hide/i }).click() + + onlineMock.mockReturnValue(true) + window.dispatchEvent(new Event('online')) + + await sleep(15) + + expect(queryClient.getQueryState(key)).toMatchObject({ + fetchStatus: 'idle', + status: 'success', + }) + expect(count).toBe(typeof AbortSignal === 'function' ? 1 : 2) + + onlineMock.mockRestore() + }) + + it('online queries with cacheTime:0 should not fetch if paused and then unmounted', async () => { + const key = queryKey() + let count = 0 + + function Component() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return 'data' + count + }, + cacheTime: 0, + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus} +
+
data: {state.data}
+
+ ) + } + + function Page() { + const [show, setShow] = React.useState(true) + + return ( +
+ {show && } + +
+ ) + } + + const onlineMock = mockNavigatorOnLine(false) + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => + rendered.getByText('status: loading, fetchStatus: paused') + ) + + rendered.getByRole('button', { name: /hide/i }).click() + + onlineMock.mockReturnValue(true) + window.dispatchEvent(new Event('online')) + + await sleep(15) + + expect(queryClient.getQueryState(key)).not.toBeDefined() + + expect(count).toBe(0) + + onlineMock.mockRestore() + }) + }) + + describe('networkMode always', () => { + it('always queries should start fetching even if you are offline', async () => { + const onlineMock = mockNavigatorOnLine(false) + + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return 'data ' + count + }, + networkMode: 'always', + }) + + return ( +
+
+ status: {state.status}, isPaused: {String(state.isPaused)} +
+
data: {state.data}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => + rendered.getByText('status: success, isPaused: false') + ) + + await waitFor(() => { + expect(rendered.getByText('data: data 1')).toBeInTheDocument() + }) + + onlineMock.mockRestore() + }) + + it('always queries should not pause retries', async () => { + const onlineMock = mockNavigatorOnLine(false) + const consoleMock = mockConsoleError() + + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + throw new Error('error ' + count) + }, + networkMode: 'always', + retry: 1, + retryDelay: 5, + }) + + return ( +
+
+ status: {state.status}, isPaused: {String(state.isPaused)} +
+
+ error: {state.error instanceof Error && state.error.message} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => rendered.getByText('status: error, isPaused: false')) + + await waitFor(() => { + expect(rendered.getByText('error: error 2')).toBeInTheDocument() + }) + + expect(count).toBe(2) + + consoleMock.mockRestore() + onlineMock.mockRestore() + }) + }) + + describe('networkMode offlineFirst', () => { + it('offlineFirst queries should start fetching if you are offline, but pause retries', async () => { + const consoleMock = mockConsoleError() + const onlineMock = mockNavigatorOnLine(false) + + const key = queryKey() + let count = 0 + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + throw new Error('failed' + count) + }, + retry: 2, + retryDelay: 1, + networkMode: 'offlineFirst', + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus}, + failureCount: {state.failureCount} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => + rendered.getByText( + 'status: loading, fetchStatus: paused, failureCount: 1' + ) + ) + + expect(count).toBe(1) + + onlineMock.mockReturnValue(true) + window.dispatchEvent(new Event('online')) + + await waitFor(() => + rendered.getByText('status: error, fetchStatus: idle, failureCount: 3') + ) + + expect(count).toBe(3) + + onlineMock.mockRestore() + consoleMock.mockRestore() + }) + }) }) diff --git a/src/reactjs/tests/utils.tsx b/src/reactjs/tests/utils.tsx index b594a1f117..4170d2ba41 100644 --- a/src/reactjs/tests/utils.tsx +++ b/src/reactjs/tests/utils.tsx @@ -16,18 +16,12 @@ export function renderWithClient(client: QueryClient, ui: React.ReactElement) { } } -export function mockVisibilityState(value: string) { - Object.defineProperty(document, 'visibilityState', { - value, - configurable: true, - }) +export function mockVisibilityState(value: VisibilityState) { + return jest.spyOn(document, 'visibilityState', 'get').mockReturnValue(value) } export function mockNavigatorOnLine(value: boolean) { - Object.defineProperty(navigator, 'onLine', { - value, - configurable: true, - }) + return jest.spyOn(navigator, 'onLine', 'get').mockReturnValue(value) } export function mockConsoleError() { diff --git a/src/reactjs/types.ts b/src/reactjs/types.ts index 79aa3d4507..cfaa1089af 100644 --- a/src/reactjs/types.ts +++ b/src/reactjs/types.ts @@ -10,6 +10,7 @@ import { MutationFunction, MutateOptions, MutationMeta, + NetworkMode, } from '../core/types' export interface UseBaseQueryOptions< @@ -97,6 +98,7 @@ export interface UseMutationOptions< ) => Promise | void retry?: RetryValue retryDelay?: RetryDelayValue + networkMode?: NetworkMode useErrorBoundary?: boolean | ((error: TError) => boolean) meta?: MutationMeta } diff --git a/src/reactjs/useBaseQuery.ts b/src/reactjs/useBaseQuery.ts index 6853467dca..5d79aea354 100644 --- a/src/reactjs/useBaseQuery.ts +++ b/src/reactjs/useBaseQuery.ts @@ -29,7 +29,7 @@ export function useBaseQuery< const queryClient = useQueryClient() const errorResetBoundary = useQueryErrorResetBoundary() - const defaultedOptions = queryClient.defaultQueryObserverOptions(options) + const defaultedOptions = queryClient.defaultQueryOptions(options) // Make sure results are optimistically set in fetching state before subscribing or updating options defaultedOptions.optimisticResults = true @@ -133,11 +133,7 @@ export function useBaseQuery< result.isError && !errorResetBoundary.isReset() && !result.isFetching && - shouldThrowError( - defaultedOptions.suspense, - defaultedOptions.useErrorBoundary, - result.error - ) + shouldThrowError(defaultedOptions.useErrorBoundary, result.error) ) { throw result.error } diff --git a/src/reactjs/useMutation.ts b/src/reactjs/useMutation.ts index 264950c080..5e48928726 100644 --- a/src/reactjs/useMutation.ts +++ b/src/reactjs/useMutation.ts @@ -115,8 +115,7 @@ export function useMutation< if ( currentResult.error && shouldThrowError( - undefined, - obsRef.current.options.useErrorBoundary, + !!obsRef.current.options.useErrorBoundary, currentResult.error ) ) { diff --git a/src/reactjs/useQueries.ts b/src/reactjs/useQueries.ts index cd2d5d0ad3..339f6a0937 100644 --- a/src/reactjs/useQueries.ts +++ b/src/reactjs/useQueries.ts @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import React from 'react' import { QueryFunction } from '../core/types' import { notifyManager } from '../core/notifyManager' @@ -118,12 +118,10 @@ export function useQueries( const queryClient = useQueryClient() - const defaultedQueries = useMemo( + const defaultedQueries = React.useMemo( () => queries.map(options => { - const defaultedOptions = queryClient.defaultQueryObserverOptions( - options - ) + const defaultedOptions = queryClient.defaultQueryOptions(options) // Make sure the results are already in fetching state before subscribing or updating options defaultedOptions.optimisticResults = true diff --git a/src/reactjs/utils.ts b/src/reactjs/utils.ts index e5850598e1..955afb6cf2 100644 --- a/src/reactjs/utils.ts +++ b/src/reactjs/utils.ts @@ -1,6 +1,5 @@ export function shouldThrowError( - suspense: boolean | undefined, - _useErrorBoundary: boolean | ((err: TError) => boolean) | undefined, + _useErrorBoundary: boolean | ((err: TError) => boolean), error: TError ): boolean { // Allow useErrorBoundary function to override throwing behavior on a per-error basis @@ -8,9 +7,5 @@ export function shouldThrowError( return _useErrorBoundary(error) } - // Allow useErrorBoundary to override suspense's throwing behavior - if (typeof _useErrorBoundary === 'boolean') return _useErrorBoundary - - // If suspense is enabled default to throwing errors - return !!suspense + return _useErrorBoundary } From 4b751081179e22d1302df0fdfc22fe57d79340b4 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Fri, 3 Dec 2021 09:33:12 +0100 Subject: [PATCH 13/19] refactor(queryClient): remove undocumented methods --- .../guides/migrating-to-react-query-4.md | 25 +++++++++++++ src/core/queryClient.ts | 18 ---------- src/core/tests/hydration.test.tsx | 36 +++++++++---------- src/core/tests/mutationCache.test.tsx | 23 +++++++----- src/core/tests/mutations.test.tsx | 13 ++++--- src/core/tests/queryClient.test.tsx | 22 ------------ src/reactjs/tests/utils.tsx | 9 ++++- 7 files changed, 73 insertions(+), 73 deletions(-) diff --git a/docs/src/pages/guides/migrating-to-react-query-4.md b/docs/src/pages/guides/migrating-to-react-query-4.md index 5656c353d1..14002bec89 100644 --- a/docs/src/pages/guides/migrating-to-react-query-4.md +++ b/docs/src/pages/guides/migrating-to-react-query-4.md @@ -205,7 +205,32 @@ new QueryClient({ } } }) +``` + +### Removed undocumented methods from the `queryClient` +The methods `cancelMutatations` and `executeMutation` were undocumented and unused internally, so we removed them. Since they were just wrappers around methods available on the `mutationCache`, you can still use the functionality. + +```diff +- cancelMutations(): Promise { +- const promises = notifyManager.batch(() => +- this.mutationCache.getAll().map(mutation => mutation.cancel()) +- ) +- return Promise.all(promises).then(noop).catch(noop) +- } +``` + +```diff +- executeMutation< +- TData = unknown, +- TError = unknown, +- TVariables = void, +- TContext = unknown +- >( +- options: MutationOptions +- ): Promise { +- return this.mutationCache.build(this, options).execute() +- } ``` ## New Features 🚀 diff --git a/src/core/queryClient.ts b/src/core/queryClient.ts index e498cc106c..c68b9efcd3 100644 --- a/src/core/queryClient.ts +++ b/src/core/queryClient.ts @@ -500,28 +500,10 @@ export class QueryClient { .catch(noop) } - cancelMutations(): Promise { - const promises = notifyManager.batch(() => - this.mutationCache.getAll().map(mutation => mutation.cancel()) - ) - return Promise.all(promises).then(noop).catch(noop) - } - resumePausedMutations(): Promise { return this.getMutationCache().resumePausedMutations() } - executeMutation< - TData = unknown, - TError = unknown, - TVariables = void, - TContext = unknown - >( - options: MutationOptions - ): Promise { - return this.mutationCache.build(this, options).execute() - } - getQueryCache(): QueryCache { return this.queryCache } diff --git a/src/core/tests/hydration.test.tsx b/src/core/tests/hydration.test.tsx index 85a4998012..77a5e58924 100644 --- a/src/core/tests/hydration.test.tsx +++ b/src/core/tests/hydration.test.tsx @@ -1,4 +1,8 @@ -import { mockNavigatorOnLine, sleep } from '../../reactjs/tests/utils' +import { + executeMutation, + mockNavigatorOnLine, + sleep, +} from '../../reactjs/tests/utils' import { QueryCache } from '../queryCache' import { QueryClient } from '../queryClient' import { dehydrate, hydrate } from '../hydration' @@ -286,12 +290,10 @@ describe('dehydration and rehydration', () => { retryDelay: 10, }) - serverClient - .executeMutation({ - mutationKey: ['addTodo'], - variables: { text: 'text' }, - }) - .catch(() => undefined) + executeMutation(serverClient, { + mutationKey: ['addTodo'], + variables: { text: 'text' }, + }).catch(() => undefined) await sleep(50) @@ -357,12 +359,10 @@ describe('dehydration and rehydration', () => { retry: false, }) - queryClient - .executeMutation({ - mutationKey: ['addTodo'], - variables: { text: 'text' }, - }) - .catch(() => undefined) + executeMutation(queryClient, { + mutationKey: ['addTodo'], + variables: { text: 'text' }, + }).catch(() => undefined) await sleep(1) const dehydrated = dehydrate(queryClient, { dehydrateMutations: false }) @@ -389,12 +389,10 @@ describe('dehydration and rehydration', () => { retryDelay: 20, }) - queryClient - .executeMutation({ - mutationKey: ['addTodo'], - variables: { text: 'text' }, - }) - .catch(() => undefined) + executeMutation(queryClient, { + mutationKey: ['addTodo'], + variables: { text: 'text' }, + }).catch(() => undefined) // Dehydrate mutation between retries await sleep(1) diff --git a/src/core/tests/mutationCache.test.tsx b/src/core/tests/mutationCache.test.tsx index 07817a0d1f..ff3ca5cea5 100644 --- a/src/core/tests/mutationCache.test.tsx +++ b/src/core/tests/mutationCache.test.tsx @@ -1,5 +1,10 @@ import { waitFor } from '@testing-library/react' -import { queryKey, mockConsoleError, sleep } from '../../reactjs/tests/utils' +import { + queryKey, + mockConsoleError, + sleep, + executeMutation, +} from '../../reactjs/tests/utils' import { MutationCache, MutationObserver, QueryClient } from '../..' describe('mutationCache', () => { @@ -12,7 +17,7 @@ describe('mutationCache', () => { const testClient = new QueryClient({ mutationCache: testCache }) try { - await testClient.executeMutation({ + await executeMutation(testClient, { mutationKey: key, variables: 'vars', mutationFn: () => Promise.reject('error'), @@ -35,7 +40,7 @@ describe('mutationCache', () => { const testClient = new QueryClient({ mutationCache: testCache }) try { - await testClient.executeMutation({ + await executeMutation(testClient, { mutationKey: key, variables: 'vars', mutationFn: () => Promise.resolve({ data: 5 }), @@ -63,7 +68,7 @@ describe('mutationCache', () => { const testClient = new QueryClient({ mutationCache: testCache }) try { - await testClient.executeMutation({ + await executeMutation(testClient, { mutationKey: key, variables: 'vars', mutationFn: () => Promise.resolve({ data: 5 }), @@ -83,7 +88,7 @@ describe('mutationCache', () => { const testCache = new MutationCache() const testClient = new QueryClient({ mutationCache: testCache }) const key = ['mutation', 'vars'] - await testClient.executeMutation({ + await executeMutation(testClient, { mutationKey: key, variables: 'vars', mutationFn: () => Promise.resolve(), @@ -104,17 +109,17 @@ describe('mutationCache', () => { test('should filter correctly', async () => { const testCache = new MutationCache() const testClient = new QueryClient({ mutationCache: testCache }) - await testClient.executeMutation({ + await executeMutation(testClient, { mutationKey: ['a', 1], variables: 1, mutationFn: () => Promise.resolve(), }) - await testClient.executeMutation({ + await executeMutation(testClient, { mutationKey: ['a', 2], variables: 2, mutationFn: () => Promise.resolve(), }) - await testClient.executeMutation({ + await executeMutation(testClient, { mutationKey: ['b'], mutationFn: () => Promise.resolve(), }) @@ -136,7 +141,7 @@ describe('mutationCache', () => { const testCache = new MutationCache() const testClient = new QueryClient({ mutationCache: testCache }) const onSuccess = jest.fn() - await testClient.executeMutation({ + await executeMutation(testClient, { mutationKey: ['a', 1], variables: 1, cacheTime: 10, diff --git a/src/core/tests/mutations.test.tsx b/src/core/tests/mutations.test.tsx index af2676e286..14260c38db 100644 --- a/src/core/tests/mutations.test.tsx +++ b/src/core/tests/mutations.test.tsx @@ -1,5 +1,10 @@ import { QueryClient } from '../..' -import { mockConsoleError, queryKey, sleep } from '../../reactjs/tests/utils' +import { + executeMutation, + mockConsoleError, + queryKey, + sleep, +} from '../../reactjs/tests/utils' import { MutationState } from '../mutation' import { MutationObserver } from '../mutationObserver' @@ -16,7 +21,7 @@ describe('mutations', () => { }) test('mutate should trigger a mutation', async () => { - const result = await queryClient.executeMutation({ + const result = await executeMutation(queryClient, { mutationFn: async (text: string) => text, variables: 'todo', }) @@ -48,7 +53,7 @@ describe('mutations', () => { mutationFn: async (text: string) => text, }) - const result = await queryClient.executeMutation({ + const result = await executeMutation(queryClient, { mutationKey: key, variables: 'todo', }) @@ -345,7 +350,7 @@ describe('mutations', () => { expect(currentMutation['observers'].length).toEqual(1) }) - test('executeMutation should throw an error if no mutationFn found', async () => { + test('mutate should throw an error if no mutationFn found', async () => { const consoleMock = mockConsoleError() const mutation = new MutationObserver(queryClient, { diff --git a/src/core/tests/queryClient.test.tsx b/src/core/tests/queryClient.test.tsx index c6d9d58e7d..68cc4e5671 100644 --- a/src/core/tests/queryClient.test.tsx +++ b/src/core/tests/queryClient.test.tsx @@ -15,7 +15,6 @@ import { QueryClient, QueryFunction, QueryObserver, - MutationObserver, } from '../..' import { focusManager, onlineManager } from '..' @@ -1305,27 +1304,6 @@ describe('queryClient', () => { }) }) - describe('cancelMutations', () => { - test('should cancel mutations', async () => { - const key = queryKey() - const mutationObserver = new MutationObserver(queryClient, { - mutationKey: key, - mutationFn: async () => { - await sleep(20) - return 'data' - }, - onMutate: text => text, - }) - await mutationObserver.mutate() - const mutation = queryClient - .getMutationCache() - .find({ mutationKey: key })! - const mutationSpy = jest.spyOn(mutation, 'cancel') - queryClient.cancelMutations() - expect(mutationSpy).toHaveBeenCalled() - mutationSpy.mockRestore() - }) - }) describe('setMutationDefaults', () => { test('should update existing mutation defaults', () => { const key = queryKey() diff --git a/src/reactjs/tests/utils.tsx b/src/reactjs/tests/utils.tsx index 4170d2ba41..39d41cb36b 100644 --- a/src/reactjs/tests/utils.tsx +++ b/src/reactjs/tests/utils.tsx @@ -1,7 +1,7 @@ import { act, render } from '@testing-library/react' import React from 'react' -import { QueryClient, QueryClientProvider } from '../..' +import { MutationOptions, QueryClient, QueryClientProvider } from '../..' export function renderWithClient(client: QueryClient, ui: React.ReactElement) { const { rerender, ...result } = render( @@ -77,3 +77,10 @@ export const Blink: React.FC<{ duration: number }> = ({ return shouldShow ? <>{children} : <>off } + +export const executeMutation = ( + queryClient: QueryClient, + options: MutationOptions +): Promise => { + return queryClient.getMutationCache().build(queryClient, options).execute() +} From f804b15c3612f882f8c546cff77b6015fc86ea8c Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 4 Dec 2021 15:09:24 +0100 Subject: [PATCH 14/19] fix: offline mutations fixes (#3051) * feat: offline mutations move reducer into Mutation class to avoid passing state (and options) around * feat: offline mutations optimistically set paused state depending on if we can fetch or not to avoid an intermediate state where we are loading but not paused --- src/core/mutation.ts | 117 ++++++++++++------------- src/reactjs/tests/useMutation.test.tsx | 95 ++++++++++++++++++++ 2 files changed, 153 insertions(+), 59 deletions(-) diff --git a/src/core/mutation.ts b/src/core/mutation.ts index ee5d778bf1..37c514a917 100644 --- a/src/core/mutation.ts +++ b/src/core/mutation.ts @@ -4,7 +4,7 @@ import type { MutationObserver } from './mutationObserver' import { getLogger } from './logger' import { notifyManager } from './notifyManager' import { Removable } from './removable' -import { Retryer } from './retryer' +import { canFetch, Retryer } from './retryer' import { noop } from './utils' // TYPES @@ -291,7 +291,7 @@ export class Mutation< } private dispatch(action: Action): void { - this.state = reducer(this.state, action) + this.state = this.reducer(action) notifyManager.batch(() => { this.observers.forEach(observer => { @@ -304,6 +304,62 @@ export class Mutation< }) }) } + + private reducer( + action: Action + ): MutationState { + switch (action.type) { + case 'failed': + return { + ...this.state, + failureCount: this.state.failureCount + 1, + } + case 'pause': + return { + ...this.state, + isPaused: true, + } + case 'continue': + return { + ...this.state, + isPaused: false, + } + case 'loading': + return { + ...this.state, + context: action.context, + data: undefined, + error: null, + isPaused: !canFetch(this.options.networkMode), + status: 'loading', + variables: action.variables, + } + case 'success': + return { + ...this.state, + data: action.data, + error: null, + status: 'success', + isPaused: false, + } + case 'error': + return { + ...this.state, + data: undefined, + error: action.error, + failureCount: this.state.failureCount + 1, + isPaused: false, + status: 'error', + } + case 'setState': + return { + ...this.state, + ...action.state, + } + default: + return this.state + } + } } export function getDefaultState< @@ -322,60 +378,3 @@ export function getDefaultState< variables: undefined, } } - -function reducer( - state: MutationState, - action: Action -): MutationState { - switch (action.type) { - case 'failed': - return { - ...state, - failureCount: state.failureCount + 1, - } - case 'pause': - return { - ...state, - isPaused: true, - } - case 'continue': - return { - ...state, - isPaused: false, - } - case 'loading': - return { - ...state, - context: action.context, - data: undefined, - error: null, - isPaused: false, - status: 'loading', - variables: action.variables, - } - case 'success': - return { - ...state, - data: action.data, - error: null, - status: 'success', - isPaused: false, - } - case 'error': - return { - ...state, - data: undefined, - error: action.error, - failureCount: state.failureCount + 1, - isPaused: false, - status: 'error', - } - case 'setState': - return { - ...state, - ...action.state, - } - default: - return state - } -} diff --git a/src/reactjs/tests/useMutation.test.tsx b/src/reactjs/tests/useMutation.test.tsx index 799be48033..cae6d06d4f 100644 --- a/src/reactjs/tests/useMutation.test.tsx +++ b/src/reactjs/tests/useMutation.test.tsx @@ -445,6 +445,101 @@ describe('useMutation', () => { onlineMock.mockRestore() }) + it('should call onMutate even if paused', async () => { + const onlineMock = mockNavigatorOnLine(false) + const onMutate = jest.fn() + let count = 0 + + function Page() { + const mutation = useMutation( + async (_text: string) => { + count++ + await sleep(10) + return count + }, + { + onMutate, + } + ) + + return ( +
+ +
+ data: {mutation.data ?? 'null'}, status: {mutation.status}, + isPaused: {String(mutation.isPaused)} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await rendered.findByText('data: null, status: idle, isPaused: false') + + rendered.getByRole('button', { name: /mutate/i }).click() + + await rendered.findByText('data: null, status: loading, isPaused: true') + + expect(onMutate).toHaveBeenCalledTimes(1) + expect(onMutate).toHaveBeenCalledWith('todo') + + onlineMock.mockReturnValue(true) + window.dispatchEvent(new Event('online')) + + await rendered.findByText('data: 1, status: success, isPaused: false') + + expect(onMutate).toHaveBeenCalledTimes(1) + expect(count).toBe(1) + + onlineMock.mockRestore() + }) + + it('should optimistically go to paused state if offline', async () => { + const onlineMock = mockNavigatorOnLine(false) + let count = 0 + const states: Array = [] + + function Page() { + const mutation = useMutation(async (_text: string) => { + count++ + await sleep(10) + return count + }) + + states.push(`${mutation.status}, ${mutation.isPaused}`) + + return ( +
+ +
+ data: {mutation.data ?? 'null'}, status: {mutation.status}, + isPaused: {String(mutation.isPaused)} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await rendered.findByText('data: null, status: idle, isPaused: false') + + rendered.getByRole('button', { name: /mutate/i }).click() + + await rendered.findByText('data: null, status: loading, isPaused: true') + + // no intermediate 'loading, false' state is expected because we don't start mutating! + expect(states[0]).toBe('idle, false') + expect(states[1]).toBe('loading, true') + + onlineMock.mockReturnValue(true) + window.dispatchEvent(new Event('online')) + + await rendered.findByText('data: 1, status: success, isPaused: false') + + onlineMock.mockRestore() + }) + it('should be able to retry a mutation when online', async () => { const consoleMock = mockConsoleError() const onlineMock = mockNavigatorOnLine(false) From 6985a499801e1241701db6c0b2624121dbc33bf7 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 4 Dec 2021 15:14:33 +0100 Subject: [PATCH 15/19] examples: fix query keys in basic examples because we need those for preview builds --- examples/basic-typescript/src/index.tsx | 2 +- examples/basic/src/index.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/basic-typescript/src/index.tsx b/examples/basic-typescript/src/index.tsx index 1b4c8e9b29..4a969879e7 100644 --- a/examples/basic-typescript/src/index.tsx +++ b/examples/basic-typescript/src/index.tsx @@ -20,7 +20,7 @@ type Post = { function usePosts() { return useQuery( - "posts", + ["posts"], async (): Promise> => { const { data } = await axios.get( "https://jsonplaceholder.typicode.com/posts" diff --git a/examples/basic/src/index.js b/examples/basic/src/index.js index 019d856875..638eef5425 100644 --- a/examples/basic/src/index.js +++ b/examples/basic/src/index.js @@ -53,7 +53,7 @@ function App() { } function usePosts() { - return useQuery("posts", async () => { + return useQuery(["posts"], async () => { const { data } = await axios.get( "https://jsonplaceholder.typicode.com/posts" ); From bd0f87ccd90adacca64068eb2aedd8bf75995b0c Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sun, 5 Dec 2021 09:15:06 +0100 Subject: [PATCH 16/19] fix(useMutation): make sure cacheCallbacks are always called even if the useMutation component unmounts and we have a cacheTime of 0; the fix was cherry-picked from the react-18 branch, where we also introduced this behavior --- src/core/mutation.ts | 2 +- src/core/queriesObserver.ts | 8 ++- src/reactjs/tests/useMutation.test.tsx | 74 ++++++++++++++++++++++++++ src/reactjs/types.ts | 1 + 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/core/mutation.ts b/src/core/mutation.ts index 37c514a917..cec82bee82 100644 --- a/src/core/mutation.ts +++ b/src/core/mutation.ts @@ -134,7 +134,7 @@ export class Mutation< if (this.cacheTime) { this.scheduleGc() } else { - this.mutationCache.remove(this) + this.optionalRemove() } this.mutationCache.notify({ diff --git a/src/core/queriesObserver.ts b/src/core/queriesObserver.ts index 257666db17..79d1bbc168 100644 --- a/src/core/queriesObserver.ts +++ b/src/core/queriesObserver.ts @@ -1,6 +1,10 @@ import { difference, replaceAt } from './utils' import { notifyManager } from './notifyManager' -import type { QueryObserverOptions, QueryObserverResult } from './types' +import type { + QueryObserverOptions, + QueryObserverResult, + DefaultedQueryObserverOptions, +} from './types' import type { QueryClient } from './queryClient' import { NotifyOptions, QueryObserver } from './queryObserver' import { Subscribable } from './subscribable' @@ -205,6 +209,6 @@ export class QueriesObserver extends Subscribable { } type QueryObserverMatch = { - defaultedQueryOptions: QueryObserverOptions + defaultedQueryOptions: DefaultedQueryObserverOptions observer: QueryObserver } diff --git a/src/reactjs/tests/useMutation.test.tsx b/src/reactjs/tests/useMutation.test.tsx index cae6d06d4f..b3b2e14981 100644 --- a/src/reactjs/tests/useMutation.test.tsx +++ b/src/reactjs/tests/useMutation.test.tsx @@ -746,4 +746,78 @@ describe('useMutation', () => { consoleMock.mockRestore() }) + + it('should call cache callbacks when unmounted', async () => { + const onSuccess = jest.fn() + const onSuccessMutate = jest.fn() + const onSettled = jest.fn() + const onSettledMutate = jest.fn() + const mutationKey = queryKey() + let count = 0 + + function Page() { + const [show, setShow] = React.useState(true) + return ( +
+ + {show && } +
+ ) + } + + function Component() { + const mutation = useMutation( + async (_text: string) => { + count++ + await sleep(10) + return count + }, + { + mutationKey, + cacheTime: 0, + onSuccess, + onSettled, + } + ) + + return ( +
+ +
+ data: {mutation.data ?? 'null'}, status: {mutation.status}, + isPaused: {String(mutation.isPaused)} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await rendered.findByText('data: null, status: idle, isPaused: false') + + rendered.getByRole('button', { name: /mutate/i }).click() + rendered.getByRole('button', { name: /hide/i }).click() + + await waitFor(() => { + expect( + queryClient.getMutationCache().findAll({ mutationKey }) + ).toHaveLength(0) + }) + + expect(count).toBe(1) + + expect(onSuccess).toHaveBeenCalledTimes(1) + expect(onSettled).toHaveBeenCalledTimes(1) + expect(onSuccessMutate).toHaveBeenCalledTimes(0) + expect(onSettledMutate).toHaveBeenCalledTimes(0) + }) }) diff --git a/src/reactjs/types.ts b/src/reactjs/types.ts index cfaa1089af..3d2079d110 100644 --- a/src/reactjs/types.ts +++ b/src/reactjs/types.ts @@ -77,6 +77,7 @@ export interface UseMutationOptions< > { mutationFn?: MutationFunction mutationKey?: MutationKey + cacheTime?: number onMutate?: ( variables: TVariables ) => Promise | TContext | undefined From bb1443c4cf1ecb477b66a1b0f4b36f1b80c8ff65 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Mon, 6 Dec 2021 20:08:23 +0100 Subject: [PATCH 17/19] Feature/cachetime zero (#3054) * refactor: cacheTime-zero remove special handling for cacheTime: 0 and schedule a normal garbage collection for those queries. They will be eligible for gc after a setTimeout(0), but then they will only be optionally removed. This makes sure that paused queries are NOT gc'ed * refactor: cacheTime-zero remove special test "about online queries with cacheTime:0 should not fetch if paused and then unmounted". paused queries will now be kept until they continue, just like with every other query, unless query cancellation or abort signal was involved * refactor: cacheTime-zero adapt "remounting" test: if the same query with cacheTime 0 unmounts and remounts in the same cycle, the query will now be picked up and will not go to loading state again. I think this is okay * refactor: cacheTime-zero re-add instant query removal after fetching, because fetching via `queryClient.fetchQuery` will not remove the query otherwise, because the normal gc-mechanism now checks for `hadObservers` due to a suspense issue :/ * refactor: cacheTime-zero weird edge case: the previous logic was instantly removing the query _while_ it was still fetching, which is something we likely don't want. The data will stay in the currentQuery of the observer if the observer unsubscribes but still exists, and a new subscription will pick it up, unless the query was explicitly cancelled or the abort signal was consumed. * refactor: cacheTime-zero we need to wait a tick because even cacheTime 0 now waits at least a setTimeout(0) to be eligible for gc * refactor: cacheTime-zero schedule a new garbage collection after each new fetch; this won't do anything when you still have observers, but it fixes an edge case where prefetching took longer than the cacheTime, in which case the query was again never removed test needed adaption because we don't instantly remove, but deferred by a tick * refactor: cacheTime-zero stabilize test * refactor: cacheTime-zero apply a different suspense "workaround": do not garbage collect when fetching optimistically (done only by suspense) - gc will kick in once an observer subscribes; this will make sure we can still gc other fetches that don't have an observer consistently, like prefetching when the fetch takes longer than the gc time (which was leaking with the old workaround) * refactor: cacheTime-zero remove leftover * refactor: cacheTime-zero since every fetch triggers a new gc cycle, we don't need to do this in a loop anymore also, reset isFetchingOptimistic after every fetch --- src/core/mutation.ts | 6 +- src/core/query.ts | 33 +++---- src/core/queryObserver.ts | 23 +---- src/core/tests/query.test.tsx | 15 ++-- src/core/tests/queryClient.test.tsx | 5 +- src/reactjs/tests/useQuery.test.tsx | 128 +++++++++++----------------- src/reactjs/useBaseQuery.ts | 6 -- 7 files changed, 74 insertions(+), 142 deletions(-) diff --git a/src/core/mutation.ts b/src/core/mutation.ts index cec82bee82..093f4107ee 100644 --- a/src/core/mutation.ts +++ b/src/core/mutation.ts @@ -131,11 +131,7 @@ export class Mutation< removeObserver(observer: MutationObserver): void { this.observers = this.observers.filter(x => x !== observer) - if (this.cacheTime) { - this.scheduleGc() - } else { - this.optionalRemove() - } + this.scheduleGc() this.mutationCache.notify({ type: 'observerRemoved', diff --git a/src/core/query.ts b/src/core/query.ts index 4d30855b43..ed449d8e56 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -154,6 +154,7 @@ export class Query< revertState?: QueryState state: QueryState meta: QueryMeta | undefined + isFetchingOptimistic?: boolean private cache: QueryCache private promise?: Promise @@ -161,13 +162,11 @@ export class Query< private observers: QueryObserver[] private defaultOptions?: QueryOptions private abortSignalConsumed: boolean - private hadObservers: boolean constructor(config: QueryConfig) { super() this.abortSignalConsumed = false - this.hadObservers = false this.defaultOptions = config.defaultOptions this.setOptions(config.options) this.observers = [] @@ -177,7 +176,6 @@ export class Query< this.initialState = config.state || this.getDefaultState(this.options) this.state = this.initialState this.meta = config.meta - this.scheduleGc() } private setOptions( @@ -197,12 +195,8 @@ export class Query< } protected optionalRemove() { - if (!this.observers.length) { - if (this.state.fetchStatus === 'idle') { - this.cache.remove(this) - } else if (this.hadObservers) { - this.scheduleGc() - } + if (!this.observers.length && this.state.fetchStatus === 'idle') { + this.cache.remove(this) } } @@ -307,7 +301,6 @@ export class Query< addObserver(observer: QueryObserver): void { if (this.observers.indexOf(observer) === -1) { this.observers.push(observer) - this.hadObservers = true // Stop the query from being garbage collected this.clearGcTimeout() @@ -331,11 +324,7 @@ export class Query< } } - if (this.cacheTime) { - this.scheduleGc() - } else { - this.cache.remove(this) - } + this.scheduleGc() } this.cache.notify({ type: 'observerRemoved', query: this, observer }) @@ -455,10 +444,11 @@ export class Query< // Notify cache callback this.cache.config.onSuccess?.(data, this as Query) - // Remove query after fetching if cache time is 0 - if (this.cacheTime === 0) { - this.optionalRemove() + if (!this.isFetchingOptimistic) { + // Schedule query gc after fetching + this.scheduleGc() } + this.isFetchingOptimistic = false }, onError: (error: TError | { silent?: boolean }) => { // Optimistically update state if needed @@ -477,10 +467,11 @@ export class Query< getLogger().error(error) } - // Remove query after fetching if cache time is 0 - if (this.cacheTime === 0) { - this.optionalRemove() + if (!this.isFetchingOptimistic) { + // Schedule query gc after fetching + this.scheduleGc() } + this.isFetchingOptimistic = false }, onFail: () => { this.dispatch({ type: 'failed' }) diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index 8e1a7b826c..6ed455d60f 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -309,15 +309,8 @@ export class QueryObserver< const query = this.client .getQueryCache() - .build( - this.client, - defaultedOptions as QueryOptions< - TQueryFnData, - TError, - TQueryData, - TQueryKey - > - ) + .build(this.client, defaultedOptions) + query.isFetchingOptimistic = true return query.fetch().then(() => this.createResult(query, defaultedOptions)) } @@ -655,17 +648,7 @@ export class QueryObserver< } private updateQuery(): void { - const query = this.client - .getQueryCache() - .build( - this.client, - this.options as QueryOptions< - TQueryFnData, - TError, - TQueryData, - TQueryKey - > - ) + const query = this.client.getQueryCache().build(this.client, this.options) if (query === this.currentQuery) { return diff --git a/src/core/tests/query.test.tsx b/src/core/tests/query.test.tsx index 21d3fe2888..118ac6662b 100644 --- a/src/core/tests/query.test.tsx +++ b/src/core/tests/query.test.tsx @@ -13,6 +13,7 @@ import { onlineManager, QueryFunctionContext, } from '../..' +import { waitFor } from '@testing-library/react' describe('query', () => { let queryClient: QueryClient @@ -472,7 +473,6 @@ describe('query', () => { }) test('queries with cacheTime 0 should be removed immediately after unsubscribing', async () => { - const consoleMock = mockConsoleError() const key = queryKey() let count = 0 const observer = new QueryObserver(queryClient, { @@ -486,13 +486,12 @@ describe('query', () => { }) const unsubscribe1 = observer.subscribe() unsubscribe1() - await sleep(10) + await waitFor(() => expect(queryCache.find(key)).toBeUndefined()) const unsubscribe2 = observer.subscribe() unsubscribe2() - await sleep(10) - expect(count).toBe(2) - expect(queryCache.find(key)).toBeUndefined() - consoleMock.mockRestore() + + await waitFor(() => expect(queryCache.find(key)).toBeUndefined()) + expect(count).toBe(1) }) test('should be garbage collected when unsubscribed to', async () => { @@ -506,7 +505,7 @@ describe('query', () => { const unsubscribe = observer.subscribe() expect(queryCache.find(key)).toBeDefined() unsubscribe() - expect(queryCache.find(key)).toBeUndefined() + await waitFor(() => expect(queryCache.find(key)).toBeUndefined()) }) test('should be garbage collected later when unsubscribed and query is fetching', async () => { @@ -529,7 +528,7 @@ describe('query', () => { expect(queryCache.find(key)).toBeDefined() await sleep(10) // should be removed after an additional staleTime wait - expect(queryCache.find(key)).toBeUndefined() + await waitFor(() => expect(queryCache.find(key)).toBeUndefined()) }) test('should not be garbage collected unless there are no subscribers', async () => { diff --git a/src/core/tests/queryClient.test.tsx b/src/core/tests/queryClient.test.tsx index 68cc4e5671..53de7d72ae 100644 --- a/src/core/tests/queryClient.test.tsx +++ b/src/core/tests/queryClient.test.tsx @@ -416,9 +416,10 @@ describe('queryClient', () => { }, { cacheTime: 0 } ) - const result2 = queryClient.getQueryData(key1) expect(result).toEqual(1) - expect(result2).toEqual(undefined) + await waitFor(() => + expect(queryClient.getQueryData(key1)).toEqual(undefined) + ) }) test('should keep a query in cache if cache time is Infinity', async () => { diff --git a/src/reactjs/tests/useQuery.test.tsx b/src/reactjs/tests/useQuery.test.tsx index b6e8a01614..b8b04fbc13 100644 --- a/src/reactjs/tests/useQuery.test.tsx +++ b/src/reactjs/tests/useQuery.test.tsx @@ -691,52 +691,78 @@ describe('useQuery', () => { expect(states[1]).toMatchObject({ data: 'data' }) }) - it('should create a new query when re-mounting with cacheTime 0', async () => { + it('should pick up a query when re-mounting with cacheTime 0', async () => { const key = queryKey() const states: UseQueryResult[] = [] function Page() { const [toggle, setToggle] = React.useState(false) - React.useEffect(() => { - setActTimeout(() => { - setToggle(true) - }, 20) - }, [setToggle]) - - return toggle ? : + return ( +
+ + {toggle ? ( + + ) : ( + + )} +
+ ) } - function Component() { + function Component({ value }: { value: string }) { const state = useQuery( key, async () => { - await sleep(5) - return 'data' + await sleep(10) + return 'data: ' + value }, { cacheTime: 0, + notifyOnChangeProps: 'all', } ) states.push(state) - return null + return ( +
+
{state.data}
+
+ ) } - renderWithClient(queryClient, ) + const rendered = renderWithClient(queryClient, ) - await sleep(100) + await rendered.findByText('data: 1') - expect(states.length).toBe(5) + rendered.getByRole('button', { name: /toggle/i }).click() + + await rendered.findByText('data: 2') + + expect(states.length).toBe(4) // First load - expect(states[0]).toMatchObject({ isLoading: true, isSuccess: false }) + expect(states[0]).toMatchObject({ + isLoading: true, + isSuccess: false, + isFetching: true, + }) // First success - expect(states[1]).toMatchObject({ isLoading: false, isSuccess: true }) - // Switch - expect(states[2]).toMatchObject({ isLoading: false, isSuccess: true }) - // Second load - expect(states[3]).toMatchObject({ isLoading: true, isSuccess: false }) + expect(states[1]).toMatchObject({ + isLoading: false, + isSuccess: true, + isFetching: false, + }) + // Switch, goes to fetching + expect(states[2]).toMatchObject({ + isLoading: false, + isSuccess: true, + isFetching: true, + }) // Second success - expect(states[4]).toMatchObject({ isLoading: false, isSuccess: true }) + expect(states[3]).toMatchObject({ + isLoading: false, + isSuccess: true, + isFetching: false, + }) }) it('should not get into an infinite loop when removing a query with cacheTime 0 and rerendering', async () => { @@ -5097,64 +5123,6 @@ describe('useQuery', () => { onlineMock.mockRestore() }) - - it('online queries with cacheTime:0 should not fetch if paused and then unmounted', async () => { - const key = queryKey() - let count = 0 - - function Component() { - const state = useQuery({ - queryKey: key, - queryFn: async () => { - count++ - await sleep(10) - return 'data' + count - }, - cacheTime: 0, - }) - - return ( -
-
- status: {state.status}, fetchStatus: {state.fetchStatus} -
-
data: {state.data}
-
- ) - } - - function Page() { - const [show, setShow] = React.useState(true) - - return ( -
- {show && } - -
- ) - } - - const onlineMock = mockNavigatorOnLine(false) - - const rendered = renderWithClient(queryClient, ) - - await waitFor(() => - rendered.getByText('status: loading, fetchStatus: paused') - ) - - rendered.getByRole('button', { name: /hide/i }).click() - - onlineMock.mockReturnValue(true) - window.dispatchEvent(new Event('online')) - - await sleep(15) - - expect(queryClient.getQueryState(key)).not.toBeDefined() - - expect(count).toBe(0) - - onlineMock.mockRestore() - }) }) describe('networkMode always', () => { diff --git a/src/reactjs/useBaseQuery.ts b/src/reactjs/useBaseQuery.ts index 5d79aea354..a5f4d5f5dd 100644 --- a/src/reactjs/useBaseQuery.ts +++ b/src/reactjs/useBaseQuery.ts @@ -59,12 +59,6 @@ export function useBaseQuery< if (typeof defaultedOptions.staleTime !== 'number') { defaultedOptions.staleTime = 1000 } - - // Set cache time to 1 if the option has been set to 0 - // when using suspense to prevent infinite loop of fetches - if (defaultedOptions.cacheTime === 0) { - defaultedOptions.cacheTime = 1 - } } if (defaultedOptions.suspense || defaultedOptions.useErrorBoundary) { From 0e1bb92bc7fd5deac4b0836ac1a063c865b5cce0 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Mon, 6 Dec 2021 20:12:40 +0100 Subject: [PATCH 18/19] add publishing capabilities for alpha branch --- .github/workflows/test-and-publish.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-and-publish.yml b/.github/workflows/test-and-publish.yml index 8c2e52ac94..24d468633e 100644 --- a/.github/workflows/test-and-publish.yml +++ b/.github/workflows/test-and-publish.yml @@ -5,6 +5,7 @@ on: branches: - 'master' - 'next' + - 'alpha' - 'beta' - '1.x' - '2.x' @@ -36,7 +37,7 @@ jobs: name: 'Publish Module to NPM' needs: test # publish only when merged in master on original repo, not on PR - if: github.repository == 'tannerlinsley/react-query' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/next' || github.ref == 'refs/heads/beta' || github.ref == 'refs/heads/1.x' || github.ref == 'refs/heads/2.x') + if: github.repository == 'tannerlinsley/react-query' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/next' || github.ref == 'refs/heads/alpha' || github.ref == 'refs/heads/beta' || github.ref == 'refs/heads/1.x' || github.ref == 'refs/heads/2.x') runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 From 217ee3a43630679b9ab580f6292f01ab9ab75e26 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Mon, 6 Dec 2021 21:44:20 +0100 Subject: [PATCH 19/19] fix(queryFilters): fetchStatus to queryFilters (#3061) --- docs/src/pages/guides/filters.md | 7 ++++--- src/core/query.ts | 4 ---- src/core/queryClient.ts | 2 +- src/core/tests/queryCache.test.tsx | 21 +++++++++++++++++++++ src/core/utils.ts | 19 +++++++++++++++---- src/devtools/tests/devtools.test.tsx | 4 ++-- 6 files changed, 43 insertions(+), 14 deletions(-) diff --git a/docs/src/pages/guides/filters.md b/docs/src/pages/guides/filters.md index ac8897c93b..79e6f1cda6 100644 --- a/docs/src/pages/guides/filters.md +++ b/docs/src/pages/guides/filters.md @@ -34,9 +34,10 @@ A query filter object supports the following properties: - `stale?: boolean` - When set to `true` it will match stale queries. - When set to `false` it will match fresh queries. -- `fetching?: boolean` - - When set to `true` it will match queries that are currently fetching. - - When set to `false` it will match queries that are not fetching. +- `fetchStatus?: FetchStatus` + - When set to `fetching` it will match queries that are currently fetching. + - When set to `paused` it will match queries that wanted to fetch, but have been `paused`. + - When set to `idle` it will match queries that are not fetching. - `predicate?: (query: Query) => boolean` - This predicate function will be called for every single query in the cache and be expected to return truthy for queries that are `found`. - `queryKey?: QueryKey` diff --git a/src/core/query.ts b/src/core/query.ts index ed449d8e56..12e99622ed 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -256,10 +256,6 @@ export class Query< return this.observers.some(observer => observer.options.enabled !== false) } - isFetching(): boolean { - return this.state.fetchStatus === 'fetching' - } - isStale(): boolean { return ( this.state.isInvalidated || diff --git a/src/core/queryClient.ts b/src/core/queryClient.ts index c68b9efcd3..eae1f3302d 100644 --- a/src/core/queryClient.ts +++ b/src/core/queryClient.ts @@ -99,7 +99,7 @@ export class QueryClient { isFetching(queryKey?: QueryKey, filters?: QueryFilters): number isFetching(arg1?: QueryKey | QueryFilters, arg2?: QueryFilters): number { const [filters] = parseFilterArgs(arg1, arg2) - filters.fetching = true + filters.fetchStatus = 'fetching' return this.queryCache.findAll(filters).length } diff --git a/src/core/tests/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx index 36ae34a7b8..e9fcba22cc 100644 --- a/src/core/tests/queryCache.test.tsx +++ b/src/core/tests/queryCache.test.tsx @@ -78,6 +78,7 @@ describe('queryCache', () => { test('should filter correctly', async () => { const key1 = queryKey() const key2 = queryKey() + const keyFetching = queryKey() await queryClient.prefetchQuery(key1, () => 'data1') await queryClient.prefetchQuery(key2, () => 'data2') await queryClient.prefetchQuery([{ a: 'a', b: 'b' }], () => 'data3') @@ -137,6 +138,26 @@ describe('queryCache', () => { queryCache.findAll({ predicate: query => query === query3 }) ).toEqual([query3]) expect(queryCache.findAll(['posts'])).toEqual([query4]) + + expect(queryCache.findAll({ fetchStatus: 'idle' })).toEqual([ + query1, + query2, + query3, + query4, + ]) + expect(queryCache.findAll(key2, { fetchStatus: undefined })).toEqual([ + query2, + ]) + + const promise = queryClient.prefetchQuery(keyFetching, async () => { + await sleep(20) + return 'dataFetching' + }) + expect(queryCache.findAll({ fetchStatus: 'fetching' })).toEqual([ + queryCache.find(keyFetching), + ]) + await promise + expect(queryCache.findAll({ fetchStatus: 'fetching' })).toEqual([]) }) test('should return all the queries when no filters are defined', async () => { diff --git a/src/core/utils.ts b/src/core/utils.ts index 578caf7084..0c4c9b0fcf 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,6 +1,7 @@ import type { Mutation } from './mutation' import type { Query } from './query' import type { + FetchStatus, MutationFunction, MutationKey, MutationOptions, @@ -33,9 +34,9 @@ export interface QueryFilters { */ stale?: boolean /** - * Include or exclude fetching queries + * Include queries matching their fetchStatus */ - fetching?: boolean + fetchStatus?: FetchStatus } export interface MutationFilters { @@ -164,7 +165,14 @@ export function matchQuery( filters: QueryFilters, query: Query ): boolean { - const { type = 'all', exact, fetching, predicate, queryKey, stale } = filters + const { + type = 'all', + exact, + fetchStatus, + predicate, + queryKey, + stale, + } = filters if (isQueryKey(queryKey)) { if (exact) { @@ -190,7 +198,10 @@ export function matchQuery( return false } - if (typeof fetching === 'boolean' && query.isFetching() !== fetching) { + if ( + typeof fetchStatus !== 'undefined' && + fetchStatus !== query.state.fetchStatus + ) { return false } diff --git a/src/devtools/tests/devtools.test.tsx b/src/devtools/tests/devtools.test.tsx index aaa87f617d..3ffe6e16df 100644 --- a/src/devtools/tests/devtools.test.tsx +++ b/src/devtools/tests/devtools.test.tsx @@ -127,7 +127,7 @@ describe('ReactQueryDevtools', () => { // When the query is fetching then expect number of // fetching queries to be 1 - expect(currentQuery?.isFetching()).toEqual(true) + expect(currentQuery?.state.fetchStatus).toEqual('fetching') await screen.findByText( getByTextContent( 'fresh (0) fetching (1) paused (0) stale (0) inactive (0)' @@ -138,7 +138,7 @@ describe('ReactQueryDevtools', () => { // until 300ms after, so expect the number of fresh // queries to be 1 await waitFor(() => { - expect(currentQuery?.isFetching()).toEqual(false) + expect(currentQuery?.state.fetchStatus).toEqual('idle') }) await screen.findByText( getByTextContent(