From b84681fdc7d37645cd149f40e98cb18fd8bc393c Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Tue, 7 Dec 2021 08:56:27 +0100 Subject: [PATCH] v4 alpha (#3060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(hydration): remove hydration package (#2936) * 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 * feat: better query filters (#2938) * 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 * 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 * refactor(persistQueryClient): Make persistQueryClient stable (#2961) * :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 * 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 * 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 * 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 * refactor: Remove deprecated promise cancel (#2996) * :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 * remove test that doesn't make sense anymore - we don't allow different falsy query keys now * 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 * refactor(queryClient): remove undocumented methods * 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 * examples: fix query keys in basic examples because we need those for preview builds * 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 * 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 * add publishing capabilities for alpha branch * fix(queryFilters): fetchStatus to queryFilters (#3061) Co-authored-by: Eddy Co-authored-by: Eddy Vinck Co-authored-by: Prateek Surana Co-authored-by: Rene Dellefont BREAKING CHANGE: new query filters, query key must be an array --- .github/workflows/test-and-publish.yml | 3 +- createAsyncStoragePersister/package.json | 6 + .../package.json | 6 - createWebStoragePersister/package.json | 6 + .../package.json | 6 - docs/src/manifests/manifest.json | 26 +- docs/src/pages/comparison.md | 2 +- 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 | 25 +- 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 | 255 ++++ docs/src/pages/guides/mutations.md | 12 +- docs/src/pages/guides/network-mode.md | 46 + 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 +- ...stor.md => createAsyncStoragePersister.md} | 28 +- ...sistor.md => createWebStoragePersister.md} | 30 +- docs/src/pages/plugins/persistQueryClient.md | 46 +- docs/src/pages/quick-start.md | 4 +- .../pages/reference/InfiniteQueryObserver.md | 2 +- docs/src/pages/reference/MutationCache.md | 6 +- docs/src/pages/reference/QueryClient.md | 63 +- docs/src/pages/reference/QueryObserver.md | 2 +- docs/src/pages/reference/hydration.md | 8 - docs/src/pages/reference/useInfiniteQuery.md | 4 +- docs/src/pages/reference/useMutation.md | 14 +- docs/src/pages/reference/useQuery.md | 41 +- examples/basic-typescript/src/index.tsx | 2 +- examples/basic/src/index.js | 19 +- hydration/package.json | 6 - package.json | 26 +- persistQueryClient-experimental/package.json | 6 - persistQueryClient/package.json | 6 + react/package.json | 6 - reactjs/package.json | 6 + rollup.config.js | 19 +- .../index.ts | 12 +- src/core/infiniteQueryBehavior.ts | 16 +- src/core/infiniteQueryObserver.ts | 34 +- src/core/mutation.ts | 159 ++- src/core/mutationCache.ts | 57 +- src/core/notifiable.ts | 12 + src/core/queriesObserver.ts | 12 +- src/core/query.ts | 153 +-- src/core/queryCache.ts | 36 +- src/core/queryClient.ts | 108 +- src/core/queryObserver.ts | 105 +- src/core/removable.ts | 35 + src/core/retryer.ts | 76 +- src/core/tests/hydration.test.tsx | 133 +- src/core/tests/infiniteQueryBehavior.test.tsx | 8 +- src/core/tests/infiniteQueryObserver.test.tsx | 2 +- src/core/tests/mutationCache.test.tsx | 169 ++- src/core/tests/mutationObserver.test.tsx | 2 +- src/core/tests/mutations.test.tsx | 21 +- src/core/tests/queriesObserver.test.tsx | 38 +- src/core/tests/query.test.tsx | 78 +- src/core/tests/queryCache.test.tsx | 50 +- src/core/tests/queryClient.test.tsx | 289 +++-- src/core/tests/queryObserver.test.tsx | 4 +- src/core/tests/utils.test.tsx | 27 +- src/core/types.ts | 46 +- src/core/utils.ts | 80 +- .../index.ts | 8 +- .../index.ts | 8 +- .../tests/storageIsFull.test.ts | 70 +- src/devtools/devtools.tsx | 84 +- src/devtools/tests/devtools.test.tsx | 131 +- src/devtools/theme.tsx | 1 + src/devtools/useMediaQuery.ts | 6 +- src/devtools/utils.ts | 8 +- src/hydration/index.ts | 2 +- src/index.ts | 2 +- .../index.ts | 18 +- src/react/utils.ts | 16 - 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 | 24 +- .../tests/QueryClientProvider.test.tsx | 0 .../tests/QueryResetErrorBoundary.test.tsx | 1 - .../tests/logger.native.test.tsx | 0 .../tests/ssr-hydration.test.tsx | 12 +- src/{react => reactjs}/tests/ssr.test.tsx | 0 .../tests/suspense.test.tsx | 6 +- .../tests/useInfiniteQuery.test.tsx | 43 +- .../tests/useIsFetching.test.tsx | 0 .../tests/useIsMutating.test.tsx | 19 +- .../tests/useMutation.test.tsx | 241 +++- .../tests/useQueries.test.tsx | 4 +- .../tests/useQuery.test.tsx | 1069 +++++++++++++++-- src/{react => reactjs}/tests/utils.tsx | 25 +- src/{react => reactjs}/types.ts | 3 + src/{react => reactjs}/useBaseQuery.ts | 16 +- src/{react => reactjs}/useInfiniteQuery.ts | 0 src/{react => reactjs}/useIsFetching.ts | 0 src/{react => reactjs}/useIsMutating.ts | 0 src/{react => reactjs}/useMutation.ts | 3 +- src/{react => reactjs}/useQueries.ts | 8 +- src/{react => reactjs}/useQuery.ts | 0 src/reactjs/utils.ts | 11 + src/ts3.8/useQueries.ts | 2 +- tsconfig.types.json | 7 +- 127 files changed, 3126 insertions(+), 1409 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 create mode 100644 docs/src/pages/guides/migrating-to-react-query-4.md create mode 100644 docs/src/pages/guides/network-mode.md rename docs/src/pages/plugins/{createAsyncStoragePersistor.md => createAsyncStoragePersister.md} (62%) rename docs/src/pages/plugins/{createWebStoragePersistor.md => createWebStoragePersister.md} (54%) delete mode 100644 hydration/package.json delete mode 100644 persistQueryClient-experimental/package.json create mode 100644 persistQueryClient/package.json delete mode 100644 react/package.json create mode 100644 reactjs/package.json create mode 100644 src/core/notifiable.ts create mode 100644 src/core/removable.ts 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 (61%) rename src/{persistQueryClient-experimental => persistQueryClient}/index.ts (87%) delete mode 100644 src/react/utils.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 (87%) rename src/{react => reactjs}/tests/QueryClientProvider.test.tsx (100%) rename src/{react => reactjs}/tests/QueryResetErrorBoundary.test.tsx (99%) rename src/{react => reactjs}/tests/logger.native.test.tsx (100%) rename src/{react => reactjs}/tests/ssr-hydration.test.tsx (94%) rename src/{react => reactjs}/tests/ssr.test.tsx (100%) rename src/{react => reactjs}/tests/suspense.test.tsx (99%) rename src/{react => reactjs}/tests/useInfiniteQuery.test.tsx (97%) rename src/{react => reactjs}/tests/useIsFetching.test.tsx (100%) rename src/{react => reactjs}/tests/useIsMutating.test.tsx (84%) rename src/{react => reactjs}/tests/useMutation.test.tsx (70%) rename src/{react => reactjs}/tests/useQueries.test.tsx (99%) rename src/{react => reactjs}/tests/useQuery.test.tsx (81%) rename src/{react => reactjs}/tests/utils.tsx (74%) rename src/{react => reactjs}/types.ts (98%) rename src/{react => reactjs}/useBaseQuery.ts (88%) 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 (98%) rename src/{react => reactjs}/useQueries.ts (97%) rename src/{react => reactjs}/useQuery.ts (100%) create mode 100644 src/reactjs/utils.ts 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 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 18ad4cd4e5..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", @@ -204,6 +209,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" } ] }, @@ -314,19 +324,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)", @@ -442,4 +452,4 @@ ] } ] -} \ No newline at end of file +} 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/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 9419e44d11..79e6f1cda6 100644 --- a/docs/src/pages/guides/filters.md +++ b/docs/src/pages/guides/filters.md @@ -14,31 +14,30 @@ 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. -- `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` @@ -53,7 +52,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 new file mode 100644 index 0000000000..14002bec89 --- /dev/null +++ b/docs/src/pages/guides/migrating-to-react-query-4.md @@ -0,0 +1,255 @@ +--- +id: migrating-to-react-query-4 +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 +- import { dehydrate, hydrate, useHydrate, Hydrate } from 'react-query/hydration' ++ 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: + +- `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. + +### 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. + +### 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: + +``` +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'; +``` + +### `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. + +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' +``` + +### 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. + +### 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' + } + } +}) +``` + +### 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 🚀 + +### 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/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/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/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 f9239ea91a..fab84b4c2c 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] // An 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 dc3b757d3c..0058831fec 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/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/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/MutationCache.md b/docs/src/pages/reference/MutationCache.md index 06f1d52e99..404fce8e46 100644 --- a/docs/src/pages/reference/MutationCache.md +++ b/docs/src/pages/reference/MutationCache.md @@ -65,8 +65,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) @@ -74,7 +74,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/QueryClient.md b/docs/src/pages/reference/QueryClient.md index be3fa19d6a..b195e050d0 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: @@ -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** @@ -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 @@ -268,14 +266,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', { +await queryClient.invalidateQueries(['posts'], { exact, - refetchActive: true, - refetchInactive: false + refetchType: 'active', }, { throwOnError, cancelRefetch }) ``` @@ -283,20 +280,22 @@ 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 - `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` @@ -312,10 +311,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** @@ -328,8 +327,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** @@ -342,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** @@ -396,8 +397,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** @@ -468,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` @@ -476,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']) } ``` @@ -493,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` @@ -501,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/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/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/useMutation.md b/docs/src/pages/reference/useMutation.md index e8b2e9cdcb..07d82110ca 100644 --- a/docs/src/pages/reference/useMutation.md +++ b/docs/src/pages/reference/useMutation.md @@ -17,13 +17,15 @@ const { reset, status, } = useMutation(mutationFn, { + cacheTime, mutationKey, + networkMode, onError, onMutate, onSettled, onSuccess, useErrorBoundary, - meta, + meta }) mutate(variables, { @@ -39,9 +41,16 @@ 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. +- `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 @@ -94,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 0b5588acc7..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,16 +27,17 @@ const { refetch, remove, status, + fetchStatus, } = useQuery(queryKey, queryFn?, { cacheTime, enabled, + networkMode, initialData, - initialDataUpdatedAt + initialDataUpdatedAt, isDataEqual, keepPreviousData, meta, notifyOnChangeProps, - notifyOnChangePropsExclusions, onError, onSettled, onSuccess, @@ -66,7 +68,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,12 +76,15 @@ 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. - 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. @@ -126,18 +131,15 @@ 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 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. @@ -223,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` @@ -236,6 +244,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/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 722e2bcc7c..638eef5425 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); @@ -38,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" ); 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/package.json b/package.json index 560a5dcec6..0c927899a0 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\"", @@ -55,12 +55,12 @@ "es", "hydration", "devtools", - "persistQueryClient-experimental", - "createWebStoragePersistor-experimental", - "createAsyncStoragePersistor-experimental", + "persistQueryClient", + "createWebStoragePersister", + "createAsyncStoragePersister", "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/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/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/rollup.config.js b/rollup.config.js index 528bebbaf5..35fdd215aa 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -19,21 +19,20 @@ 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', - '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/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/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/infiniteQueryObserver.ts b/src/core/infiniteQueryObserver.ts index 19ef264650..dc0a6a6021 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 }, }, }) } @@ -134,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 58796a3a6b..093f4107ee 100644 --- a/src/core/mutation.ts +++ b/src/core/mutation.ts @@ -3,7 +3,8 @@ import type { MutationCache } from './mutationCache' import type { MutationObserver } from './mutationObserver' import { getLogger } from './logger' import { notifyManager } from './notifyManager' -import { Retryer } from './retryer' +import { Removable } from './removable' +import { canFetch, Retryer } from './retryer' import { noop } from './utils' // TYPES @@ -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,38 @@ 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) + + this.scheduleGc() + + 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 { @@ -247,21 +280,82 @@ export class Mutation< }, retry: this.options.retry ?? 0, retryDelay: this.options.retryDelay, + networkMode: this.options.networkMode, }) return this.retryer.promise } private dispatch(action: Action): void { - this.state = reducer(this.state, action) + this.state = this.reducer(action) notifyManager.batch(() => { this.observers.forEach(observer => { observer.onMutationUpdate(action) }) - this.mutationCache.notify(this) + this.mutationCache.notify({ + mutation: this, + type: 'updated', + action, + }) }) } + + 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< @@ -280,60 +374,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/core/mutationCache.ts b/src/core/mutationCache.ts index 624e6972a2..d333ccb26f 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,13 +13,13 @@ 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 onMutate?: ( variables: unknown, @@ -26,11 +27,43 @@ interface MutationCacheConfig { ) => 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[] @@ -66,13 +99,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 { @@ -101,14 +134,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/queriesObserver.ts b/src/core/queriesObserver.ts index 2eb22ef8b5..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' @@ -74,7 +78,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( @@ -134,7 +138,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) } @@ -205,6 +209,6 @@ export class QueriesObserver extends Subscribable { } type QueryObserverMatch = { - defaultedQueryOptions: QueryObserverOptions + defaultedQueryOptions: DefaultedQueryObserverOptions observer: QueryObserver } diff --git a/src/core/query.ts b/src/core/query.ts index 1b5a3854ad..12e99622ed 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -2,11 +2,9 @@ import { getAbortController, Updater, functionalUpdate, - isValidTimeout, noop, replaceEqualDeep, timeUntilStale, - ensureQueryKeyArray, } from './utils' import type { InitialDataFunction, @@ -14,16 +12,17 @@ import type { QueryOptions, QueryStatus, QueryFunctionContext, - EnsuredQueryKey, 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 @@ -51,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< @@ -65,8 +63,9 @@ export interface FetchContext< > { fetchFn: () => unknown | Promise fetchOptions?: FetchOptions + signal?: AbortSignal options: QueryOptions - queryKey: EnsuredQueryKey + queryKey: TQueryKey state: QueryState meta: QueryMeta | undefined } @@ -100,6 +99,7 @@ interface SuccessAction { data: TData | undefined type: 'success' dataUpdatedAt?: number + notifySuccess?: boolean } interface ErrorAction { @@ -146,28 +146,27 @@ 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 + isFetchingOptimistic?: boolean private cache: QueryCache private promise?: Promise - private gcTimeout?: number private retryer?: Retryer 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( @@ -187,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( @@ -200,36 +194,15 @@ 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() { - if (!this.observers.length) { - if (this.state.isFetching) { - if (this.hadObservers) { - this.scheduleGc() - } - } else { - this.cache.remove(this) - } + protected optionalRemove() { + if (!this.observers.length && this.state.fetchStatus === 'idle') { + this.cache.remove(this) } } setData( updater: Updater, - options?: SetDataOptions + options?: SetDataOptions & { notifySuccess: boolean } ): TData { const prevData = this.state.data @@ -249,6 +222,7 @@ export class Query< data, type: 'success', dataUpdatedAt: options?.updatedAt, + notifySuccess: options?.notifySuccess, }) return data @@ -268,7 +242,8 @@ export class Query< } destroy(): void { - this.clearGcTimeout() + super.destroy() + this.cancel({ silent: true }) } @@ -281,10 +256,6 @@ export class Query< return this.observers.some(observer => observer.options.enabled !== false) } - isFetching(): boolean { - return this.state.isFetching - } - isStale(): boolean { return ( this.state.isInvalidated || @@ -305,7 +276,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 @@ -316,7 +287,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 @@ -326,7 +297,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() @@ -343,18 +313,14 @@ 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() } } - if (this.cacheTime) { - this.scheduleGc() - } else { - this.cache.remove(this) - } + this.scheduleGc() } this.cache.notify({ type: 'observerRemoved', query: this, observer }) @@ -375,7 +341,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 }) @@ -401,26 +367,32 @@ 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, } - 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 = () => { @@ -435,12 +407,14 @@ export class Query< const context: FetchContext = { fetchOptions, options: this.options, - queryKey: queryKey, + queryKey: this.queryKey, state: this.state, fetchFn, meta: this.meta, } + addSignalProperty(context) + if (this.options.behavior?.onFetch) { this.options.behavior?.onFetch(context) } @@ -450,7 +424,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 }) @@ -466,10 +440,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 @@ -488,10 +463,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' }) @@ -504,6 +480,7 @@ export class Query< }, retry: context.options.retry, retryDelay: context.options.retryDelay, + networkMode: context.options.networkMode, }) this.promise = this.retryer.promise @@ -519,7 +496,7 @@ export class Query< observer.onQueryUpdate(action) }) - this.cache.notify({ query: this, type: 'queryUpdated', action }) + this.cache.notify({ query: this, type: 'updated', action }) }) } @@ -550,10 +527,9 @@ export class Query< errorUpdatedAt: 0, fetchFailureCount: 0, fetchMeta: null, - isFetching: false, isInvalidated: false, - isPaused: false, status: hasData ? 'success' : 'idle', + fetchStatus: 'idle', } } @@ -570,20 +546,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': @@ -594,9 +571,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': @@ -612,8 +588,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/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/queryClient.ts b/src/core/queryClient.ts index cb9d6ba181..eae1f3302d 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() } @@ -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 } @@ -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( @@ -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) }) } @@ -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 }, }) ) @@ -499,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 } @@ -591,16 +574,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 = { @@ -608,13 +605,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( @@ -623,25 +614,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 aa36cfd2cd..6ed455d60f 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -1,4 +1,4 @@ -import { RefetchQueryFilters } 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') } @@ -292,12 +284,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 }, }) } @@ -310,27 +305,23 @@ export class QueryObserver< TQueryKey > ): Promise> { - 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) + query.isFetchingOptimistic = true return query.fetch().then(() => this.createResult(query, defaultedOptions)) } 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 }) @@ -456,7 +447,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 @@ -471,7 +462,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' } @@ -567,8 +560,11 @@ export class QueryObserver< } } + const isFetching = fetchStatus === 'fetching' + const result: QueryObserverBaseResult = { status, + fetchStatus, isLoading: status === 'loading', isSuccess: status === 'success', isError: status === 'error', @@ -582,9 +578,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, @@ -604,27 +601,22 @@ export class QueryObserver< return true } - const { notifyOnChangeProps, notifyOnChangePropsExclusions } = this.options - - if (!notifyOnChangeProps && !notifyOnChangePropsExclusions) { - return true - } + const { notifyOnChangeProps } = this.options - 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) }) } @@ -656,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 @@ -687,7 +669,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 } @@ -719,9 +701,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/retryer.ts b/src/core/retryer.ts index 07621123d4..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 @@ -35,12 +36,10 @@ 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 function canFetch(networkMode: NetworkMode | undefined): boolean { + return (networkMode ?? 'online') === 'online' + ? onlineManager.isOnline() + : true } export class CancelledError { @@ -66,31 +65,39 @@ export class Retryer { failureCount: number isPaused: boolean isResolved: boolean - isTransportCancelable: 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 - this.isTransportCancelable = false this.promise = new Promise((outerResolve, outerReject) => { promiseResolve = outerResolve promiseReject = outerReject @@ -116,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?.() + } }) } @@ -142,25 +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?.() - - // 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 => { @@ -196,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() } }) @@ -211,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 2de31d1132..77a5e58924 100644 --- a/src/core/tests/hydration.test.tsx +++ b/src/core/tests/hydration.test.tsx @@ -1,4 +1,8 @@ -import { mockNavigatorOnLine, sleep } from '../../react/tests/utils' +import { + executeMutation, + mockNavigatorOnLine, + sleep, +} from '../../reactjs/tests/utils' import { QueryCache } from '../queryCache' import { QueryClient } from '../queryClient' import { dehydrate, hydrate } from '../hydration' @@ -12,12 +16,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 +33,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 +70,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 +82,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 +96,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 +107,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 +116,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 +159,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 +174,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 +186,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 +207,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 +217,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 +228,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 +242,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 +250,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 +260,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() @@ -265,7 +269,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() @@ -278,7 +282,7 @@ describe('dehydration and rehydration', () => { const serverClient = new QueryClient() - serverClient.setMutationDefaults('addTodo', { + serverClient.setMutationDefaults(['addTodo'], { mutationFn: serverAddTodo, onMutate: serverOnMutate, onSuccess: serverOnSuccess, @@ -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) @@ -302,7 +304,7 @@ describe('dehydration and rehydration', () => { // --- - mockNavigatorOnLine(true) + onlineMock.mockReturnValue(true) const parsed = JSON.parse(stringified) const client = new QueryClient() @@ -316,7 +318,7 @@ describe('dehydration and rehydration', () => { }) const clientOnSuccess = jest.fn() - client.setMutationDefaults('addTodo', { + client.setMutationDefaults(['addTodo'], { mutationFn: clientAddTodo, onMutate: clientOnMutate, onSuccess: clientOnSuccess, @@ -339,6 +341,7 @@ describe('dehydration and rehydration', () => { client.clear() consoleMock.mockRestore() + onlineMock.mockRestore() }) test('should not dehydrate mutations if dehydrateMutations is set to false', async () => { @@ -351,17 +354,15 @@ describe('dehydration and rehydration', () => { const queryClient = new QueryClient() - queryClient.setMutationDefaults('addTodo', { + queryClient.setMutationDefaults(['addTodo'], { mutationFn: serverAddTodo, 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 }) @@ -382,18 +383,16 @@ describe('dehydration and rehydration', () => { const queryClient = new QueryClient() - queryClient.setMutationDefaults('addTodo', { + queryClient.setMutationDefaults(['addTodo'], { mutationFn: serverAddTodo, retry: 1, 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/infiniteQueryBehavior.test.tsx b/src/core/tests/infiniteQueryBehavior.test.tsx index 92d5c6c9f9..a3e57e30e4 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, @@ -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/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 c0bd84f487..ff3ca5cea5 100644 --- a/src/core/tests/mutationCache.test.tsx +++ b/src/core/tests/mutationCache.test.tsx @@ -1,5 +1,11 @@ -import { queryKey, mockConsoleError } from '../../react/tests/utils' -import { MutationCache, QueryClient } from '../..' +import { waitFor } from '@testing-library/react' +import { + queryKey, + mockConsoleError, + sleep, + executeMutation, +} from '../../reactjs/tests/utils' +import { MutationCache, MutationObserver, QueryClient } from '../..' describe('mutationCache', () => { describe('MutationCacheConfig.onError', () => { @@ -11,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'), @@ -34,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 }), @@ -62,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 }), @@ -82,17 +88,17 @@ 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(), }) 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) @@ -103,30 +109,161 @@ 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({ - mutationKey: 'b', + await executeMutation(testClient, { + 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]) }) }) + + 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 executeMutation(testClient, { + 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/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..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 '../../react/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, { @@ -371,14 +376,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 +398,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 +407,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/queriesObserver.test.tsx b/src/core/tests/queriesObserver.test.tsx index f2fcae9846..c4e5739735 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, @@ -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() @@ -146,11 +118,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/query.test.tsx b/src/core/tests/query.test.tsx index efdc4894e6..118ac6662b 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, @@ -13,6 +13,7 @@ import { onlineManager, QueryFunctionContext, } from '../..' +import { waitFor } from '@testing-library/react' describe('query', () => { let queryClient: QueryClient @@ -46,10 +47,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 +82,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 +143,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 +179,7 @@ describe('query', () => { expect(isCancelledError(result)).toBe(true) // Reset visibilityState to original value - mockVisibilityState(originalVisibilityState) + visibilityMock.mockRestore() window.dispatchEvent(new FocusEvent('focus')) }) @@ -190,7 +187,10 @@ describe('query', () => { const key = queryKey() const queryFn = jest - .fn, [QueryFunctionContext]>() + .fn< + Promise<'data'>, + [QueryFunctionContext>] + >() .mockResolvedValue('data') queryClient.prefetchQuery(key, queryFn) @@ -201,7 +201,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 +277,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 @@ -329,43 +332,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() @@ -507,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, { @@ -521,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 () => { @@ -541,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 () => { @@ -564,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/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx index cf26eb3ef7..e9fcba22cc 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' @@ -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 () => { @@ -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') @@ -89,23 +90,24 @@ 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, { 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,14 +130,34 @@ 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( queryCache.findAll({ predicate: query => query === query3 }) ).toEqual([query3]) - expect(queryCache.findAll('posts')).toEqual([query4]) + 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/tests/queryClient.test.tsx b/src/core/tests/queryClient.test.tsx index 30f8e2996c..53de7d72ae 100644 --- a/src/core/tests/queryClient.test.tsx +++ b/src/core/tests/queryClient.test.tsx @@ -1,11 +1,20 @@ -import { sleep, queryKey, mockConsoleError } from '../../react/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, QueryFunction, QueryObserver, - MutationObserver, } from '../..' import { focusManager, onlineManager } from '..' @@ -166,19 +175,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() @@ -219,6 +215,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', () => { @@ -226,7 +278,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], @@ -252,10 +307,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) }) }) @@ -312,8 +367,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') @@ -361,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 () => { @@ -424,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'], @@ -466,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') @@ -506,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') @@ -688,7 +744,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) @@ -709,7 +765,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) }) @@ -726,7 +783,10 @@ describe('queryClient', () => { queryFn: queryFn1, }) const unsubscribe = observer.subscribe() - await queryClient.refetchQueries({ active: true, stale: true }) + await queryClient.refetchQueries( + { type: 'active', stale: true }, + { cancelRefetch: false } + ) unsubscribe() expect(queryFn1).toHaveBeenCalledTimes(2) expect(queryFn2).toHaveBeenCalledTimes(1) @@ -764,7 +824,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) @@ -783,7 +843,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) @@ -802,31 +862,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() @@ -891,7 +932,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() @@ -905,14 +946,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() @@ -927,14 +968,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() @@ -947,17 +988,49 @@ 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 () => { + const key = queryKey() + const abortFn = jest.fn() + let fetchCount = 0 + const observer = new QueryObserver(queryClient, { + queryKey: key, + enabled: false, + initialData: 1, + }) + observer.subscribe() + + queryClient.fetchQuery(key, ({ signal }) => { + const promise = new Promise(resolve => { + fetchCount++ + setTimeout(() => resolve(5), 10) + if (signal) { + signal.addEventListener('abort', abortFn) + } + }) + + return promise + }) + + await queryClient.refetchQueries() + observer.destroy() + if (typeof AbortSignal === 'function') { + expect(abortFn).toHaveBeenCalledTimes(1) + } + expect(fetchCount).toBe(2) }) - test('should cancel ongoing fetches if cancelRefetch option is passed', async () => { + 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, enabled: false, @@ -965,18 +1038,24 @@ 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: true }) + await queryClient.refetchQueries(undefined, { cancelRefetch: false }) observer.destroy() - expect(cancelFn).toHaveBeenCalledTimes(1) + if (typeof AbortSignal === 'function') { + expect(abortFn).toHaveBeenCalledTimes(0) + } + expect(fetchCount).toBe(1) }) }) @@ -1104,7 +1183,7 @@ describe('queryClient', () => { await queryClient.invalidateQueries({ queryKey: key, - refetchInactive: true, + refetchType: 'all', refetchPage: (page, _, allPages) => { return page === allPages[0] }, @@ -1136,7 +1215,7 @@ describe('queryClient', () => { await queryClient.resetQueries({ queryKey: key, - inactive: true, + type: 'inactive', refetchPage: (page, _, allPages) => { return page === allPages[0] }, @@ -1149,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() @@ -1175,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() @@ -1199,27 +1305,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/core/tests/queryObserver.test.tsx b/src/core/tests/queryObserver.test.tsx index 1cba155cbd..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, @@ -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/core/tests/utils.test.tsx b/src/core/tests/utils.test.tsx index e237215270..2297c2a6b7 100644 --- a/src/core/tests/utils.test.tsx +++ b/src/core/tests/utils.test.tsx @@ -2,13 +2,12 @@ import { replaceEqualDeep, partialDeepEqual, isPlainObject, - mapQueryStatusFilter, parseMutationArgs, matchMutation, 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' @@ -340,36 +339,16 @@ 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' } + 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 583b34a157..e57e06d79d 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,13 +1,9 @@ import type { MutationState } from './mutation' import type { QueryBehavior, Query } from './query' import type { RetryValue, RetryDelayValue } from './retryer' -import type { QueryFilters } from './utils' - -export type QueryKey = string | readonly unknown[] -export type EnsuredQueryKey = T extends string - ? [T] - : Exclude +import type { QueryFilters, QueryTypeFilter } from './utils' +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 @@ -48,6 +44,8 @@ export interface InfiniteData { export type QueryMeta = Record +export type NetworkMode = 'online' | 'always' | 'offlineFirst' + export interface QueryOptions< TQueryFnData = unknown, TError = unknown, @@ -62,6 +60,7 @@ export interface QueryOptions< */ retry?: RetryValue retryDelay?: RetryDelayValue + networkMode?: NetworkMode cacheTime?: number isDataEqual?: (oldData: TData | undefined, newData: TData) => boolean queryFn?: QueryFunction @@ -140,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' /** @@ -158,13 +157,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`. */ @@ -212,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, @@ -270,8 +278,7 @@ export interface RefetchOptions extends ResultOptions { export interface InvalidateQueryFilters extends QueryFilters, RefetchPageFilters { - refetchActive?: boolean - refetchInactive?: boolean + refetchType?: QueryTypeFilter | 'none' } export interface RefetchQueryFilters @@ -296,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 @@ -310,6 +318,7 @@ export interface QueryObserverBaseResult { isIdle: boolean isLoading: boolean isLoadingError: boolean + isPaused: boolean isPlaceholderData: boolean isPreviousData: boolean isRefetchError: boolean @@ -321,6 +330,7 @@ export interface QueryObserverBaseResult { ) => Promise> remove: () => void status: QueryStatus + fetchStatus: FetchStatus } export interface QueryObserverIdleResult @@ -497,7 +507,7 @@ export type InfiniteQueryObserverResult = | InfiniteQueryObserverRefetchErrorResult | InfiniteQueryObserverSuccessResult -export type MutationKey = string | readonly unknown[] +export type MutationKey = readonly unknown[] export type MutationStatus = 'idle' | 'loading' | 'success' | 'error' @@ -537,6 +547,8 @@ export interface MutationOptions< ) => Promise | void retry?: RetryValue retryDelay?: RetryDelayValue + networkMode?: NetworkMode + cacheTime?: number _defaulted?: boolean meta?: MutationMeta } diff --git a/src/core/utils.ts b/src/core/utils.ts index 1b01474502..0c4c9b0fcf 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,7 +1,7 @@ import type { Mutation } from './mutation' import type { Query } from './query' -import { EnsuredQueryKey } from './types' import type { + FetchStatus, MutationFunction, MutationKey, MutationOptions, @@ -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 */ @@ -38,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 { @@ -68,7 +64,7 @@ export type Updater = | TOutput | DataUpdateFunction -export type QueryStatusFilter = 'all' | 'active' | 'inactive' | 'none' +export type QueryTypeFilter = 'all' | 'active' | 'inactive' // UTILS @@ -91,14 +87,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) } @@ -173,34 +161,14 @@ 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, + type = 'all', exact, - fetching, - inactive, + fetchStatus, predicate, queryKey, stale, @@ -216,16 +184,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 } } @@ -234,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 } @@ -289,17 +256,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() @@ -315,7 +275,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) } /** @@ -420,8 +380,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/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 61% rename from src/createWebStoragePersistor-experimental/tests/storageIsFull.test.ts rename to src/createWebStoragePersister/tests/storageIsFull.test.ts index 2b19a1d45b..d071c5d87d 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 { sleep } from '../../react/tests/utils' -import { createWebStoragePersistor } from '../index' +import { createWebStoragePersister } from '../index' +import { sleep } from '../../reactjs/tests/utils' function getMockStorage(limitSize?: number) { const dataSet = new Map() @@ -33,23 +33,23 @@ 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, }) - 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]) ) @@ -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,54 +71,54 @@ describe('createWebStoragePersistor ', () => { const N = 2000 const storage = getMockStorage(N * 5) // can save 4 items; - const webStoragePersistor = createWebStoragePersistor({ + const webStoragePersister = createWebStoragePersister({ throttleTime: 0, 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', 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') + 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(), 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') + 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() }) @@ -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, }) @@ -137,7 +137,7 @@ describe('createWebStoragePersistor ', () => { mutationCache.build( queryClient, { - mutationKey: 'MUTATIONS', + mutationKey: ['MUTATIONS'], mutationFn: () => Promise.resolve('M'.repeat(N)), }, { @@ -151,25 +151,25 @@ describe('createWebStoragePersistor ', () => { } ) 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', 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( - restoredClient?.clientState.queries.find(q => q.queryKey === 'A') + restoredClient?.clientState.queries.find(q => q.queryKey === ['A']) ).toBeUndefined() }) }) 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 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() - mockNavigatorOnLine(false) + const onlineMock = mockNavigatorOnLine(false) let count = 0 const states: UseMutationResult[] = [] @@ -395,6 +556,7 @@ describe('useMutation', () => { { retry: 1, retryDelay: 5, + networkMode: 'offlineFirst', } ) @@ -437,7 +599,7 @@ describe('useMutation', () => { failureCount: 1, }) - mockNavigatorOnLine(true) + onlineMock.mockReturnValue(true) window.dispatchEvent(new Event('online')) await sleep(50) @@ -456,6 +618,7 @@ describe('useMutation', () => { }) consoleMock.mockRestore() + onlineMock.mockRestore() }) it('should not change state if unmounted', async () => { @@ -583,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/react/tests/useQueries.test.tsx b/src/reactjs/tests/useQueries.test.tsx similarity index 99% rename from src/react/tests/useQueries.test.tsx rename to src/reactjs/tests/useQueries.test.tsx index 305e7af5cd..0b549747dc 100644 --- a/src/react/tests/useQueries.test.tsx +++ b/src/reactjs/tests/useQueries.test.tsx @@ -868,11 +868,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/react/tests/useQuery.test.tsx b/src/reactjs/tests/useQuery.test.tsx similarity index 81% rename from src/react/tests/useQuery.test.tsx rename to src/reactjs/tests/useQuery.test.tsx index 54b1f40a15..b8b04fbc13 100644 --- a/src/react/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, @@ -91,13 +93,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, }) } @@ -173,6 +178,7 @@ describe('useQuery', () => { isFetched: false, isFetchedAfterMount: false, isFetching: true, + isPaused: false, isIdle: false, isLoading: true, isLoadingError: false, @@ -185,6 +191,7 @@ describe('useQuery', () => { refetch: expect.any(Function), remove: expect.any(Function), status: 'loading', + fetchStatus: 'fetching', }) expect(states[1]).toEqual({ @@ -197,6 +204,7 @@ describe('useQuery', () => { isFetched: true, isFetchedAfterMount: true, isFetching: false, + isPaused: false, isIdle: false, isLoading: false, isLoadingError: false, @@ -209,6 +217,7 @@ describe('useQuery', () => { refetch: expect.any(Function), remove: expect.any(Function), status: 'success', + fetchStatus: 'idle', }) }) @@ -233,6 +242,7 @@ describe('useQuery', () => { return (

Status: {state.status}

+
Failure Count: {state.failureCount}
) } @@ -251,6 +261,7 @@ describe('useQuery', () => { isFetched: false, isFetchedAfterMount: false, isFetching: true, + isPaused: false, isIdle: false, isLoading: true, isLoadingError: false, @@ -263,6 +274,7 @@ describe('useQuery', () => { refetch: expect.any(Function), remove: expect.any(Function), status: 'loading', + fetchStatus: 'fetching', }) expect(states[1]).toEqual({ @@ -275,6 +287,7 @@ describe('useQuery', () => { isFetched: false, isFetchedAfterMount: false, isFetching: true, + isPaused: false, isIdle: false, isLoading: true, isLoadingError: false, @@ -287,6 +300,7 @@ describe('useQuery', () => { refetch: expect.any(Function), remove: expect.any(Function), status: 'loading', + fetchStatus: 'fetching', }) expect(states[2]).toEqual({ @@ -299,6 +313,7 @@ describe('useQuery', () => { isFetched: true, isFetchedAfterMount: true, isFetching: false, + isPaused: false, isIdle: false, isLoading: false, isLoadingError: true, @@ -311,6 +326,7 @@ describe('useQuery', () => { refetch: expect.any(Function), remove: expect.any(Function), status: 'error', + fetchStatus: 'idle', }) consoleMock.mockRestore() @@ -370,7 +386,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) @@ -549,6 +568,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[] = [] @@ -570,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 () => { @@ -633,6 +780,7 @@ describe('useQuery', () => { }, { cacheTime: 0, + notifyOnChangeProps: 'all', } ) @@ -815,54 +963,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) @@ -890,46 +996,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) @@ -960,7 +1033,7 @@ describe('useQuery', () => { function Page() { const [, rerender] = React.useState({}) - const state = useQuery(key, () => ++count) + const state = useQuery(key, () => ++count, { notifyOnChangeProps: 'all' }) states.push(state) @@ -1001,7 +1074,7 @@ describe('useQuery', () => { let count = 0 function Page() { - const state = useQuery(key, () => ++count) + const state = useQuery(key, () => ++count, { notifyOnChangeProps: 'all' }) states.push(state) @@ -1052,10 +1125,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) @@ -1428,6 +1505,7 @@ describe('useQuery', () => {

data: {state.data}

error: {state.error?.message}

+

previous data: {state.isPreviousData}

) } @@ -1589,7 +1667,7 @@ describe('useQuery', () => { await sleep(10) return count }, - { enabled: false, keepPreviousData: true } + { enabled: false, keepPreviousData: true, notifyOnChangeProps: 'all' } ) states.push(state) @@ -1678,7 +1756,7 @@ describe('useQuery', () => { await sleep(10) return count }, - { enabled: false, keepPreviousData: true } + { enabled: false, keepPreviousData: true, notifyOnChangeProps: 'all' } ) states.push(state) @@ -1748,7 +1826,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) @@ -1763,7 +1841,7 @@ describe('useQuery', () => { } function SecondComponent() { - useQuery(key, () => 2) + useQuery(key, () => 2, { notifyOnChangeProps: 'all' }) return null } @@ -2324,10 +2402,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 } @@ -3097,10 +3175,8 @@ describe('useQuery', () => { const consoleMock = mockConsoleError() - const originalVisibilityState = document.visibilityState - // make page unfocused - mockVisibilityState('hidden') + const visibilityMock = mockVisibilityState('hidden') let count = 0 @@ -3139,7 +3215,7 @@ describe('useQuery', () => { act(() => { // reset visibilityState to original value - mockVisibilityState(originalVisibilityState) + visibilityMock.mockRestore() window.dispatchEvent(new FocusEvent('focus')) }) @@ -3195,8 +3271,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') @@ -3216,7 +3291,7 @@ describe('useQuery', () => { act(() => { // reset visibilityState to original value - mockVisibilityState(originalVisibilityState) + visibilityMock.mockRestore() window.dispatchEvent(new FocusEvent('focus')) }) @@ -3637,7 +3712,12 @@ describe('useQuery', () => { states.push(queryInfo) - return
count: {queryInfo.data}
+ return ( +
+

count: {queryInfo.data}

+

refetch: {queryInfo.isRefetching}

+
+ ) } const rendered = renderWithClient(queryClient, ) @@ -3718,7 +3798,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)} } @@ -3955,14 +4035,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 } @@ -3984,14 +4063,16 @@ 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 () => { 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) @@ -4195,7 +4276,7 @@ describe('useQuery', () => { count++ return count }, - { staleTime: Infinity, enabled: false } + { staleTime: Infinity, enabled: false, notifyOnChangeProps: 'all' } ) states.push(state) @@ -4458,4 +4539,732 @@ 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() + }) + }) + + 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/react/tests/utils.tsx b/src/reactjs/tests/utils.tsx similarity index 74% rename from src/react/tests/utils.tsx rename to src/reactjs/tests/utils.tsx index 314c3183be..39d41cb36b 100644 --- a/src/react/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( @@ -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() { @@ -37,9 +31,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 { @@ -83,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() +} diff --git a/src/react/types.ts b/src/reactjs/types.ts similarity index 98% rename from src/react/types.ts rename to src/reactjs/types.ts index 79aa3d4507..3d2079d110 100644 --- a/src/react/types.ts +++ b/src/reactjs/types.ts @@ -10,6 +10,7 @@ import { MutationFunction, MutateOptions, MutationMeta, + NetworkMode, } from '../core/types' export interface UseBaseQueryOptions< @@ -76,6 +77,7 @@ export interface UseMutationOptions< > { mutationFn?: MutationFunction mutationKey?: MutationKey + cacheTime?: number onMutate?: ( variables: TVariables ) => Promise | TContext | undefined @@ -97,6 +99,7 @@ export interface UseMutationOptions< ) => Promise | void retry?: RetryValue retryDelay?: RetryDelayValue + networkMode?: NetworkMode useErrorBoundary?: boolean | ((error: TError) => boolean) meta?: MutationMeta } diff --git a/src/react/useBaseQuery.ts b/src/reactjs/useBaseQuery.ts similarity index 88% rename from src/react/useBaseQuery.ts rename to src/reactjs/useBaseQuery.ts index 3adb64e053..a5f4d5f5dd 100644 --- a/src/react/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 @@ -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) { @@ -133,17 +127,13 @@ export function useBaseQuery< result.isError && !errorResetBoundary.isReset() && !result.isFetching && - shouldThrowError( - defaultedOptions.suspense, - defaultedOptions.useErrorBoundary, - result.error - ) + shouldThrowError(defaultedOptions.useErrorBoundary, result.error) ) { throw result.error } // Handle result property usage tracking - if (defaultedOptions.notifyOnChangeProps === 'tracked') { + if (!defaultedOptions.notifyOnChangeProps) { result = observer.trackResult(result, defaultedOptions) } 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 98% rename from src/react/useMutation.ts rename to src/reactjs/useMutation.ts index 264950c080..5e48928726 100644 --- a/src/react/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/react/useQueries.ts b/src/reactjs/useQueries.ts similarity index 97% rename from src/react/useQueries.ts rename to src/reactjs/useQueries.ts index cd2d5d0ad3..339f6a0937 100644 --- a/src/react/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/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/reactjs/utils.ts b/src/reactjs/utils.ts new file mode 100644 index 0000000000..955afb6cf2 --- /dev/null +++ b/src/reactjs/utils.ts @@ -0,0 +1,11 @@ +export function shouldThrowError( + _useErrorBoundary: boolean | ((err: TError) => boolean), + error: TError +): boolean { + // Allow useErrorBoundary function to override throwing behavior on a per-error basis + if (typeof _useErrorBoundary === 'function') { + return _useErrorBoundary(error) + } + + return _useErrorBoundary +} 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 diff --git a/tsconfig.types.json b/tsconfig.types.json index f95e1f4c19..6f6510ecd5 100644 --- a/tsconfig.types.json +++ b/tsconfig.types.json @@ -11,11 +11,10 @@ }, "files": [ "./src/index.ts", - "./src/hydration/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" ],