From d35efae674442fa5b7a378a9853275eb991dd221 Mon Sep 17 00:00:00 2001 From: Niek Date: Mon, 21 Sep 2020 08:30:44 +0200 Subject: [PATCH] feat: initial v3 changes --- .github/workflows/test-and-publish.yml | 2 +- .gitignore | 4 +- core/package.json | 6 + docs/src/pages/docs/api.md | 681 +++++++-------- docs/src/pages/docs/comparison.md | 1 + .../docs/guides/migrating-to-react-query-3.md | 273 ++++++ docs/src/pages/docs/guides/queries.md | 30 + docs/src/pages/docs/guides/suspense.md | 14 +- docs/src/pages/docs/overview.md | 14 +- docs/src/pages/docs/quick-start.md | 17 +- hydration.d.ts | 1 - hydration.js | 5 - hydration/package.json | 6 + index.js | 5 - jest.config.js | 2 +- package.json | 15 +- react/package.json | 6 + rollup.config.js | 109 --- src/core/config.ts | 153 +--- src/core/index.ts | 24 +- src/core/notifyManager.ts | 21 +- src/core/queriesObserver.ts | 133 +++ src/core/query.ts | 454 +++++----- src/core/queryCache.ts | 534 ++---------- src/core/queryClient.ts | 336 ++++++++ src/core/queryObserver.ts | 309 +++---- src/core/setConsole.ts | 27 + src/core/setFocusHandler.ts | 6 +- src/core/setOnlineHandler.ts | 4 +- src/core/tests/queryCache.test.tsx | 736 +++++++++++----- src/core/tests/utils.test.tsx | 7 +- src/core/types.ts | 239 ++---- src/core/utils.ts | 229 +++-- src/hydration/hydration.ts | 44 +- src/hydration/react.tsx | 10 +- src/hydration/tests/hydration.test.tsx | 207 +++-- src/hydration/tests/react.test.tsx | 77 +- src/hydration/tests/ssr.test.tsx | 105 +-- src/index.ts | 8 +- src/react/Console.native.ts | 5 + src/react/Console.ts | 1 + src/react/QueryClientProvider.tsx | 31 + ...undary.tsx => QueryErrorResetBoundary.tsx} | 12 +- src/react/ReactQueryCacheProvider.tsx | 47 -- src/react/ReactQueryConfigProvider.tsx | 35 - src/react/index.ts | 33 +- src/react/tests/QueryClientProvider.test.tsx | 130 +++ ...t.tsx => QueryResetErrorBoundary.test.tsx} | 32 +- .../tests/ReactQueryCacheProvider.test.tsx | 235 ------ .../tests/ReactQueryConfigProvider.test.tsx | 247 ------ src/react/tests/ssr.test.tsx | 307 +++---- src/react/tests/suspense.test.tsx | 94 ++- src/react/tests/useInfiniteQuery.test.tsx | 130 ++- src/react/tests/useIsFetching.test.tsx | 17 +- src/react/tests/useMutation.test.tsx | 26 +- src/react/tests/usePaginatedQuery.test.tsx | 298 ------- src/react/tests/useQueries.test.tsx | 33 + src/react/tests/useQuery.test.tsx | 788 ++++++++++-------- src/react/tests/utils.tsx | 9 +- src/react/types.ts | 57 ++ src/react/useBaseQuery.ts | 82 +- src/react/useInfiniteQuery.ts | 92 +- src/react/useIsFetching.ts | 12 +- src/react/useMutation.ts | 159 ++-- src/react/usePaginatedQuery.ts | 71 -- src/react/useQueries.ts | 39 + src/react/useQuery.ts | 81 +- src/react/utils.ts | 3 +- tsconfig.json | 6 +- 69 files changed, 3912 insertions(+), 4054 deletions(-) create mode 100644 core/package.json create mode 100644 docs/src/pages/docs/guides/migrating-to-react-query-3.md delete mode 100644 hydration.d.ts delete mode 100644 hydration.js create mode 100644 hydration/package.json delete mode 100644 index.js create mode 100644 react/package.json create mode 100644 src/core/queriesObserver.ts create mode 100644 src/core/queryClient.ts create mode 100644 src/core/setConsole.ts create mode 100644 src/react/Console.native.ts create mode 100644 src/react/Console.ts create mode 100644 src/react/QueryClientProvider.tsx rename src/react/{ReactQueryErrorResetBoundary.tsx => QueryErrorResetBoundary.tsx} (63%) delete mode 100644 src/react/ReactQueryCacheProvider.tsx delete mode 100644 src/react/ReactQueryConfigProvider.tsx create mode 100644 src/react/tests/QueryClientProvider.test.tsx rename src/react/tests/{ReactQueryResetErrorBoundary.test.tsx => QueryResetErrorBoundary.test.tsx} (82%) delete mode 100644 src/react/tests/ReactQueryCacheProvider.test.tsx delete mode 100644 src/react/tests/ReactQueryConfigProvider.test.tsx delete mode 100644 src/react/tests/usePaginatedQuery.test.tsx create mode 100644 src/react/tests/useQueries.test.tsx create mode 100644 src/react/types.ts delete mode 100644 src/react/usePaginatedQuery.ts create mode 100644 src/react/useQueries.ts diff --git a/.github/workflows/test-and-publish.yml b/.github/workflows/test-and-publish.yml index 9a6927fd50..eaffcbf1bd 100644 --- a/.github/workflows/test-and-publish.yml +++ b/.github/workflows/test-and-publish.yml @@ -38,7 +38,7 @@ jobs: registry-url: https://registry.npmjs.org/ - name: Install dependencies uses: bahmutov/npm-install@v1 - - run: yarn build && yarn build:types + - run: yarn build - run: npx semantic-release@17 env: NODE_AUTH_TOKEN: ${{secrets.npm_token}} diff --git a/.gitignore b/.gitignore index d96cdc6640..e50ed1d73a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules # builds +types build dist lib @@ -12,6 +13,7 @@ es artifacts .rpt2_cache coverage +*.tgz # misc .DS_Store @@ -31,5 +33,3 @@ stats-hydration.json stats-react.json stats.html .vscode/settings.json - -types diff --git a/core/package.json b/core/package.json new file mode 100644 index 0000000000..5f875365e0 --- /dev/null +++ b/core/package.json @@ -0,0 +1,6 @@ +{ + "internal": true, + "main": "../lib/core/index.js", + "module": "../es/core/index.js", + "types": "../types/core/index.d.ts" +} diff --git a/docs/src/pages/docs/api.md b/docs/src/pages/docs/api.md index 72844fa922..2818cf37c5 100644 --- a/docs/src/pages/docs/api.md +++ b/docs/src/pages/docs/api.md @@ -25,7 +25,6 @@ const { cacheTime, enabled, initialData, - initialStale, isDataEqual, keepPreviousData, notifyOnStatusChange, @@ -52,52 +51,52 @@ const { const queryInfo = useQuery({ queryKey, queryFn, - config, + enabled, }) ``` **Options** -- `queryKey: String | any[]` +- `queryKey: string | unknown[]` - **Required** - The query key to use for this query. - If a string is passed, it will be used as the query key. - If an array is passed, each item will be serialized into a stable query key. See [Query Keys](./guides/queries#query-keys) for more information. - The query will automatically update when this key changes (as long as `enabled` is not set to `false`). -- `queryFn: Function(variables) => Promise(data/error)` +- `queryFn: (...params: unknown[]) => Promise` - **Required, but only if no default query function has been defined** - The function that the query will use to request data. - Receives the following variables in the order that they are provided: - - Query Key Variables + - Query Key parameters - Must return a promise that will either resolves data or throws an error. -- `enabled: Boolean | unknown` +- `enabled: boolean | unknown` - Set this to `false` to disable this query from automatically running. - Actually it can be anything that will pass a boolean condition. See [Dependent Queries](./guides/queries#dependent-queries) for more information. -- `retry: Boolean | Int | Function(failureCount, error) => shouldRetry | Boolean` +- `retry: boolean | number | (failureCount: number, error: TError) => boolean` - If `false`, failed queries will not retry by default. - If `true`, failed queries will retry infinitely. - - If set to an `Int`, e.g. `3`, failed queries will retry until the failed query count meets that number. -- `retryDelay: Function(retryAttempt: Int) => Int` + - If set to an `number`, e.g. `3`, failed queries will retry until the failed query count meets that number. +- `retryDelay: (retryAttempt: number) => number` - This function receives a `retryAttempt` integer and returns the delay to apply before the next attempt in milliseconds. - A function like `attempt => Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000)` applies exponential backoff. - A function like `attempt => attempt * 1000` applies linear backoff. -- `staleTime: Int | Infinity` - - The time in milliseconds after data is considered stale. +- `staleTime: number | Infinity` + - The time in milliseconds after data is considered stale. This only applies to the hook it is defined on. - If set to `Infinity`, query will never go stale -- `cacheTime: Int | Infinity` - - The time in milliseconds that unused/inactive cache data remains in memory. When a query's cache becomes unused or inactive, that cache data will be garbage collected after this duration. +- `cacheTime: number | Infinity` + - The time in milliseconds that unused/inactive cache data remains in memory. When a query'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 -- `refetchInterval: false | Integer` +- `refetchInterval: false | number` - Optional - If set to a number, all queries will continuously refetch at this frequency in milliseconds -- `refetchIntervalInBackground: Boolean` +- `refetchIntervalInBackground: boolean` - Optional - If set to `true`, queries that are set to continuously refetch with a `refetchInterval` will continue to refetch while their tab/window is in the background - `refetchOnMount: boolean | "always"` - Optional - Defaults to `true` - If set to `true`, the query will refetch on mount if the data is stale. - - If set to `false`, will disable additional instances of a query to trigger background refetches. + - If set to `false`, the query will not refetch on mount. - If set to `"always"`, the query will always refetch on mount. - `refetchOnWindowFocus: boolean | "always"` - Optional @@ -111,41 +110,41 @@ const queryInfo = 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. -- `notifyOnStatusChange: Boolean` +- `notifyOnStatusChange: boolean` - Optional - Set this to `false` to only re-render when there are changes to `data` or `error`. - Defaults to `true`. -- `onSuccess: Function(data) => data` +- `onSuccess: (data: TData) => void` - Optional - This function will fire any time the query successfully fetches new data. -- `onError: Function(err) => void` +- `onError: (error: TError) => void` - Optional - This function will fire if the query encounters an error and will be passed the error. -- `onSettled: Function(data, error) => data` +- `onSettled: (data?: TData, error?: TError) => void` - Optional - This function will fire any time the query is either successfully fetched or errors and be passed either the data or error -- `suspense: Boolean` +- `select: (data: TData) => unknown` + - Optional + - This option can be used to transform or select a part of the data returned by the query function. +- `suspense: boolean` - Optional - Set this to `true` to enable suspense mode. - When `true`, `useQuery` will suspend when `status === 'loading'` - When `true`, `useQuery` will throw runtime errors when `status === 'error'` -- `initialData: any | Function() => any` +- `initialData: unnown | () => unknown` - Optional - If set, this value will be used as the initial data for the query cache (as long as the query hasn't been created or cached yet) - If set to a function, the function will be called **once** during the shared/root query initialization, and be expected to synchronously return the initialData -- `initialStale: Boolean | Function() => Boolean` - - Optional - - If set, this will mark any `initialData` provided as stale and will likely cause it to be refetched on mount - - If a function is passed, it will be called only when appropriate to resolve the `initialStale` value. This can be useful if your `initialStale` value is costly to calculate. -- `keepPreviousData: Boolean` + - Initial data is considered stale by default unless a `staleTime` has been set. +- `keepPreviousData: boolean` - Optional - Defaults to `false` - If set, any previous `data` will be kept when fetching new data because the query key changed. -- `queryFnParamsFilter: Function(args) => filteredArgs` +- `queryFnParamsFilter: (...params: unknown[]) => unknown[]` - Optional - This function will filter the params that get passed to `queryFn`. - - For example, you can filter out the first query key from the params by using `queryFnParamsFilter: args => args.slice(1)`. -- `structuralSharing: Boolean` + - For example, you can filter out the first query key from the params by using `queryFnParamsFilter: params => params.slice(1)`. +- `structuralSharing: boolean` - Optional - Defaults to `true` - If set to `false`, structural sharing between query results will be disabled. @@ -158,69 +157,58 @@ const queryInfo = useQuery({ - `loading` if the query is in a "hard" loading state. This means there is no cached data and the query is currently fetching, eg `isFetching === true` - `error` if the query attempt resulted in an error. The corresponding `error` property has the error received from the attempted fetch - `success` if the query has received a response with no errors and is ready to display its data. The corresponding `data` property on the query is the data received from the successful fetch or if the query is in `manual` mode and has not been fetched yet `data` is the first `initialData` supplied to the query on initialization. -- `isIdle: Boolean` +- `isIdle: boolean` - A derived boolean from the `status` variable above, provided for convenience. -- `isLoading: Boolean` +- `isLoading: boolean` - A derived boolean from the `status` variable above, provided for convenience. -- `isSuccess: Boolean` +- `isSuccess: boolean` - A derived boolean from the `status` variable above, provided for convenience. -- `isError: Boolean` +- `isError: boolean` - A derived boolean from the `status` variable above, provided for convenience. -- `data: Any` +- `data: TData` - Defaults to `undefined`. - The last successfully resolved data for the query. -- `error: null | Error` +- `error: null | TError` - Defaults to `null` - The error object for the query, if an error was thrown. -- `isStale: Boolean` - - Will be `true` if the cache data is stale. -- `isPreviousData: Boolean` +- `isStale: boolean` + - Will be `true` if the data in the cache is invalidated or if the data is older than the given `staleTime`. +- `isPreviousData: boolean` - Will be `true` when `keepPreviousData` is set and data from the previous query is returned. -- `isFetchedAfterMount: Boolean` +- `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. -- `isFetching: Boolean` +- `isFetching: boolean` - Defaults to `true` so long as `manual` is set to `false` - Will be `true` if the query is currently fetching, including background fetching. -- `failureCount: Integer` +- `failureCount: number` - The failure count for the query. - Incremented every time the query fails. - Reset to `0` when the query succeeds. -- `refetch: Function({ throwOnError }) => Promise` +- `refetch: (options: { throwOnError: 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 -- `remove: Function() => void` +- `remove: () => void` - A function to remove the query from the cache. -## `usePaginatedQuery` +## `useQueries` + +The `useQueries` hook can be used to fetch a variable number of queries: ```js -const { - data = undefined, - resolvedData, - latestData, - ...queryInfo -} = usePaginatedQuery(queryKey, queryFn, options) +const results = useQueries([ + { queryKey: ['post', 1], queryFn: fetchPost }, + { queryKey: ['post', 2], queryFn: fetchPost }, +]) ``` **Options** -The options for `usePaginatedQuery` are identical to the [`useQuery` hook](#usequery) +The `useQueries` hook accepts an array with query option objects identical to the [`useQuery` hook](#usequery). **Returns** -The returned properties for `usePaginatedQuery` are identical to the [`useQuery` hook](#usequery), with the addition of the following: - -- `data: undefined` - - The standard `data` property is not used for paginated queries and is replaced by the `resolvedData` and `latestData` options below. -- `resolvedData: Any` - - Defaults to `undefined`. - - The last successfully resolved data for the query. - - When fetching based on a new query key, the value will resolve to the last known successful value, regardless of query key -- `latestData: Any` - - Defaults to `undefined`. - - The actual data object for this query and its specific query key - - When fetching an uncached query, this value will be `undefined` +The `useQueries` hook returns an array with all the query results. ## `useInfiniteQuery` @@ -243,7 +231,7 @@ const { The options for `useInfiniteQuery` are identical to the [`useQuery` hook](#usequery) with the addition of the following: -- `getFetchMore: Function(lastPage, allPages) => fetchMoreVariable | Boolean` +- `getFetchMore: (lastPage, allPages) => fetchMoreVariable | boolean` - When new data is received for this query, this function receives both the last page of the infinite list of data and the full array of all pages. - It should return a **single variable** that will be passed as the last optional parameter to your query function @@ -253,10 +241,10 @@ The returned properties for `useInfiniteQuery` are identical to the [`useQuery` - `isFetchingMore: false | 'next' | 'previous'` - If using `paginated` mode, this will be `true` when fetching more results using the `fetchMore` function. -- `fetchMore: Function(fetchMoreVariableOverride) => Promise` +- `fetchMore: (fetchMoreVariableOverride) => Promise` - This function allows you to fetch the next "page" of results. - `fetchMoreVariableOverride` allows you to optionally override the fetch more variable returned from your `getFetchMore` option to your query function to retrieve the next page of results. -- `canFetchMore: Boolean` +- `canFetchMore: boolean` - If using `paginated` mode, this will be `true` if there is more data to be fetched (known via the required `getFetchMore` option function). ## `useMutation` @@ -284,26 +272,26 @@ const promise = mutate(variables, { **Options** -- `mutationFn: Function(variables) => Promise` +- `mutationFn: (variables) => Promise` - **Required** - A function that performs an asynchronous task and returns a promise. - `variables` is an object that `mutate` will pass to your `mutationFn` -- `onMutate: Function(variables) => Promise | snapshotValue` +- `onMutate: (variables) => Promise | snapshotValue` - Optional - This function will fire before the mutation function is fired and is passed the same variables the mutation function would receive - Useful to perform optimistic updates to a resource in hopes that the mutation succeeds - The value returned from this function will be passed to both the `onError` and `onSettled` functions in the event of a mutation failure and can be useful for rolling back optimistic updates. -- `onSuccess: Function(data, variables) => Promise | undefined` +- `onSuccess: (data, variables) => Promise | undefined` - Optional - This function will fire when the mutation is successful and will be passed the mutation's result. - Fires after the `mutate`-level `onSuccess` handler (if it is defined) - If a promise is returned, it will be awaited and resolved before proceeding -- `onError: Function(err, variables, onMutateValue) => Promise | undefined` +- `onError: (err, variables, onMutateValue) => Promise | undefined` - Optional - This function will fire if the mutation encounters an error and will be passed the error. - Fires after the `mutate`-level `onError` handler (if it is defined) - If a promise is returned, it will be awaited and resolved before proceeding -- `onSettled: Function(data, error, variables, onMutateValue) => Promise | undefined` +- `onSettled: (data, error, variables, onMutateValue) => Promise | undefined` - Optional - This function will fire when the mutation is either successfully fetched or encounters an error and be passed either the data or error - Fires after the `mutate`-level `onSettled` handler (if it is defined) @@ -317,7 +305,7 @@ const promise = mutate(variables, { **Returns** -- `mutate: Function(variables, { onSuccess, onSettled, onError, throwOnError }) => Promise` +- `mutate: (variables, { onSuccess, onSettled, onError, throwOnError }) => Promise` - The mutation function you can call with variables to trigger the mutation and optionally override the original mutation options. - `variables: any` - Optional @@ -331,151 +319,150 @@ const promise = 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` -- `data: undefined | Any` +- `data: undefined | unknown` - Defaults to `undefined` - The last successfully resolved data for the query. -- `error: null | Error` +- `error: null | TError` - The error object for the query, if an error was encountered. -- `reset: Function() => void` +- `reset: () => void` - A function to clean the mutation internal state (i.e., it resets the mutation to its initial state). -## `QueryCache` +## `QueryClient` -The `QueryCache` is the backbone of React Query that manages all of the state, caching, lifecycle and magic of every query. It supports relatively unrestricted, but safe, access to manipulate query's as you need. +The `QueryClient` can be used to interact with a cache: ```js -import { QueryCache } from 'react-query' +import { QueryClient, QueryCache } from 'react-query' -const queryCache = new QueryCache({ - defaultConfig: { +const cache = new QueryCache() +const client = new QueryClient({ + cache, + defaultOptions: { queries: { staleTime: Infinity, }, }, }) + +await client.prefetchQuery('posts', fetchPosts) ``` -Its available properties and methods are: +Its available methods are: -- [`fetchQuery`](#querycachefetchquery) -- [`prefetchQuery`](#querycacheprefetchquery) -- [`getQueryData`](#querycachegetquerydata) -- [`setQueryData`](#querycachesetquerydata) -- [`refetchQueries`](#querycacherefetchqueries) -- [`invalidateQueries`](#querycacheinvalidatequeries) -- [`cancelQueries`](#querycachecancelqueries) -- [`removeQueries`](#querycacheremovequeries) -- [`getQuery`](#querycachegetquery) -- [`getQueries`](#querycachegetqueries) -- [`isFetching`](#querycacheisfetching) -- [`subscribe`](#querycachesubscribe) -- [`clear`](#querycacheclear) +- [`fetchQueryData`](#clientfetchquerydata) +- [`prefetchQuery`](#clientprefetchquery) +- [`getQueryData`](#clientgetquerydata) +- [`setQueryData`](#clientsetquerydata) +- [`refetchQueries`](#clientrefetchqueries) +- [`invalidateQueries`](#clientinvalidatequeries) +- [`cancelQueries`](#clientcancelqueries) +- [`removeQueries`](#clientremovequeries) +- [`watchQuery`](#clientwatchquery) +- [`watchQueries`](#clientwatchqueries) +- [`isFetching`](#queryclientisfetching) +- [`setQueryDefaults`](#clientsetquerydefaults) **Options** -- `defaultConfig: QueryQueryConfig` +- `cache: QueryCache` + - The query cache this client is connected to. +- `defaultOptions: DefaultOptions` - Optional - - Define defaults for all queries and mutations using this query cache. + - Define defaults for all queries and mutations using this query client. -## `queryCache.fetchQuery` +## `client.fetchQueryData` -`fetchQuery` is an asynchronous method that can be used to fetch and cache a query. It will either resolve with the data or throw with the error. Specify a `staleTime` to only trigger a fetch when the data is stale. Use the `prefetchQuery` method if you just want to fetch a query without needing the result. +`fetchQueryData` is an asynchronous method that can be used to fetch and cache a query. It will either resolve with the data or throw with the error. Specify a `staleTime` to only trigger a fetch when the data is stale. Use the `prefetchQuery` method if you just want to fetch a query without needing the result. + +If the query exists and the data is not invalidated and also not older than the given `staleTime`, then the data from the cache will be returned. Otherwise it will try to fetch the latest data. + +> The difference between using `fetchQueryData` and `setQueryData` is that `fetchQueryData` is async and will ensure that duplicate requests for this query are not created with `useQuery` instances for the same query are rendered while the data is fetching. ```js try { - const data = await queryCache.fetchQuery(queryKey, queryFn) + const data = await client.fetchQueryData(queryKey, queryFn) } catch (error) { console.log(error) } ``` -**Returns** +Set a stale time to only fetch when the data is older than the specified time: -- `Promise` +```js +try { + const data = await client.fetchQueryData(queryKey, queryFn, { + staleTime: 10000, + }) +} catch (error) { + console.log(error) +} +``` -## `queryCache.prefetchQuery` +**Options** -`prefetchQuery` is an asynchronous method that can be used to fetch and cache a query response before it is needed or rendered with `useQuery` and friends. +The options for `fetchQueryData` are exactly the same as those of [`useQuery`](#usequery). -- If either: - - The query does not exist or - - The query exists but the data is stale - - The queryFn will be called, the data resolved, the cache populated and the data returned via promise. -- If you want to force the query to prefetch regardless of the data being stale, you can pass the `force: true` option in the options object -- If the query exists, and the data is NOT stale, the existing data in the cache will be returned via promise +**Returns** -> The difference between using `prefetchQuery` and `setQueryData` is that `prefetchQuery` is async and will ensure that duplicate requests for this query are not created with `useQuery` instances for the same query are rendered while the data is fetching. +- `Promise` -```js -await queryCache.prefetchQuery(queryKey, queryFn) -``` +## `client.prefetchQuery` -To pass options like `force` or `throwOnError`, use the fourth options object: +`prefetchQuery` is an asynchronous method that can be used to prefetch a query before it is needed or rendered with `useQuery` and friends. The method works the same as `fetchQueryData` except that is will not throw or return any data. ```js -await queryCache.prefetchQuery(queryKey, queryFn, config, { - force: true, - throwOnError: true, -}) +await client.prefetchQuery(queryKey, queryFn) ``` You can even use it with a default queryFn in your config! ```js -await queryCache.prefetchQuery(queryKey) +await client.prefetchQuery(queryKey) ``` **Options** -The options for `prefetchQuery` are exactly the same as those of [`useQuery`](#usequery) with the exception of the last options object: - -- `force: Boolean` - - Set this `true` if you want `prefetchQuery` to fetch the data even if the data exists and is NOT stale. -- `throwOnError: Boolean` - - Set this `true` if you want `prefetchQuery` to throw an error when it encounters errors. +The options for `prefetchQuery` are exactly the same as those of [`useQuery`](#usequery). **Returns** -- `Promise` - - A promise is returned that will either immediately resolve with the query's cached response data, or resolve to the data returned by the fetch function. It **will not** throw an error if the fetch fails. This can be configured by setting the `throwOnError` option to `true`. +- `Promise` + - A promise is returned that will either immediately resolve if no fetch is needed or after the query has been executed. It will not return any data or throw any errors. -## `queryCache.getQueryData` +## `client.getQueryData` `getQueryData` is a synchronous function that can be used to get an existing query's cached data. If the query does not exist, `undefined` will be returned. ```js -const data = queryCache.getQueryData(queryKey) +const data = client.getQueryData(queryKey) ``` **Options** -- `queryKey: QueryKey` - - See [Query Keys](./guides/queries#query-keys) for more information on how to construct and use a query key +- `queryKey?: QueryKey`: [Query Keys](#./guides/queries#query-keys) +- `filters?: QueryFilters`: [Query Filters](./guides/queries#query-filters) **Returns** -- `data: any | undefined` +- `data: TData | undefined` - The data for the cached query, or `undefined` if the query does not exist. -## `queryCache.setQueryData` +## `client.setQueryData` -`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 and immediately be marked as stale. **If the query is not utilized by a query hook in the default `cacheTime` of 5 minutes, the query will be garbage collected**. +`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**. -> The difference between using `setQueryData` and `prefetchQuery` 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 `prefetchQuery` to handle the asynchronous fetch. +> The difference between using `setQueryData` and `fetchQueryData` 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 `fetchQueryData` to handle the asynchronous fetch. ```js -queryCache.setQueryData(queryKey, updater, config) +client.setQueryData(queryKey, updater) ``` **Options** -- `queryKey: QueryKey` - - See [Query Keys](./guides/queries#query-keys) for more information on how to construct and use a query key -- `updater: Any | Function(oldData) => newData` +- `queryKey: QueryKey` [Query Keys](./guides/queries#query-keys) +- `updater: unknown | (oldData: TData | undefined) => TData` - If non-function is passed, the data will be updated to this value - If a function is passed, it will receive the old data value and be expected to return a new one. -- `config: object` - - The standard query config object use in [`useQuery`](#usequery) **Using an updater value** @@ -491,7 +478,36 @@ For convenience in syntax, you can also pass an updater function which receives setQueryData(queryKey, oldData => newData) ``` -## `queryCache.refetchQueries` +## `client.invalidateQueries` + +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 + +```js +await queryCache.invalidateQueries('posts', { + exact, + refetchActive = true, + refetchInactive = false +}, { throwOnError }) +``` + +**Options** + +- `queryKey?: QueryKey`: [Query Keys](#./guides/queries#query-keys) +- `filters?: QueryFilters`: [Query Filters](./guides/queries#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 +- `refetchOptions?: RefetchOptions`: + - `throwOnError?: boolean` + - When set to `true`, this method will throw if any of the query refetch tasks fail. + +## `client.refetchQueries` The `refetchQueries` method can be used to refetch queries based on certain conditions. @@ -499,201 +515,212 @@ Examples: ```js // refetch all queries: -await queryCache.refetchQueries() +await client.refetchQueries() // refetch all stale queries: -await queryCache.refetchQueries([], { stale: true }) +await client.refetchQueries({ stale: true }) -// refetch all stale and active queries: -await queryCache.refetchQueries([], { stale: true, active: true }) +// refetch all active queries partially matching a query key: +await client.refetchQueries(['posts'], { active: true }) -// refetch all queries partially matching a query key: -await queryCache.refetchQueries(['posts']) - -// refetch all queries exactly matching a query key: -await queryCache.refetchQueries(['posts', 1], { exact: true }) +// refetch all active queries exactly matching a query key: +await client.refetchQueries(['posts', 1], { active: true, exact: true }) ``` **Options** -- `queryKeyOrPredicateFn` can either be a [Query Key](#query-keys) or a `Function` - - `queryKey: QueryKey` - - If a query key is passed, queries will be filtered to those where this query key is included in the existing query's query key. This means that if you passed a query key of `'todos'`, it would match queries with the `todos`, `['todos']`, and `['todos', 5]`. See [Query Keys](./guides/queries#query-keys) for more information. - - `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`. - - The `exact` option has no effect when using a function -- `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. Remember to destructure it out of the array! -- `active?: boolean` - - When set to `true` it will refetch active queries. - - When set to `false` it will refetch inactive queries. -- `stale?: boolean` - - When set to `true` it will match on stale queries. - - When set to `false` it will match on fresh queries. -- `throwOnError?: boolean` - - When set to `true`, this method will throw if any of the query refetch tasks fail. +- `queryKey?: QueryKey`: [Query Keys](#./guides/queries#query-keys) +- `filters?: QueryFilters`: [Query Filters](./guides/queries#query-filters) +- `refetchOptions?: RefetchOptions`: + - `throwOnError?: boolean` + - When set to `true`, this method will throw if any of the query refetch tasks fail. **Returns** This function returns a promise that will resolve when all of the queries are done being refetched. By default, it **will not** throw an error if any of those queries refetches fail, but this can be configured by setting the `throwOnError` option to `true` -## `queryCache.invalidateQueries` +## `client.cancelQueries` -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 stale and active queries are refetched in the background. +The `cancelQueries` method can be used to cancel outgoing queries based on their query keys or any other functionally accessible property/state of the query. -- If you **do not want active queries to refetch**, and simply be marked as stale, you can use the `refetchActive: false` option. -- If you **want inactive queries to refetch** as well, use the `refetchInactive: true` option +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 -const queries = queryCache.invalidateQueries(inclusiveQueryKeyOrPredicateFn, { - exact, - throwOnError, - refetchActive = true, - refetchInactive = false -}) +await client.cancelQueries('posts', { exact: true }) ``` **Options** -- `queryKeyOrPredicateFn` can either be a [Query Key](#query-keys) or a `function` - - `queryKey: QueryKey` - - If a query key is passed, queries will be filtered to those where this query key is included in the existing query's query key. This means that if you passed a query key of `'todos'`, it would match queries with the `todos`, `['todos']`, and `['todos', 5]`. See [Query Keys](./guides/queries#query-keys) for more information. - - `Function(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`. - - The `exact` option has no effect with using a function -- `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. Remember to destructure it out of the array! -- `throwOnError: Boolean` - - When set to `true`, this function will throw if any of the query refetch tasks fail. -- `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 stale. -- `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 stale and also refetched in the background +- `queryKey?: QueryKey`: [Query Keys](#./guides/queries#query-keys) +- `filters?: QueryFilters`: [Query Filters](./guides/queries#query-filters) **Returns** -This function returns a promise that will resolve when all of the queries are done being refetched. By default, it **will not** throw an error if any of those queries refetches fail, but this can be configured by setting the `throwOnError` option to `true` +This method does not return anything -## `queryCache.cancelQueries` +## `client.removeQueries` -The `cancelQueries` method can be used to cancel outgoing queries based on their query keys or any other functionally accessible property/state of the query. +The `removeQueries` method can be used to remove queries from the cache based on their query keys or any other functionally accessible property/state of the query. -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 +client.removeQueries(queryKey, { exact: true }) +``` + +**Options** + +- `queryKey?: QueryKey`: [Query Keys](#./guides/queries#query-keys) +- `filters?: QueryFilters`: [Query Filters](./guides/queries#query-filters) + +**Returns** + +This method does not return anything + +## `client.watchQuery` + +The `watchQuery` method returns a `QueryObserver` instance which can be used to watch a query. ```js -queryCache.cancelQueries(queryKeyOrPredicateFn, { - exact, +const observer = client.watchQuery('posts') + +observer.subscribe(result => { + console.log(result) + observer.unsubscribe() }) ``` **Options** -- `queryKeyOrPredicateFn` can either be a [Query Key](#query-keys) or a `function` - - `queryKey` - - If a query key is passed, queries will be filtered to those where this query key is included in the existing query's query key. This means that if you passed a query key of `'todos'`, it would match queries with the `todos`, `['todos']`, and `['todos', 5]`. See [Query Keys](./guides/queries#query-keys) for more information. - - `Function(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`. - - The `exact` option has no effect with using a function -- `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. Remember to destructure it out of the array! +The options for `watchQuery` are exactly the same as those of [`useQuery`](#usequery). **Returns** -This function does not return anything +- `QueryObserver` -## `queryCache.removeQueries` +## `client.watchQueries` -The `removeQueries` method can be used to remove queries from the cache based on their query keys or any other functionally accessible property/state of the query. +The `watchQueries` method returns a `QueriesObserver` instance to watch multiple queries. ```js -queryCache.removeQueries(queryKeyOrPredicateFn, { - exact, +const observer = client.watchQueries([ + { queryKey: ['post', 1], queryFn: fetchPost }, + { queryKey: ['post', 2], queryFn: fetchPost }, +]) + +observer.subscribe(result => { + console.log(result) + observer.unsubscribe() }) ``` **Options** -- `queryKeyOrPredicateFn` can either be a [Query Key](#query-keys) or a `function` - - `queryKey` - - If a query key is passed, queries will be filtered to those where this query key is included in the existing query's query key. This means that if you passed a query key of `'todos'`, it would match queries with the `todos`, `['todos']`, and `['todos', 5]`. See [Query Keys](./guides/queries#query-keys) for more information. - - `Function(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`. - - The `exact` option has no effect with using a function -- `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. Remember to destructure it out of the array! +The options for `watchQueries` are exactly the same as those of [`useQueries`](#usequeries). **Returns** -This function does not return anything +- `QueriesObserver` -## `queryCache.getQuery` +## `client.isFetching` -`getQuery` is a slightly more advanced synchronous function that can be used to get an existing query object from the cache. This object not only contains **all** the state for the query, but all of the instances, and underlying guts of the query as well. If the query does not exist, `undefined` will be returned. +This `isFetching` method returns an `integer` representing how many queries, if any, in the cache are currently fetching (including background-fetching, loading new pages, or loading more infinite query results) -> Note: This is not typically needed for most applications, but can come in handy when needing more information about a query in rare scenarios (eg. Looking at the query.state.updatedAt timestamp to decide whether a query is fresh enough to be used as an initial value) +```js +if (client.isFetching()) { + console.log('At least one query is fetching!') +} +``` + +React Query also exports a handy [`useIsFetching`](#useisfetching) hook that will let you subscribe to this state in your components without creating a manual subscription to the query cache. + +## `client.setQueryDefaults` + +`setQueryDefaults` is a synchronous method to set default options for a specific query. If the query does not exist yet it will create it. ```js -const query = queryCache.getQuery(queryKey) +client.setQueryDefaults('posts', fetchPosts) + +function Component() { + const { data } = useQuery('posts') +} ``` **Options** -- `queryKey: QueryKey` - - See [Query Keys](./guides/queries#query-keys) for more information on how to construct and use a query key +- `queryKey?: QueryKey`: [Query Keys](#./guides/queries#query-keys) +- `filters?: QueryFilters`: [Query Filters](./guides/queries#query-filters) -**Returns** +## `QueryCache` -- `query: QueryObject` - - The query object from the cache +The `QueryCache` is the backbone of React Query that manages all of the state, caching, lifecycle and magic of every query. It supports relatively unrestricted, but safe, access to manipulate query's as you need. -## `queryCache.getQueries` +```js +import { QueryCache } from 'react-query' -`getQueries` is even more advanced synchronous function that can be used to get existing query objects from the cache that partially match query key. If queries do not exist, empty array will be returned. +const cache = new QueryCache() +const query = cache.find('posts') +``` -> Note: This is not typically needed for most applications, but can come in handy when needing more information about a query in rare scenarios +Its available methods are: + +- [`find`](#cachefind) +- [`findAll`](#cachefindall) +- [`subscribe`](#cachesubscribe) +- [`clear`](#cacheclear) + +## `cache.find` + +`find` is a slightly more advanced synchronous method that can be used to get an existing query instance from the cache. This instance not only contains **all** the state for the query, but all of the instances, and underlying guts of the query as well. If the query does not exist, `undefined` will be returned. + +> Note: This is not typically needed for most applications, but can come in handy when needing more information about a query in rare scenarios (eg. Looking at the query.state.updatedAt timestamp to decide whether a query is fresh enough to be used as an initial value) ```js -const queries = queryCache.getQueries(queryKey) +const query = cache.find(queryKey) ``` **Options** -- `queryKey: QueryKey` - - See [Query Keys](./guides/queries#query-keys) for more information on how to construct and use a query key +- `queryKey?: QueryKey`: [Query Keys](#./guides/queries#query-keys) +- `filters?: QueryFilters`: [Query Filters](./guides/queries#query-filters) **Returns** -- `queries: QueryObject[]` - - Query objects from the cache +- `Query` + - The query instance from the cache -## `queryCache.isFetching` +## `cache.findAll` -This `isFetching` property is an `integer` representing how many queries, if any, in the cache are currently fetching (including background-fetching, loading new pages, or loading more infinite query results) +`findAll` is even more advanced synchronous method that can be used to get existing query instances from the cache that partially match query key. If queries do not exist, empty array will be returned. + +> Note: This is not typically needed for most applications, but can come in handy when needing more information about a query in rare scenarios ```js -if (queryCache.isFetching) { - console.log('At least one query is fetching!') -} +const queries = cache.findAll(queryKey) ``` -React Query also exports a handy [`useIsFetching`](#useisfetching) hook that will let you subscribe to this state in your components without creating a manual subscription to the query cache. +**Options** + +- `queryKey?: QueryKey`: [Query Keys](#./guides/queries#query-keys) +- `filters?: QueryFilters`: [Query Filters](./guides/queries#query-filters) + +**Returns** -## `queryCache.subscribe` +- `Query[]` + - Query instances from the cache + +## `client.subscribe` The `subscribe` method can be used to subscribe to the query cache as a whole and be informed of safe/known updates to the cache like query states changing or queries being updated, added or removed ```js const callback = (cache, query) => {} -const unsubscribe = queryCache.subscribe(callback) +const unsubscribe = cache.subscribe(callback) ``` **Options** -- `callback: Function(queryCache, query?) => void` - - This function will be called with the query cache any time it is updated via its tracked update mechanisms (eg, `query.setState`, `queryCache.removeQueries`, etc). Out of scope mutations to the queryCache are not encouraged and will not fire subscription callbacks +- `callback: (cache, query?) => void` + - This function will be called with the query cache any time it is updated via its tracked update mechanisms (eg, `query.setState`, `client.removeQueries`, etc). Out of scope mutations to the cache are not encouraged and will not fire subscription callbacks - Additionally, for updates to the cache triggered by a specific query, the `query` will be passed as the second argument to the callback **Returns** @@ -701,31 +728,22 @@ const unsubscribe = queryCache.subscribe(callback) - `unsubscribe: Function => void` - This function will unsubscribe the callback from the query cache. -## `queryCache.clear` +## `cache.clear` -The `clear` method can be used to clear the queryCache entirely and start fresh. +The `clear` method can be used to clear the cache entirely and start fresh. ```js -queryCache.clear() +cache.clear() ``` -**Returns** - -- `queries: Array` - - This will be an array containing the queries that were found. - -## `makeQueryCache` - -The `makeQueryCache` factory function has been deprecated in favor of `new QueryCache()`. +## `useQueryClient` -## `useQueryCache` - -The `useQueryCache` hook returns the current queryCache instance. +The `useQueryClient` hook returns the current `QueryClient` instance. ```js -import { useQueryCache } from 'react-query' +import { useQueryClient } from 'react-query' -const queryCache = useQueryCache() +const client = useQueryClient() ``` ## `useIsFetching` @@ -740,113 +758,34 @@ const isFetching = useIsFetching() **Returns** -- `isFetching: Int` +- `isFetching: number` - Will be the `number` of the queries that your application is currently loading or fetching in the background. -## `ReactQueryConfigProvider` - -`ReactQueryConfigProvider` is an optional provider component and can be used to define defaults for all instances of `useQuery` within it's sub-tree: - -```js -import { - QueryCache, - ReactQueryCacheProvider, - ReactQueryConfigProvider, -} from 'react-query' - -const queryCache = new QueryCache({ - defaultConfig: { - queries: { - suspense: false, - queryKeySerializerFn: defaultQueryKeySerializerFn, - queryFn, - enabled: true, - retry: 3, - retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), - staleTime: 0, - cacheTime: 5 * 60 * 1000, - refetchOnWindowFocus: true, - refetchInterval: false, - queryFnParamsFilter: identity, - refetchOnMount: true, - isDataEqual: deepEqual, - onError: noop, - onSuccess: noop, - onSettled: noop, - useErrorBoundary: false, // falls back to suspense - }, - mutations: { - suspense: false, - throwOnError: false, - onMutate: noop, - onError: noop, - onSuccess: noop, - onSettled: noop, - useErrorBoundary: false, // falls back to suspense - }, - }, -}) - -const overrides = { - queries: { - suspense: true, - }, - mutations: { - suspense: true, - }, -} - -function App() { - return ( - - ... - - ... - - - ) -} -``` +## `QueryClientProvider` -**Options** - -- `config: Object` - - Must be **stable** or **memoized**. Do not create an inline object! - - For non-global properties please see their usage in both the [`useQuery` hook](#usequery) and the [`useMutation` hook](#usemutation). - -## `ReactQueryCacheProvider` - -The query cache can be connected to React with the `ReactQueryCacheProvider`. This component puts the cache on the context, which enables you to access it from anywhere in your component tree. +Use the `QueryClientProvider` component to connect a `QueryClient` to your application: ```js -import { ReactQueryCacheProvider, QueryCache } from 'react-query' +import { QueryClient, QueryClientProvider, QueryCache } from 'react-query' -const queryCache = new QueryCache() +const cache = new QueryCache() +const client = new QueryClient({ cache }) function App() { - return ( - - ... - - ) + return ... } ``` -**Options** - -- `queryCache: QueryCache` - - Instance of QueryCache. - -## `ReactQueryErrorResetBoundary` +## `QueryErrorResetBoundary` -When using **suspense** or **useErrorBoundaries** in your queries, you need a way to let queries know that you want to try again when re-rendering after some error occured. With the `ReactQueryErrorResetBoundary` component you can reset any query errors within the boundaries of the component. +When using **suspense** or **useErrorBoundaries** in your queries, you need a way to let queries know that you want to try again when re-rendering after some error occured. With the `QueryErrorResetBoundary` component you can reset any query errors within the boundaries of the component. ```js -import { ReactQueryErrorResetBoundary } from 'react-query' +import { QueryErrorResetBoundary } from 'react-query' import { ErrorBoundary } from 'react-error-boundary' const App: React.FC = () => ( - + {({ reset }) => ( ( )} - + ) ``` -## `useErrorResetBoundary` +## `useQueryErrorResetBoundary` -This hook will reset any query errors within the closest `ReactQueryErrorResetBoundary`. If there is no boundary defined it will reset them globally: +This hook will reset any query errors within the closest `QueryErrorResetBoundary`. If there is no boundary defined it will reset them globally: ```js -import { useErrorResetBoundary } from 'react-query' +import { useQueryErrorResetBoundary } from 'react-query' import { ErrorBoundary } from 'react-error-boundary' const App: React.FC = () => { - const { reset } = useErrorResetBoundary() + const { reset } = useQueryErrorResetBoundary() return ( Boolean` + - The `cache` that should be dehydrated +- `shouldDehydrate: (query: Query) => boolean` - This function is called for each query in the cache - Return `true` to include this query in dehydration, or `false` otherwise - Default version only includes successful queries, do `shouldDehydrate: () => true` to include all queries @@ -935,32 +874,32 @@ const dehydratedState = dehydrate(queryCache, { **Returns** - `dehydratedState: DehydratedState` - - This includes everything that is needed to hydrate the `queryCache` at a later point + - This includes everything that is needed to hydrate the `cache` at a later point - You **should not** rely on the exact format of this response, it is not part of the public API and can change at any time - This result is not in serialized form, you need to do that yourself if desired ## `hydration/hydrate` -`hydrate` adds a previously dehydrated state into a `queryCache`. If the queries included in dehydration already exist in the cache, `hydrate` does not overwrite them. +`hydrate` adds a previously dehydrated state into a `cache`. If the queries included in dehydration already exist in the cache, `hydrate` does not overwrite them. ```js import { hydrate } from 'react-query/hydration' -hydrate(queryCache, dehydratedState) +hydrate(cache, dehydratedState) ``` **Options** -- `queryCache: QueryCache` +- `cache: QueryCache` - **Required** - - The `queryCache` to hydrate the state into + - The `cache` to hydrate the state into - `dehydratedState: DehydratedState` - **Required** - The state to hydrate into the cache ## `hydration/useHydrate` -`useHydrate` adds a previously dehydrated state into the `queryCache` returned by `useQueryCache`. +`useHydrate` adds a previously dehydrated state into the `cache` returned by `useQueryCache`. ```jsx import { useHydrate } from 'react-query/hydration' diff --git a/docs/src/pages/docs/comparison.md b/docs/src/pages/docs/comparison.md index 8ca87dab14..c0a6d9f9ee 100644 --- a/docs/src/pages/docs/comparison.md +++ b/docs/src/pages/docs/comparison.md @@ -28,6 +28,7 @@ Feature/Capability Key: | Paginated Queries | ✅ | ✅ | ✅ | | Infinite Queries | ✅ | ✅ | ✅ | | Lagged / "Lazy" Queries1 | ✅ | 🛑 | 🛑 | +| Selectors | ✅ | 🛑 | ✅ | | Initial Data | ✅ | ✅ | ✅ | | Scroll Recovery | ✅ | ✅ | ✅ | | Cache Manipulation | ✅ | ✅ | ✅ | diff --git a/docs/src/pages/docs/guides/migrating-to-react-query-3.md b/docs/src/pages/docs/guides/migrating-to-react-query-3.md new file mode 100644 index 0000000000..cdc283bb30 --- /dev/null +++ b/docs/src/pages/docs/guides/migrating-to-react-query-3.md @@ -0,0 +1,273 @@ +--- +id: migrating-to-react-query-3 +title: Migrating to React Query 3 +--- + +## V3 migration + +This article explains how to migrate your application to React Query 3. + +### QueryClient + +The `QueryCache` has been split into a `QueryClient` and a `QueryCache`. +The `QueryCache` contains all cached queries and the `QueryClient` can be used to interact with a cache. + +This has some benefits: + +- Allows for different type of caches. +- Multiple clients with different configurations can use the same cache. +- Clients can be used to track queries, which can be used for shared caches on SSR. +- The client API is more focused towards general usage. +- Easier to test the individual components. + +Use the `QueryClientProvider` component to connect a `QueryClient` to your application: + +```js +import { QueryClient, QueryClientProvider, QueryCache } from 'react-query' + +const cache = new QueryCache() +const client = new QueryClient({ cache }) + +function App() { + return ... +} +``` + +### useQueryCache() + +The `useQueryCache()` hook has been replaced by the `useQueryClient()` hook: + +```js +import { useCallback } from 'react' +import { useQueryClient } from 'react-query' + +function Todo() { + const client = useQueryClient() + + const onClickButton = useCallback(() => { + client.invalidateQueries('posts') + }, [client]) + + return +} +``` + +### ReactQueryConfigProvider + +The `ReactQueryConfigProvider` component has been removed. Default options for queries and mutations can now be specified in `QueryClient`: + +```js +const client = new QueryClient({ + cache, + defaultOptions: { + queries: { + staleTime: Infinity, + }, + }, +}) +``` + +### usePaginatedQuery() + +The `usePaginatedQuery()` hook has been replaced by the `keepPreviousData` option on `useQuery`: + +```js +import { useQuery } from 'react-query' + +function Page({ page }) { + const { data } = useQuery(['page', page], fetchPage, { + keepPreviousData: true, + }) +} +``` + +### Query object syntax + +The object syntax has been collapsed: + +```js +// Old: +useQuery({ + queryKey: 'posts', + queryFn: fetchPosts, + config: { staleTime: Infinity }, +}) + +// New: +useQuery({ + queryKey: 'posts', + queryFn: fetchPosts, + staleTime: Infinity, +}) +``` + +### queryCache.prefetchQuery() + +The `client.prefetchQuery()` method should now only be used for prefetching scenarios where the result is not relevant. + +Use the `client.fetchQueryData()` method to get the query data or error: + +```js +// Prefetch a query: +await client.prefetchQuery('posts', fetchPosts) + +// Fetch a query: +try { + const data = await client.fetchQueryData('posts', fetchPosts) +} catch (error) { + // Error handling +} +``` + +### ReactQueryCacheProvider + +The `ReactQueryCacheProvider` component has been replaced by the `QueryClientProvider` component. + +### makeQueryCache() + +The `makeQueryCache()` function has replaced by `new QueryCache()`. + +### ReactQueryErrorResetBoundary + +The `ReactQueryErrorResetBoundary` component has been renamed to `QueryErrorResetBoundary`. + +### queryCache.resetErrorBoundaries() + +The `queryCache.resetErrorBoundaries()` method has been replaced by the `QueryErrorResetBoundary` component. + +### queryCache.getQuery() + +The `queryCache.getQuery()` method has been replaced by `cache.find()`. + +### queryCache.getQueries() + +The `queryCache.getQueries()` method has been replaced by `cache.findAll()`. + +### queryCache.isFetching + +The `queryCache.isFetching` property has been replaced by `client.isFetching()`. + +### QueryOptions.initialStale + +The `initialStale` query option has been removed and initial data is now treated as regular data. +Which means that if `initialData` is provided, the query will refetch on mount by default. +If you do not want to refetch immediately, you can define a `staleTime`. + +### QueryOptions.forceFetchOnMount + +The `forceFetchOnMount` query option has been replaced by `refetchOnMount: 'always'`. + +### QueryOptions.refetchOnMount + +When `refetchOnMount` was set to `false` any additional components were prevented from refetching on mount. +In version 3 only the component where the option has been set will not refetch on mount. + +### QueryResult.clear() + +The `QueryResult.clear()` method has been renamed to `QueryResult.remove()`. + +### New features + +Some new features have also been added besides the API changes, performance improvements and file size reduction. + +#### Selectors + +The `useQuery` and `useInfiniteQuery` hooks now have a `select` option to select or transform parts of the query result. + +```js +import { useQuery } from 'react-query' + +function User() { + const { data } = useQuery('user', fetchUser, { + select: user => user.username, + }) + return
Username: {data}
+} +``` + +Set the `notifyOnStatusChange` option to `false` to only re-render when the selected data changes. + +#### useQueries() + +The `useQueries()` hook can be used to fetch a variable number of queries: + +```js +import { useQueries } from 'react-query' + +function Overview() { + const results = useQueries([ + { queryKey: ['post', 1], queryFn: fetchPost }, + { queryKey: ['post', 2], queryFn: fetchPost }, + ]) + return ( +
    + {results.map(({ data }) => data &&
  • {data.title})
  • )} +
+ ) +} +``` + +#### client.watchQuery() + +The `client.watchQuery()` method can be used to create and/or watch a query: + +```js +const observer = client.watchQuery('posts') + +observer.subscribe(result => { + console.log(result) + observer.unsubscribe() +}) +``` + +#### client.watchQueries() + +The `client.watchQueries()` method can be used to create and/or watch multiple queries: + +```js +const observer = client.watchQueries([ + { queryKey: ['post', 1], queryFn: fetchPost }, + { queryKey: ['post', 2], queryFn: fetchPost }, +]) + +observer.subscribe(result => { + console.log(result) + observer.unsubscribe() +}) +``` + +## `client.setQueryDefaults` + +The `client.setQueryDefaults()` method to set default options for a specific query. If the query does not exist yet it will create it. + +```js +client.setQueryDefaults('posts', fetchPosts) + +function Component() { + const { data } = useQuery('posts') +} +``` + +#### React Native error screens + +To prevent showing error screens in React Native when a query fails it was necessary to manually change the Console: + +```js +import { setConsole } from 'react-query' + +setConsole({ + log: console.log, + warn: console.warn, + error: console.warn, +}) +``` + +In version 3 this is done automatically when React Query is used in React Native. + +#### Core separation + +The core of React Query is now fully separated from React, which means it can also be used standalone or in other frameworks. Use the `react-query/core` entrypoint to only import the core functionality: + +```js +import { QueryClient } from 'react-query/core' +``` diff --git a/docs/src/pages/docs/guides/queries.md b/docs/src/pages/docs/guides/queries.md index 2a5a773716..55bf06f631 100644 --- a/docs/src/pages/docs/guides/queries.md +++ b/docs/src/pages/docs/guides/queries.md @@ -257,3 +257,33 @@ function GlobalLoadingIndicator() { ) : null } ``` + +# Query Filters + +Some methods within React Query accept a `QueryFilters` object. A query filter is an object with certain conditions to match a query with: + +```js +await client.refetchQueries({ active: true, inactive: true }) +await client.refetchQueries('posts', { active: true, inactive: true }) +``` + +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 active queries. + - When set to `false` it will match inactive queries. +- `stale?: boolean` + - When set to `true` it will match stale queries. + - When set to `false` it will not match stale queries. +- `fresh?: boolean` + - When set to `true` it will match fresh queries. + - When set to `false` it will not match fresh queries. +- `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` + - Set this property to define a query key to match on. diff --git a/docs/src/pages/docs/guides/suspense.md b/docs/src/pages/docs/guides/suspense.md index a03622bf91..95c5c18435 100644 --- a/docs/src/pages/docs/guides/suspense.md +++ b/docs/src/pages/docs/guides/suspense.md @@ -47,16 +47,16 @@ In addition to queries behaving differently in suspense mode, mutations also beh Whether you are using **suspense** or **useErrorBoundaries** in your queries, you will need a way to let queries know that you want to try again when re-rendering after some error occured. -Query errors can be reset with the `ReactQueryErrorResetBoundary` component or with the `useErrorResetBoundary` hook. +Query errors can be reset with the `QueryErrorResetBoundary` component or with the `useQueryErrorResetBoundary` hook. When using the component it will reset any query errors within the boundaries of the component: ```js -import { ReactQueryErrorResetBoundary } from 'react-query' +import { QueryErrorResetBoundary } from 'react-query' import { ErrorBoundary } from 'react-error-boundary' const App: React.FC = () => ( - + {({ reset }) => ( ( )} - + ) ``` -When using the hook it will reset any query errors within the closest `ReactQueryErrorResetBoundary`. If there is no boundary defined it will reset them globally: +When using the hook it will reset any query errors within the closest `QueryErrorResetBoundary`. If there is no boundary defined it will reset them globally: ```js -import { useErrorResetBoundary } from 'react-query' +import { useQueryErrorResetBoundary } from 'react-query' import { ErrorBoundary } from 'react-error-boundary' const App: React.FC = () => { - const { reset } = useErrorResetBoundary() + const { reset } = useQueryErrorResetBoundary() return ( + - + ) } diff --git a/docs/src/pages/docs/quick-start.md b/docs/src/pages/docs/quick-start.md index 7a29d30154..38f99d0d03 100644 --- a/docs/src/pages/docs/quick-start.md +++ b/docs/src/pages/docs/quick-start.md @@ -10,22 +10,23 @@ This example very briefly illustrates the 3 core concepts of React Query: - Query Invalidation ```js -import { useQuery, useMutation, useQueryCache, QueryCache, ReactQueryCacheProvider } from 'react-query' +import { useQuery, useMutation, useQueryClient, QueryCache, QueryClient, QueryClientProvider } from 'react-query' import { getTodos, postTodo } from '../my-api' -const queryCache = new QueryCache() +const cache = new QueryCache() +const client = new QueryClient({ cache }) function App() { return ( - + - + ); } function Todos() { - // Cache - const cache = useQueryCache() + // Client + const client = useQueryClient() // Queries const todosQuery = useQuery('todos', getTodos) @@ -33,8 +34,8 @@ function Todos() { // Mutations const [addTodo] = useMutation(postTodo, { onSuccess: () => { - // Query Invalidations - cache.invalidateQueries('todos') + // Refetch + client.invalidateQueries('todos') }, }) diff --git a/hydration.d.ts b/hydration.d.ts deleted file mode 100644 index cbe29dae80..0000000000 --- a/hydration.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './types/hydration/index'; diff --git a/hydration.js b/hydration.js deleted file mode 100644 index 5479665434..0000000000 --- a/hydration.js +++ /dev/null @@ -1,5 +0,0 @@ -if (process.env.NODE_ENV === 'production') { - module.exports = require('./dist/hydration/react-query-hydration.production.min.js') -} else { - module.exports = require('./dist/hydration/react-query-hydration.development.js') -} diff --git a/hydration/package.json b/hydration/package.json new file mode 100644 index 0000000000..804509cb4d --- /dev/null +++ b/hydration/package.json @@ -0,0 +1,6 @@ +{ + "internal": true, + "main": "../lib/hydration/index.js", + "module": "../es/hydration/index.js", + "types": "../types/hydration/index.d.ts" +} diff --git a/index.js b/index.js deleted file mode 100644 index db06627635..0000000000 --- a/index.js +++ /dev/null @@ -1,5 +0,0 @@ -if (process.env.NODE_ENV === 'production') { - module.exports = require('./dist/react-query.production.min.js') -} else { - module.exports = require('./dist/react-query.development.js') -} diff --git a/jest.config.js b/jest.config.js index 2ea8ec84d4..5a8bc03d90 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,6 +4,6 @@ module.exports = { testMatch: ['/src/**/*.test.tsx'], testPathIgnorePatterns: ['/types/'], moduleNameMapper: { - 'react-query': '/src/react/index.ts', + 'react-query': '/src/index.ts', }, } diff --git a/package.json b/package.json index 74fcafdaac..45d76bb2f2 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,11 @@ "test:coverage": "yarn test:ci; open coverage/lcov-report/index.html", "test:types": "tsc", "test:eslint": "eslint --ext .ts,.tsx ./src", - "build": "yarn build:commonjs && yarn build:es && yarn build:umd", + "build": "yarn build:commonjs && yarn build:es && yarn build:umd && yarn build:types", "build:commonjs": "rm -rf ./lib && BABEL_ENV=commonjs babel --extensions .ts,.tsx --ignore ./src/**/*.test.tsx ./src --out-dir lib", "build:es": "rm -rf ./es && babel --extensions .ts,.tsx --ignore ./src/**/*.test.tsx ./src --out-dir es", - "build:umd": "rm -rf ./dist && NODE_ENV=production rollup -c && rollup-plugin-visualizer stats-react.json stats-hydration.json", - "build:types": "rm -rf ./types && tsc --project ./tsconfig.types.json && replace 'import type' 'import' ./types -r && replace 'export type' 'export' ./types -r", + "build:umd": "rm -rf ./dist && NODE_ENV=production rollup -c && rollup-plugin-visualizer stats-react.json", + "build:types": "rm -rf ./types && tsc --project ./tsconfig.types.json && replace 'import type' 'import' ./types -r --silent && replace 'export type' 'export' ./types -r --silent", "now-build": "yarn && cd www && yarn && yarn build", "start": "rollup -c -w", "format": "prettier {.,src,src/**,example/src,example/src/**,types}/*.{md,js,jsx,tsx,json} --write", @@ -42,13 +42,14 @@ ] }, "files": [ + "core", "dist", - "lib", "es", - "types", + "hydration", + "lib", + "react", "scripts", - "hydration.js", - "hydration.d.ts" + "types" ], "dependencies": { "@babel/runtime": "^7.5.5" diff --git a/react/package.json b/react/package.json new file mode 100644 index 0000000000..02f74bc64a --- /dev/null +++ b/react/package.json @@ -0,0 +1,6 @@ +{ + "internal": true, + "main": "../lib/react/index.js", + "module": "../es/react/index.js", + "types": "../types/react/index.d.ts" +} diff --git a/rollup.config.js b/rollup.config.js index 3b5badf04d..e65876a34a 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -8,56 +8,19 @@ import visualizer from 'rollup-plugin-visualizer' import replace from '@rollup/plugin-replace' const external = ['react', 'react-dom'] -const hydrationExternal = [...external, 'react-query'] const globals = { react: 'React', 'react-dom': 'ReactDOM', } -const hydrationGlobals = { - ...globals, - 'react-query': 'ReactQuery', -} const inputSrc = 'src/index.ts' -const hydrationSrc = 'src/hydration/index.ts' const extensions = ['.js', '.jsx', '.es6', '.es', '.mjs', '.ts', '.tsx'] const babelConfig = { extensions, runtimeHelpers: true } const resolveConfig = { extensions } export default [ - { - input: inputSrc, - output: { - file: 'dist/react-query.mjs', - format: 'es', - sourcemap: true, - }, - external, - plugins: [ - resolve(resolveConfig), - babel(babelConfig), - commonJS(), - externalDeps(), - ], - }, - { - input: inputSrc, - output: { - file: 'dist/react-query.min.mjs', - format: 'es', - sourcemap: true, - }, - external, - plugins: [ - resolve(resolveConfig), - babel(babelConfig), - commonJS(), - externalDeps(), - terser(), - ], - }, { input: inputSrc, output: { @@ -99,76 +62,4 @@ export default [ }), ], }, - { - input: hydrationSrc, - output: { - file: 'dist/hydration/react-query-hydration.mjs', - format: 'es', - sourcemap: true, - }, - external: hydrationExternal, - plugins: [ - resolve(resolveConfig), - babel(babelConfig), - commonJS(), - externalDeps(), - ], - }, - { - input: hydrationSrc, - output: { - file: 'dist/hydration/react-query-hydration.min.mjs', - format: 'es', - sourcemap: true, - }, - external: hydrationExternal, - plugins: [ - resolve(resolveConfig), - babel(babelConfig), - commonJS(), - externalDeps(), - terser(), - ], - }, - { - input: hydrationSrc, - output: { - name: 'ReactQueryHydration', - file: 'dist/hydration/react-query-hydration.development.js', - format: 'umd', - sourcemap: true, - globals: hydrationGlobals, - }, - external: hydrationExternal, - plugins: [ - resolve(resolveConfig), - babel(babelConfig), - commonJS(), - externalDeps(), - ], - }, - { - input: hydrationSrc, - output: { - name: 'ReactQueryHydration', - file: 'dist/hydration/react-query-hydration.production.min.js', - format: 'umd', - sourcemap: true, - globals: hydrationGlobals, - }, - external: hydrationExternal, - plugins: [ - replace({ 'process.env.NODE_ENV': `"production"`, delimiters: ['', ''] }), - resolve(resolveConfig), - babel(babelConfig), - commonJS(), - externalDeps(), - terser(), - size(), - visualizer({ - filename: 'stats-hydration.json', - json: true, - }), - ], - }, ] diff --git a/src/core/config.ts b/src/core/config.ts index 182b164f77..4ce774b10f 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,147 +1,42 @@ -import { stableStringify } from './utils' -import type { - ArrayQueryKey, - MutationConfig, - QueryConfig, - QueryKey, - QueryKeySerializerFunction, - ReactQueryConfig, - ResolvedQueryConfig, -} from './types' -import type { QueryCache } from './queryCache' - -// TYPES - -export interface ReactQueryConfigRef { - current: ReactQueryConfig -} +import type { DefaultOptions } from './types' +import { defaultQueryKeySerializerFn } from './utils' // CONFIG -export const defaultQueryKeySerializerFn: QueryKeySerializerFunction = ( - queryKey: QueryKey -): [string, ArrayQueryKey] => { - try { - let arrayQueryKey: ArrayQueryKey = Array.isArray(queryKey) - ? queryKey - : [queryKey] - const queryHash = stableStringify(arrayQueryKey) - arrayQueryKey = JSON.parse(queryHash) - return [queryHash, arrayQueryKey] - } catch { - throw new Error('A valid query key is required!') - } -} - -/** - * Config merging strategy - * - * When using hooks the config will be merged in the following order: - * - * 1. These defaults. - * 2. Defaults from the hook query cache. - * 3. Combined defaults from any config providers in the tree. - * 4. Query/mutation config provided to the hook. - * - * When using a query cache directly the config will be merged in the following order: - * - * 1. These defaults. - * 2. Defaults from the query cache. - * 3. Query/mutation config provided to the query cache method. - */ -export const DEFAULT_CONFIG: ReactQueryConfig = { +export const DEFAULT_OPTIONS = { queries: { cacheTime: 5 * 60 * 1000, enabled: true, notifyOnStatusChange: true, - queryFn: () => Promise.reject(), queryKeySerializerFn: defaultQueryKeySerializerFn, refetchOnMount: true, refetchOnReconnect: true, refetchOnWindowFocus: true, retry: 3, - retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), + retryDelay: (attempt: number) => Math.min(1000 * 2 ** attempt, 30000), staleTime: 0, structuralSharing: true, }, } -export function getDefaultReactQueryConfig() { - return { - queries: { ...DEFAULT_CONFIG.queries }, - mutations: { ...DEFAULT_CONFIG.mutations }, - } -} - -export function mergeReactQueryConfigs( - a: ReactQueryConfig, - b: ReactQueryConfig -): ReactQueryConfig { - return { - shared: { - ...a.shared, - ...b.shared, - }, - queries: { - ...a.queries, - ...b.queries, - }, - mutations: { - ...a.mutations, - ...b.mutations, - }, - } -} - -export function getResolvedQueryConfig( - queryCache: QueryCache, - queryKey: QueryKey, - contextConfig?: ReactQueryConfig, - config?: QueryConfig -): ResolvedQueryConfig { - const queryCacheConfig = queryCache.getDefaultConfig() - - const resolvedConfig = { - ...DEFAULT_CONFIG.queries, - ...queryCacheConfig?.shared, - ...queryCacheConfig?.queries, - ...contextConfig?.shared, - ...contextConfig?.queries, - ...config, - } as ResolvedQueryConfig - - const result = resolvedConfig.queryKeySerializerFn(queryKey) - - resolvedConfig.queryCache = queryCache - resolvedConfig.queryHash = result[0] - resolvedConfig.queryKey = result[1] - - return resolvedConfig -} - -export function isResolvedQueryConfig( - config: any -): config is ResolvedQueryConfig { - return Boolean(config.queryHash) -} - -export function getResolvedMutationConfig< - TResult, - TError, - TVariables, - TSnapshot ->( - queryCache: QueryCache, - contextConfig?: ReactQueryConfig, - config?: MutationConfig -): MutationConfig { - const queryCacheConfig = queryCache.getDefaultConfig() - return { - ...DEFAULT_CONFIG.mutations, - ...queryCacheConfig?.shared, - ...queryCacheConfig?.mutations, - ...contextConfig?.shared, - ...contextConfig?.mutations, - ...config, - } as MutationConfig +export function getDefaultOptions(): DefaultOptions { + return DEFAULT_OPTIONS +} + +export function mergeDefaultOptions( + a: DefaultOptions, + b?: DefaultOptions +): DefaultOptions { + return b + ? { + queries: { + ...a.queries, + ...b.queries, + }, + mutations: { + ...a.mutations, + ...b.mutations, + }, + } + : a } diff --git a/src/core/index.ts b/src/core/index.ts index 883f4185af..50cc875e34 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,21 +1,13 @@ -export { getDefaultReactQueryConfig } from './config' -export { - queryCache, - queryCaches, - makeQueryCache, - QueryCache, -} from './queryCache' +export { getDefaultOptions } from './config' +export { Query } from './query' +export { QueryCache } from './queryCache' +export { QueryClient } from './queryClient' +export { setBatchedUpdates } from './notifyManager' +export { setConsole } from './setConsole' export { setFocusHandler } from './setFocusHandler' export { setOnlineHandler } from './setOnlineHandler' -export { - CancelledError, - isCancelledError, - isError, - setConsole, - setBatchedUpdates, -} from './utils' +export { CancelledError, isCancelledError, isError } from './utils' // Types export * from './types' -export type { Query } from './query' -export type { ConsoleObject } from './utils' +export type { ConsoleObject } from './setConsole' diff --git a/src/core/notifyManager.ts b/src/core/notifyManager.ts index ae70836757..0493eaee2c 100644 --- a/src/core/notifyManager.ts +++ b/src/core/notifyManager.ts @@ -1,9 +1,11 @@ -import { getBatchedUpdates, scheduleMicrotask } from './utils' +import { scheduleMicrotask } from './utils' // TYPES type NotifyCallback = () => void +type BatchUpdateFunction = (callback: () => void) => void + // CLASS export class NotifyManager { @@ -51,6 +53,23 @@ export class NotifyManager { } } +// GETTERS AND SETTERS + +// Default to a dummy "batch" implementation that just runs the callback +let batchedUpdates: BatchUpdateFunction = (callback: () => void) => { + callback() +} + +// Allow injecting another batching function later +export function setBatchedUpdates(fn: BatchUpdateFunction) { + batchedUpdates = fn +} + +// Supply a getter just to skip dealing with ESM bindings +export function getBatchedUpdates(): BatchUpdateFunction { + return batchedUpdates +} + // SINGLETON export const notifyManager = new NotifyManager() diff --git a/src/core/queriesObserver.ts b/src/core/queriesObserver.ts new file mode 100644 index 0000000000..36b9de8790 --- /dev/null +++ b/src/core/queriesObserver.ts @@ -0,0 +1,133 @@ +import { difference, hashQueryKey, noop, replaceAt } from './utils' +import { notifyManager } from './notifyManager' +import type { QueryObserverOptions, QueryObserverResult } from './types' +import type { QueryClient } from './queryClient' +import type { QueryObserver } from './queryObserver' + +export interface QueriesObserverConfig { + client: QueryClient + queries?: QueryObserverOptions[] +} + +type QueriesObserverListener = (result: QueryObserverResult[]) => void + +export class QueriesObserver { + private client: QueryClient + private result: QueryObserverResult[] + private queries: QueryObserverOptions[] + private observers: QueryObserver[] + private listener?: QueriesObserverListener + + constructor(config: QueriesObserverConfig) { + this.client = config.client + this.queries = config.queries || [] + this.result = [] + this.observers = [] + + // Bind exposed methods + this.unsubscribe = this.unsubscribe.bind(this) + + // Subscribe to queries + this.updateObservers() + } + + subscribe(listener?: QueriesObserverListener): () => void { + this.listener = listener || noop + + this.observers.forEach(observer => { + observer.subscribe(result => { + this.onUpdate(observer, result) + }) + }) + + return this.unsubscribe + } + + unsubscribe(): void { + this.listener = undefined + this.observers.forEach(observer => { + observer.unsubscribe() + }) + } + + setQueries(queries: QueryObserverOptions[]): void { + this.queries = queries + this.updateObservers() + } + + getCurrentResult(): QueryObserverResult[] { + return this.result + } + + private updateObservers(): void { + let hasIndexChange = false + + const prevObservers = this.observers + const newObservers = this.queries.map((options, i) => { + let observer: QueryObserver | undefined = prevObservers[i] + + const defaultedOptions = this.client.defaultQueryObserverOptions(options) + + defaultedOptions.queryHash = hashQueryKey( + defaultedOptions.queryKey!, + defaultedOptions + ) + + if ( + observer?.getCurrentQuery().queryHash !== defaultedOptions.queryHash + ) { + hasIndexChange = true + observer = prevObservers.find( + x => x.getCurrentQuery().queryHash === defaultedOptions.queryHash + ) + } + + if (observer) { + observer.setOptions(defaultedOptions) + return observer + } + + return this.client.watchQuery(defaultedOptions) + }) + + if (prevObservers.length === newObservers.length && !hasIndexChange) { + return + } + + this.observers = newObservers + this.result = newObservers.map(observer => observer.getCurrentResult()) + + if (!this.listener) { + return + } + + difference(prevObservers, newObservers).forEach(observer => { + observer.unsubscribe() + }) + + difference(newObservers, prevObservers).forEach(observer => { + observer.subscribe(result => { + this.onUpdate(observer, result) + }) + }) + + this.notify() + } + + private onUpdate(observer: QueryObserver, result: QueryObserverResult): void { + const index = this.observers.indexOf(observer) + if (index !== -1) { + this.result = replaceAt(this.result, index, result) + this.notify() + } + } + + private notify(): void { + const { result, listener } = this + if (listener) { + notifyManager.schedule(() => { + listener(result) + }) + } + } +} diff --git a/src/core/query.ts b/src/core/query.ts index 3b461cf7d3..b79f0bb486 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -1,8 +1,9 @@ import { CancelledError, - Console, Updater, + ensureArray, functionalUpdate, + hashQueryKey, isCancelable, isCancelledError, isDocumentVisible, @@ -12,127 +13,132 @@ import { noop, replaceEqualDeep, sleep, + timeUntilStale, } from './utils' -import { - ArrayQueryKey, +import type { + FetchMoreOptions, InitialDataFunction, IsFetchingMoreValue, QueryFunction, + QueryKey, + QueryOptions, QueryStatus, - ResolvedQueryConfig, } from './types' import type { QueryCache } from './queryCache' -import { QueryObserver, UpdateListener } from './queryObserver' +import type { QueryObserver } from './queryObserver' import { notifyManager } from './notifyManager' +import { getConsole } from './setConsole' +import { DEFAULT_OPTIONS } from './config' // TYPES -export interface QueryState { +export interface QueryConfig { + cache: QueryCache + queryKey: QueryKey + queryHash?: string + options?: QueryOptions +} + +export interface QueryState { canFetchMore?: boolean - data?: TResult + data?: TData + dataUpdateCount: number error: TError | null + errorUpdateCount: number failureCount: number isFetching: boolean isFetchingMore: IsFetchingMoreValue - isInitialData: boolean isInvalidated: boolean status: QueryStatus - throwInErrorBoundary?: boolean - updateCount: number updatedAt: number } -interface FetchOptions { +export interface FetchOptions { fetchMore?: FetchMoreOptions -} - -export interface FetchMoreOptions { - fetchMoreVariable?: unknown - previous: boolean -} - -export interface RefetchOptions { throwOnError?: boolean } -const enum ActionType { - Failed, - Fetch, - Success, - Error, - Invalidate, -} - interface SetDataOptions { updatedAt?: number } interface FailedAction { - type: ActionType.Failed + type: 'failed' } interface FetchAction { - type: ActionType.Fetch + type: 'fetch' isFetchingMore?: IsFetchingMoreValue } -interface SuccessAction { - type: ActionType.Success - data: TResult | undefined +interface SuccessAction { + type: 'success' + data: TData | undefined canFetchMore?: boolean updatedAt?: number } interface ErrorAction { - type: ActionType.Error + type: 'error' error: TError } interface InvalidateAction { - type: ActionType.Invalidate + type: 'invalidate' } -export type Action = +export type Action = | ErrorAction | FailedAction | FetchAction | InvalidateAction - | SuccessAction + | SuccessAction // CLASS -export class Query { - queryKey: ArrayQueryKey +export class Query { + queryKey: QueryKey queryHash: string - config: ResolvedQueryConfig - observers: QueryObserver[] - state: QueryState - cacheTime: number - - private queryCache: QueryCache - private promise?: Promise + options!: QueryOptions + defaultOptions?: QueryOptions + observers: QueryObserver[] + state: QueryState + cacheTime!: number + + private cache: QueryCache + private promise?: Promise private gcTimeout?: number private cancelFetch?: (silent?: boolean) => void private continueFetch?: () => void private isTransportCancelable?: boolean - constructor(config: ResolvedQueryConfig) { - this.config = config - this.queryKey = config.queryKey - this.queryHash = config.queryHash - this.queryCache = config.queryCache - this.cacheTime = config.cacheTime + constructor(config: QueryConfig) { + this.setOptions(config.options) this.observers = [] - this.state = getDefaultState(config) + this.cache = config.cache + this.queryKey = config.queryKey + this.queryHash = + config.queryHash ?? hashQueryKey(config.queryKey, this.options) + this.state = getDefaultState(this.options) this.scheduleGc() } - private updateConfig(config: ResolvedQueryConfig): void { - this.config = config - this.cacheTime = Math.max(this.cacheTime, config.cacheTime) + private setOptions( + options?: QueryOptions + ): void { + this.options = { + ...DEFAULT_OPTIONS.queries, + ...this.defaultOptions, + ...options, + } + this.cacheTime = Math.max(this.cacheTime || 0, this.options.cacheTime!) } - private dispatch(action: Action): void { + setDefaultOptions(options: QueryOptions): void { + this.defaultOptions = options + } + + private dispatch(action: Action): void { this.state = queryReducer(this.state, action) notifyManager.batch(() => { @@ -140,18 +146,18 @@ export class Query { observer.onQueryUpdate(action) }) - this.queryCache.notifyGlobalListeners(this) + this.cache.notify(this) }) } private scheduleGc(): void { - if (isServer) { - return - } - this.clearGcTimeout() - if (this.observers.length > 0 || !isValidTimeout(this.cacheTime)) { + if ( + isServer || + this.observers.length > 0 || + !isValidTimeout(this.cacheTime) + ) { return } @@ -160,7 +166,7 @@ export class Query { }, this.cacheTime) } - cancel(silent?: boolean): Promise { + cancel(silent?: boolean): Promise { const promise = this.promise if (promise && this.cancelFetch) { @@ -171,10 +177,6 @@ export class Query { return Promise.resolve(undefined) } - private continue(): void { - this.continueFetch?.() - } - private clearTimersObservers(): void { this.observers.forEach(observer => { observer.clearTimers() @@ -182,55 +184,45 @@ export class Query { } private clearGcTimeout() { - if (this.gcTimeout) { - clearTimeout(this.gcTimeout) - this.gcTimeout = undefined - } + clearTimeout(this.gcTimeout) + this.gcTimeout = undefined } setData( - updater: Updater, + updater: Updater, options?: SetDataOptions - ): void { + ): TData { const prevData = this.state.data // Get the new data - let data: TResult | undefined = functionalUpdate(updater, prevData) + let data = functionalUpdate(updater, prevData) // Structurally share data between prev and new data if needed - if (this.config.structuralSharing) { + if (this.options.structuralSharing) { data = replaceEqualDeep(prevData, data) } // Use prev data if an isDataEqual function is defined and returns `true` - if (this.config.isDataEqual?.(prevData, data)) { - data = prevData + if (this.options.isDataEqual?.(prevData, data)) { + data = prevData as TData } // Try to determine if more data can be fetched - const canFetchMore = hasMorePages(this.config, data) + const canFetchMore = hasMorePages(this.options, data) // Set data and mark it as cached this.dispatch({ - type: ActionType.Success, + type: 'success', data, canFetchMore, updatedAt: options?.updatedAt, }) - } - /** - * @deprecated - */ - clear(): void { - Console.warn( - 'react-query: clear() has been deprecated, please use remove() instead' - ) - this.remove() + return data } remove(): void { - this.queryCache.removeQuery(this) + this.cache.remove(this) } destroy(): void { @@ -240,13 +232,13 @@ export class Query { } isActive(): boolean { - return this.observers.some(observer => observer.config.enabled) + return this.observers.some(observer => observer.options.enabled) } isStale(): boolean { return ( this.state.isInvalidated || - this.state.status !== QueryStatus.Success || + !this.state.updatedAt || this.observers.some(observer => observer.getCurrentResult().isStale) ) } @@ -254,55 +246,63 @@ export class Query { isStaleByTime(staleTime = 0): boolean { return ( this.state.isInvalidated || - this.state.status !== QueryStatus.Success || - this.state.updatedAt + staleTime <= Date.now() + !this.state.updatedAt || + !timeUntilStale(this.state.updatedAt, staleTime) ) } - onInteraction(type: 'focus' | 'online'): void { - // Execute the first observer which is enabled, - // stale and wants to refetch on this interaction. - const staleObserver = this.observers.find(observer => { - const { config } = observer + onFocus(): void { + this.onExternalEvent('focus') + } + + onOnline(): void { + this.onExternalEvent('online') + } + + private onExternalEvent(type: 'focus' | 'online'): void { + // Execute the first observer that wants to fetch on this event + const fetchObserver = this.observers.find(observer => { + const { + enabled, + refetchOnWindowFocus, + refetchOnReconnect, + } = observer.options + const { isStale } = observer.getCurrentResult() + return ( - config.enabled && + enabled && ((type === 'focus' && - (config.refetchOnWindowFocus === 'always' || - (config.refetchOnWindowFocus && isStale))) || + (refetchOnWindowFocus === 'always' || + (refetchOnWindowFocus && isStale))) || (type === 'online' && - (config.refetchOnReconnect === 'always' || - (config.refetchOnReconnect && isStale)))) + (refetchOnReconnect === 'always' || + (refetchOnReconnect && isStale)))) ) }) - if (staleObserver) { - staleObserver.fetch() + if (fetchObserver) { + fetchObserver.fetch() } // Continue any paused fetch - this.continue() + this.continueFetch?.() } - /** - * @deprectated - */ - subscribe( - listener?: UpdateListener - ): QueryObserver { - const observer = new QueryObserver(this.config) - observer.subscribe(listener) - return observer - } + subscribeObserver(observer: QueryObserver): void { + if (this.observers.indexOf(observer) !== -1) { + return + } - subscribeObserver(observer: QueryObserver): void { this.observers.push(observer) // Stop the query from being garbage collected this.clearGcTimeout() + + this.cache.notify(this) } - unsubscribeObserver(observer: QueryObserver): void { + unsubscribeObserver(observer: QueryObserver): void { this.observers = this.observers.filter(x => x !== observer) if (!this.observers.length) { @@ -314,55 +314,22 @@ export class Query { this.scheduleGc() } + + this.cache.notify(this) } invalidate(): void { if (!this.state.isInvalidated) { - this.dispatch({ type: ActionType.Invalidate }) - } - } - - /** - * @deprectated - */ - refetch( - options?: RefetchOptions, - config?: ResolvedQueryConfig - ): Promise { - let promise: Promise = this.fetch(undefined, config) - - if (!options?.throwOnError) { - promise = promise.catch(noop) + this.dispatch({ type: 'invalidate' }) } - - return promise - } - - /** - * @deprectated - */ - fetchMore( - fetchMoreVariable?: unknown, - options?: FetchMoreOptions, - config?: ResolvedQueryConfig - ): Promise { - return this.fetch( - { - fetchMore: { - fetchMoreVariable, - previous: options?.previous || false, - }, - }, - config - ) } async fetch( - options?: FetchOptions, - config?: ResolvedQueryConfig - ): Promise { + options?: QueryOptions, + fetchOptions?: FetchOptions + ): Promise { if (this.promise) { - if (options?.fetchMore && this.state.data) { + if (fetchOptions?.fetchMore && this.state.data) { // Silently cancel current fetch if the user wants to fetch more await this.cancel(true) } else { @@ -372,24 +339,35 @@ export class Query { } // Update config if passed, otherwise the config from the last execution is used - if (config) { - this.updateConfig(config) + if (options) { + this.setOptions(options) + } + + // Use the options from the first observer with a query function if no function is found. + // This can happen when the query is hydrated or created with setQueryData. + if (!this.options.queryFn) { + const observer = this.observers.find(x => x.options.queryFn) + if (observer) { + this.setOptions(observer.options) + } } - config = this.config + options = this.options // Get the query function params - const filter = config.queryFnParamsFilter - const params = filter ? filter(this.queryKey) : this.queryKey + let params = ensureArray(this.queryKey) + + const filter = options.queryFnParamsFilter + params = filter ? filter(params) : params this.promise = (async () => { try { let data: any - if (config.infinite) { - data = await this.startInfiniteFetch(config, params, options) + if (options.infinite) { + data = await this.startInfiniteFetch(options, params, fetchOptions) } else { - data = await this.startFetch(config, params, options) + data = await this.startFetch(options, params) } // Set success state @@ -404,14 +382,14 @@ export class Query { // Set error state if needed if (!(isCancelledError(error) && error.silent)) { this.dispatch({ - type: ActionType.Error, + type: 'error', error, }) } // Log error if (!isCancelledError(error)) { - Console.error(error) + getConsole().error(error) } // Cleanup @@ -426,35 +404,35 @@ export class Query { } private startFetch( - config: ResolvedQueryConfig, - params: unknown[], - _options?: FetchOptions - ): Promise { + options: QueryOptions, + params: unknown[] + ): Promise { // Create function to fetch the data - const fetchData = () => config.queryFn(...params) + const queryFn = options.queryFn || defaultQueryFn + const fetchData = () => queryFn(...params) // Set to fetching state if not already in it if (!this.state.isFetching) { - this.dispatch({ type: ActionType.Fetch }) + this.dispatch({ type: 'fetch' }) } // Try to fetch the data - return this.tryFetchData(config, fetchData) + return this.tryFetchData(options, fetchData) } private startInfiniteFetch( - config: ResolvedQueryConfig, + options: QueryOptions, params: unknown[], - options?: FetchOptions - ): Promise { - const fetchMore = options?.fetchMore + fetchOptions?: FetchOptions + ): Promise { + const fetchMore = fetchOptions?.fetchMore const { previous, fetchMoreVariable } = fetchMore || {} const isFetchingMore = fetchMore ? (previous ? 'previous' : 'next') : false - const prevPages: TResult[] = (this.state.data as any) || [] + const prevPages: TQueryFnData[] = (this.state.data as any) || [] // Create function to fetch a page const fetchPage = async ( - pages: TResult[], + pages: TQueryFnData[], prepend?: boolean, cursor?: unknown ) => { @@ -463,12 +441,13 @@ export class Query { if ( typeof cursor === 'undefined' && typeof lastPage !== 'undefined' && - config.getFetchMore + options.getFetchMore ) { - cursor = config.getFetchMore(lastPage, pages) + cursor = options.getFetchMore(lastPage, pages) } - const page = await config.queryFn(...params, cursor) + const queryFn = options.queryFn || defaultQueryFn + const page = await queryFn(...params, cursor) return prepend ? [page, ...pages] : [...pages, page] } @@ -493,18 +472,18 @@ export class Query { !this.state.isFetching || this.state.isFetchingMore !== isFetchingMore ) { - this.dispatch({ type: ActionType.Fetch, isFetchingMore }) + this.dispatch({ type: 'fetch', isFetchingMore }) } // Try to get the data - return this.tryFetchData(config, fetchData) + return this.tryFetchData(options, fetchData) } - private tryFetchData( - config: ResolvedQueryConfig, - fn: QueryFunction - ): Promise { - return new Promise((outerResolve, outerReject) => { + private tryFetchData( + options: QueryOptions, + fn: QueryFunction + ): Promise { + return new Promise((outerResolve, outerReject) => { let resolved = false let continueLoop: () => void let cancelTransport: () => void @@ -558,7 +537,10 @@ export class Query { } // Await data - resolve(await promiseOrValue) + const data = await promiseOrValue + + // Resolve with data + resolve(data) } catch (error) { // Stop if the fetch is already resolved if (resolved) { @@ -567,7 +549,7 @@ export class Query { // Do we need to retry the request? const { failureCount } = this.state - const { retry, retryDelay } = config + const { retry, retryDelay } = options const shouldRetry = retry === true || @@ -581,7 +563,7 @@ export class Query { } // Increase the failureCount - this.dispatch({ type: ActionType.Failed }) + this.dispatch({ type: 'failed' }) // Delay await sleep(functionalUpdate(retryDelay, failureCount) || 0) @@ -606,98 +588,102 @@ export class Query { } } -function getLastPage(pages: TResult[], previous?: boolean): TResult { +function defaultQueryFn() { + return Promise.reject() +} + +function getLastPage( + pages: TQueryFnData[], + previous?: boolean +): TQueryFnData { return previous ? pages[0] : pages[pages.length - 1] } -function hasMorePages( - config: ResolvedQueryConfig, +function hasMorePages( + options: QueryOptions, pages: unknown, previous?: boolean ): boolean | undefined { - if (config.infinite && config.getFetchMore && Array.isArray(pages)) { - return Boolean(config.getFetchMore(getLastPage(pages, previous), pages)) + if (options.infinite && options.getFetchMore && Array.isArray(pages)) { + return Boolean(options.getFetchMore(getLastPage(pages, previous), pages)) } } -function getDefaultState( - config: ResolvedQueryConfig -): QueryState { +function getDefaultState( + options: QueryOptions +): QueryState { const data = - typeof config.initialData === 'function' - ? (config.initialData as InitialDataFunction)() - : config.initialData + typeof options.initialData === 'function' + ? (options.initialData as InitialDataFunction)() + : options.initialData + + const hasData = typeof data !== 'undefined' const status = - typeof data !== 'undefined' - ? QueryStatus.Success - : config.enabled - ? QueryStatus.Loading - : QueryStatus.Idle + hasData && options.staleTime + ? 'success' + : options.enabled + ? 'loading' + : 'idle' return { - canFetchMore: hasMorePages(config, data), + canFetchMore: hasMorePages(options, data), data, + dataUpdateCount: 0, error: null, + errorUpdateCount: 0, failureCount: 0, - isFetching: status === QueryStatus.Loading, + isFetching: status === 'loading', isFetchingMore: false, - isInitialData: true, isInvalidated: false, status, - updateCount: 0, - updatedAt: Date.now(), + updatedAt: hasData ? Date.now() : 0, } } -export function queryReducer( - state: QueryState, - action: Action -): QueryState { +export function queryReducer( + state: QueryState, + action: Action +): QueryState { switch (action.type) { - case ActionType.Failed: + case 'failed': return { ...state, failureCount: state.failureCount + 1, } - case ActionType.Fetch: + case 'fetch': return { ...state, failureCount: 0, isFetching: true, isFetchingMore: action.isFetchingMore || false, - status: - typeof state.data !== 'undefined' - ? QueryStatus.Success - : QueryStatus.Loading, + status: typeof state.data === 'undefined' ? 'loading' : 'success', } - case ActionType.Success: + case 'success': return { ...state, canFetchMore: action.canFetchMore, data: action.data, + dataUpdateCount: state.dataUpdateCount + 1, error: null, failureCount: 0, isFetching: false, isFetchingMore: false, - isInitialData: false, isInvalidated: false, - status: QueryStatus.Success, - updateCount: state.updateCount + 1, + status: 'success', updatedAt: action.updatedAt ?? Date.now(), } - case ActionType.Error: + case 'error': return { ...state, error: action.error, + errorUpdateCount: state.errorUpdateCount + 1, failureCount: state.failureCount + 1, isFetching: false, isFetchingMore: false, - status: QueryStatus.Error, - throwInErrorBoundary: true, - updateCount: state.updateCount + 1, + status: 'error', } - case ActionType.Invalidate: + case 'invalidate': return { ...state, isInvalidated: true, diff --git a/src/core/queryCache.ts b/src/core/queryCache.ts index 9e553d3e52..965c57f2d7 100644 --- a/src/core/queryCache.ts +++ b/src/core/queryCache.ts @@ -1,517 +1,119 @@ import { - Updater, - deepIncludes, - getQueryArgs, - isDocumentVisible, - isOnline, - isPlainObject, - isServer, - noop, + QueryFilters, + hashQueryKey, + matchQuery, + parseFilterArgs, } from './utils' -import { getResolvedQueryConfig } from './config' import { Query } from './query' -import { - QueryConfig, - QueryFunction, - QueryKey, - ReactQueryConfig, - TypedQueryFunction, - TypedQueryFunctionArgs, - ResolvedQueryConfig, -} from './types' +import type { QueryKey, QueryOptions } from './types' import { notifyManager } from './notifyManager' -import { QueryObserver } from './queryObserver' // TYPES -interface QueryCacheConfig { - frozen?: boolean - defaultConfig?: ReactQueryConfig -} - -interface ClearOptions { - notify?: boolean -} - -interface PrefetchQueryOptions { - force?: boolean - throwOnError?: boolean -} - -interface RefetchQueriesOptions extends QueryPredicateOptions { - throwOnError?: boolean -} - -interface InvalidateQueriesOptions extends RefetchQueriesOptions { - refetchActive?: boolean - refetchInactive?: boolean -} - -interface QueryPredicateOptions { - active?: boolean - exact?: boolean - stale?: boolean -} - -type QueryPredicate = QueryKey | QueryPredicateFn | true - -type QueryPredicateFn = (query: Query) => boolean - -export interface FetchQueryObjectConfig { - queryKey: QueryKey - queryFn?: QueryFunction - config?: QueryConfig -} - -export interface PrefetchQueryObjectConfig { - queryKey: QueryKey - queryFn?: QueryFunction - config?: QueryConfig - options?: PrefetchQueryOptions -} - interface QueryHashMap { [hash: string]: Query } -type QueryCacheListener = ( - cache: QueryCache, - query?: Query -) => void +type QueryCacheListener = (cache: QueryCache, query?: Query) => void // CLASS export class QueryCache { - isFetching: number + private listeners: QueryCacheListener[] + private queries: Query[] + private queriesMap: QueryHashMap - private config: QueryCacheConfig - private globalListeners: QueryCacheListener[] - private queries: QueryHashMap - private queriesArray: Query[] - - constructor(config?: QueryCacheConfig) { - this.config = config || {} - this.globalListeners = [] - this.queries = {} - this.queriesArray = [] - this.isFetching = 0 + constructor() { + this.listeners = [] + this.queries = [] + this.queriesMap = {} } - notifyGlobalListeners(query?: Query) { - this.isFetching = this.getQueries().reduce( - (acc, q) => (q.state.isFetching ? acc + 1 : acc), - 0 - ) + build( + options: QueryOptions + ): Query { + const queryKey = options.queryKey! + const queryHash = options.queryHash || hashQueryKey(queryKey, options) + let query = this.get(queryHash) - notifyManager.batch(() => { - this.globalListeners.forEach(listener => { - notifyManager.schedule(() => { - listener(this, query) - }) + if (!query) { + query = new Query({ + cache: this, + queryKey, + queryHash, + options, }) - }) - } - - getDefaultConfig() { - return this.config.defaultConfig - } - - getResolvedQueryConfig( - queryKey: QueryKey, - config?: QueryConfig - ): ResolvedQueryConfig { - return getResolvedQueryConfig(this, queryKey, undefined, config) - } - - subscribe(listener: QueryCacheListener): () => void { - this.globalListeners.push(listener) - return () => { - this.globalListeners = this.globalListeners.filter(x => x !== listener) + this.add(query) } - } - clear(options?: ClearOptions): void { - this.removeQueries() - if (options?.notify) { - this.notifyGlobalListeners() - } + return query } - getQueries( - predicate?: QueryPredicate, - options?: QueryPredicateOptions - ): Query[] { - const anyKey = predicate === true || typeof predicate === 'undefined' - - if (anyKey && !options) { - return this.queriesArray + add(query: Query): void { + if (!this.queriesMap[query.queryHash]) { + this.queriesMap[query.queryHash] = query + this.queries.push(query) + this.notify(query) } - - let predicateFn: QueryPredicateFn - - if (typeof predicate === 'function') { - predicateFn = predicate as QueryPredicateFn - } else { - const { exact, active, stale } = options || {} - const resolvedConfig = this.getResolvedQueryConfig(predicate) - - predicateFn = query => { - // Check query key if needed - if (!anyKey) { - if (exact) { - // Check if the query key matches exactly - if (query.queryHash !== resolvedConfig.queryHash) { - return false - } - } else { - // Check if the query key matches partially - if (!deepIncludes(query.queryKey, resolvedConfig.queryKey)) { - return false - } - } - } - - // Check active state if needed - if (typeof active === 'boolean' && query.isActive() !== active) { - return false - } - - // Check stale state if needed - if (typeof stale === 'boolean' && query.isStale() !== stale) { - return false - } - - return true - } - } - - return this.queriesArray.filter(predicateFn) } - getQuery( - predicate: QueryPredicate - ): Query | undefined { - return this.getQueries(predicate, { exact: true })[0] - } - - getQueryByHash( - queryHash: string - ): Query | undefined { - return this.queries[queryHash] - } - - getQueryData(predicate: QueryPredicate): TResult | undefined { - return this.getQuery(predicate)?.state.data - } - - removeQuery(query: Query): void { - if (this.queries[query.queryHash]) { + remove(query: Query): void { + if (this.queriesMap[query.queryHash]) { query.destroy() - delete this.queries[query.queryHash] - this.queriesArray = this.queriesArray.filter(x => x !== query) - this.notifyGlobalListeners(query) + delete this.queriesMap[query.queryHash] + this.queries = this.queries.filter(x => x !== query) + this.notify(query) } } - removeQueries( - predicate?: QueryPredicate, - options?: QueryPredicateOptions - ): void { - this.getQueries(predicate, options).forEach(query => { - this.removeQuery(query) - }) - } - - cancelQueries( - predicate?: QueryPredicate, - options?: QueryPredicateOptions - ): void { - this.getQueries(predicate, options).forEach(query => { - query.cancel() - }) - } - - /** - * @return Promise resolving to an array with the invalidated queries. - */ - invalidateQueries( - predicate?: QueryPredicate, - options?: InvalidateQueriesOptions - ): Promise[]> { - const queries = this.getQueries(predicate, options) - + clear(): void { notifyManager.batch(() => { - queries.forEach(query => { - query.invalidate() + this.queries.forEach(query => { + this.remove(query) }) }) - - const { refetchActive = true, refetchInactive = false } = options || {} - - if (!refetchInactive && !refetchActive) { - return Promise.resolve(queries) - } - - const refetchOptions: RefetchQueriesOptions = { ...options } - - if (refetchActive && !refetchInactive) { - refetchOptions.active = true - } else if (refetchInactive && !refetchActive) { - refetchOptions.active = false - } - - let promise = this.refetchQueries(predicate, refetchOptions) - - if (!options?.throwOnError) { - promise = promise.catch(() => queries) - } - - return promise.then(() => queries) } - /** - * @return Promise resolving to an array with the refetched queries. - */ - refetchQueries( - predicate?: QueryPredicate, - options?: RefetchQueriesOptions - ): Promise[]> { - const promises: Promise>[] = [] - - notifyManager.batch(() => { - this.getQueries(predicate, options).forEach(query => { - let promise = query.fetch().then(() => query) - - if (!options?.throwOnError) { - promise = promise.catch(() => query) - } - - promises.push(promise) - }) - }) - - return Promise.all(promises) + get( + queryHash: string + ): Query | undefined { + return this.queriesMap[queryHash] } - resetErrorBoundaries(): void { - this.getQueries().forEach(query => { - query.state.throwInErrorBoundary = false - }) + getAll(): Query[] { + return this.queries } - buildQuery( - queryKey: QueryKey, - config?: QueryConfig - ): Query { - const resolvedConfig = this.getResolvedQueryConfig(queryKey, config) - let query = this.getQueryByHash(resolvedConfig.queryHash) - - if (!query) { - query = this.createQuery(resolvedConfig) - } - - return query + find( + arg1: QueryKey, + arg2?: QueryFilters + ): Query | undefined { + const [filters] = parseFilterArgs(arg1, arg2) + return this.queries.find(query => matchQuery(filters, query)) } - createQuery( - config: ResolvedQueryConfig - ): Query { - const query = new Query(config) - - // A frozen cache does not add new queries to the cache - if (!this.config.frozen) { - this.queries[query.queryHash] = query - this.queriesArray.push(query) - this.notifyGlobalListeners(query) - } - - return query + findAll(queryKey?: QueryKey, filters?: QueryFilters): Query[] + findAll(filters?: QueryFilters): Query[] + findAll(arg1?: QueryKey | QueryFilters, arg2?: QueryFilters): Query[] + findAll(arg1?: QueryKey | QueryFilters, arg2?: QueryFilters): Query[] { + const [filters] = parseFilterArgs(arg1, arg2) + return filters && Object.keys(filters).length > 0 + ? this.queries.filter(query => matchQuery(filters, query)) + : this.queries } - // Parameter syntax - fetchQuery( - queryKey: QueryKey, - queryConfig?: QueryConfig - ): Promise - - // Parameter syntax with query function - fetchQuery( - queryKey: QueryKey, - queryFn: TypedQueryFunction, - queryConfig?: QueryConfig - ): Promise - - fetchQuery( - queryKey: QueryKey, - queryFn: QueryFunction, - queryConfig?: QueryConfig - ): Promise - - // Object syntax - fetchQuery( - config: FetchQueryObjectConfig - ): Promise - - // Implementation - fetchQuery( - arg1: any, - arg2?: any, - arg3?: any - ): Promise { - const [queryKey, config] = getQueryArgs(arg1, arg2, arg3) - - const resolvedConfig = this.getResolvedQueryConfig(queryKey, { - // https://github.com/tannerlinsley/react-query/issues/652 - retry: false, - ...config, - }) - - let query = this.getQueryByHash(resolvedConfig.queryHash) - - if (!query) { - query = this.createQuery(resolvedConfig) - } - - if (!query.isStaleByTime(config.staleTime)) { - return Promise.resolve(query.state.data as TResult) - } - - return query.fetch(undefined, resolvedConfig) - } - - // Parameter syntax with optional prefetch options - prefetchQuery( - queryKey: QueryKey, - options?: PrefetchQueryOptions - ): Promise - - // Parameter syntax with query function and optional prefetch options - prefetchQuery( - queryKey: QueryKey, - queryFn: TypedQueryFunction, - options?: PrefetchQueryOptions - ): Promise - - prefetchQuery( - queryKey: QueryKey, - queryFn: QueryFunction, - options?: PrefetchQueryOptions - ): Promise - - // Parameter syntax with query function, config and optional prefetch options - prefetchQuery( - queryKey: QueryKey, - queryFn: TypedQueryFunction, - queryConfig: QueryConfig, - options?: PrefetchQueryOptions - ): Promise - - prefetchQuery( - queryKey: QueryKey, - queryFn: QueryFunction, - queryConfig: QueryConfig, - options?: PrefetchQueryOptions - ): Promise - - // Object syntax - prefetchQuery( - config: PrefetchQueryObjectConfig - ): Promise - - // Implementation - prefetchQuery( - arg1: any, - arg2?: any, - arg3?: any, - arg4?: any - ): Promise { - if ( - isPlainObject(arg2) && - (arg2.hasOwnProperty('throwOnError') || arg2.hasOwnProperty('force')) - ) { - arg4 = arg2 - arg2 = undefined - arg3 = undefined - } - - const [queryKey, config, options] = getQueryArgs< - TResult, - TError, - PrefetchQueryOptions | undefined - >(arg1, arg2, arg3, arg4) - - if (options?.force) { - config.staleTime = 0 - } - - let promise: Promise = this.fetchQuery( - queryKey, - config - ) - - if (!options?.throwOnError) { - promise = promise.catch(noop) + subscribe(listener: QueryCacheListener): () => void { + this.listeners.push(listener) + return () => { + this.listeners = this.listeners.filter(x => x !== listener) } - - return promise - } - - // Parameter syntax - watchQuery( - queryKey: QueryKey, - queryConfig?: QueryConfig - ): QueryObserver - - // Parameter syntax with query function - watchQuery( - queryKey: QueryKey, - queryFn: TypedQueryFunction, - queryConfig?: QueryConfig - ): QueryObserver - - watchQuery( - queryKey: QueryKey, - queryFn: QueryFunction, - queryConfig?: QueryConfig - ): QueryObserver - - // Implementation - watchQuery( - arg1: any, - arg2?: any, - arg3?: any - ): QueryObserver { - const [queryKey, config] = getQueryArgs(arg1, arg2, arg3) - const resolvedConfig = this.getResolvedQueryConfig(queryKey, config) - return new QueryObserver(resolvedConfig) } - setQueryData( - queryKey: QueryKey, - updater: Updater, - config?: QueryConfig - ) { - this.buildQuery(queryKey, config).setData(updater) - } -} - -const defaultQueryCache = new QueryCache({ frozen: isServer }) -export { defaultQueryCache as queryCache } -export const queryCaches = [defaultQueryCache] - -/** - * @deprecated - */ -export function makeQueryCache(config?: QueryCacheConfig) { - return new QueryCache(config) -} - -export function onVisibilityOrOnlineChange(type: 'focus' | 'online') { - if (isDocumentVisible() && isOnline()) { + notify(query?: Query) { notifyManager.batch(() => { - queryCaches.forEach(queryCache => { - queryCache.getQueries().forEach(query => { - query.onInteraction(type) + this.listeners.forEach(listener => { + notifyManager.schedule(() => { + listener(this, query) }) }) }) diff --git a/src/core/queryClient.ts b/src/core/queryClient.ts new file mode 100644 index 0000000000..fccff1a3db --- /dev/null +++ b/src/core/queryClient.ts @@ -0,0 +1,336 @@ +import { + QueryFilters, + Updater, + isDocumentVisible, + isOnline, + noop, + parseFilterArgs, + parseQueryArgs, + uniq, +} from './utils' +import { DEFAULT_OPTIONS, mergeDefaultOptions } from './config' +import type { + DefaultOptions, + InvalidateOptions, + InvalidateQueryFilters, + MutationOptions, + QueryFunction, + QueryKey, + QueryObserverOptions, + QueryOptions, + RefetchOptions, +} from './types' +import { notifyManager } from './notifyManager' +import { QueryCache } from './queryCache' +import { QueryObserver } from './queryObserver' +import { QueriesObserver } from './queriesObserver' + +// TYPES + +interface QueryClientConfig { + cache: QueryCache + defaultOptions?: DefaultOptions +} + +// CLASS + +export class QueryClient { + private cache: QueryCache + private defaultOptions: DefaultOptions + + constructor(config: QueryClientConfig) { + this.cache = config.cache + this.defaultOptions = mergeDefaultOptions( + DEFAULT_OPTIONS, + config.defaultOptions + ) + } + + mount(): void { + mountedClients.push(this) + } + + unmount(): void { + const index = mountedClients.indexOf(this) + if (index > -1) { + mountedClients.splice(index, 1) + } + } + + isFetching(): number { + return this.cache + .getAll() + .reduce((acc, q) => (q.state.isFetching ? acc + 1 : acc), 0) + } + + setQueryDefaults( + options: QueryOptions + ): void + setQueryDefaults( + queryKey: QueryKey, + options?: QueryOptions + ): void + setQueryDefaults( + queryKey: QueryKey, + queryFn: QueryFunction, + options?: QueryOptions + ): void + setQueryDefaults( + arg1: QueryKey | QueryOptions, + arg2?: + | QueryFunction + | QueryOptions, + arg3?: QueryOptions + ): void { + const parsedOptions = parseQueryArgs(arg1, arg2, arg3) + const defaultedOptions = this.defaultQueryOptions(parsedOptions) + this.cache.build(defaultedOptions).setDefaultOptions(defaultedOptions) + } + + getQueryData( + queryKey: QueryKey, + filters?: QueryFilters + ): TData | undefined { + return this.cache.find(queryKey, filters)?.state.data + } + + setQueryData( + queryKey: QueryKey, + updater: Updater + ): TData { + const parsedOptions = parseQueryArgs(queryKey) + const defaultedOptions = this.defaultQueryOptions(parsedOptions) + return this.cache.build(defaultedOptions).setData(updater) + } + + removeQueries(filters?: QueryFilters): void + removeQueries(queryKey?: QueryKey, filters?: QueryFilters): void + removeQueries(arg1?: QueryKey | QueryFilters, arg2?: QueryFilters): void { + notifyManager.batch(() => { + this.cache.findAll(arg1, arg2).forEach(query => { + this.cache.remove(query) + }) + }) + } + + cancelQueries(filters?: QueryFilters): Promise + cancelQueries(queryKey?: QueryKey, filters?: QueryFilters): Promise + cancelQueries( + arg1?: QueryKey | QueryFilters, + arg2?: QueryFilters + ): Promise { + const promises = notifyManager.batch(() => + this.cache.findAll(arg1, arg2).map(query => query.cancel()) + ) + return Promise.all(promises).then(noop) + } + + invalidateQueries( + filters?: InvalidateQueryFilters, + options?: InvalidateOptions + ): Promise + invalidateQueries( + queryKey?: QueryKey, + filters?: InvalidateQueryFilters, + options?: InvalidateOptions + ): Promise + invalidateQueries( + arg1?: QueryKey | InvalidateQueryFilters, + arg2?: InvalidateQueryFilters | InvalidateOptions, + arg3?: InvalidateOptions + ): Promise { + const [filters, options] = parseFilterArgs(arg1, arg2, arg3) + + const refetchFilters: QueryFilters = { + active: filters.refetchActive ?? true, + inactive: filters.refetchInactive, + } + + return notifyManager.batch(() => { + this.cache.findAll(filters).forEach(query => { + query.invalidate() + }) + return this.refetchQueries(refetchFilters, options) + }) + } + + refetchQueries( + filters?: QueryFilters, + options?: RefetchOptions + ): Promise + refetchQueries( + queryKey?: QueryKey, + filters?: QueryFilters, + options?: RefetchOptions + ): Promise + refetchQueries( + arg1?: QueryKey | QueryFilters, + arg2?: QueryFilters | RefetchOptions, + arg3?: RefetchOptions + ): Promise { + const [filters, options] = parseFilterArgs(arg1, arg2, arg3) + + const promises = notifyManager.batch(() => + this.cache.findAll(filters).map(query => query.fetch()) + ) + + let promise = Promise.all(promises).then(noop) + + if (!options?.throwOnError) { + promise = promise.catch(noop) + } + + return promise + } + + watchQuery< + TData = unknown, + TError = unknown, + TQueryFnData = TData, + TQueryData = TQueryFnData + >( + options: QueryObserverOptions + ): QueryObserver + watchQuery< + TData = unknown, + TError = unknown, + TQueryFnData = TData, + TQueryData = TQueryFnData + >( + queryKey: QueryKey, + options?: QueryObserverOptions + ): QueryObserver + watchQuery< + TData = unknown, + TError = unknown, + TQueryFnData = TData, + TQueryData = TQueryFnData + >( + queryKey: QueryKey, + queryFn: QueryFunction, + options?: QueryObserverOptions + ): QueryObserver + watchQuery( + arg1: + | QueryKey + | QueryObserverOptions, + arg2?: + | QueryFunction + | QueryObserverOptions, + arg3?: QueryObserverOptions + ): QueryObserver { + const parsedOptions = parseQueryArgs(arg1, arg2, arg3) + return new QueryObserver({ client: this, options: parsedOptions }) + } + + watchQueries(queries: QueryObserverOptions[]): QueriesObserver { + return new QueriesObserver({ client: this, queries }) + } + + fetchQueryData( + options: QueryOptions + ): Promise + fetchQueryData( + queryKey: QueryKey, + options?: QueryOptions + ): Promise + fetchQueryData( + queryKey: QueryKey, + queryFn: QueryFunction, + options?: QueryOptions + ): Promise + fetchQueryData( + arg1: QueryKey | QueryOptions, + arg2?: + | QueryFunction + | QueryOptions, + arg3?: QueryOptions + ): Promise { + const parsedOptions = parseQueryArgs(arg1, arg2, arg3) + + // https://github.com/tannerlinsley/react-query/issues/652 + if (typeof parsedOptions.retry === 'undefined') { + parsedOptions.retry = false + } + + const defaultedOptions = this.defaultQueryOptions(parsedOptions) + + let query = this.cache.find( + defaultedOptions.queryKey! + ) + + if (!query) { + query = this.cache.build(defaultedOptions) + } else if (!query.isStaleByTime(defaultedOptions.staleTime)) { + return Promise.resolve(query.state.data as TData) + } + + return query.fetch(defaultedOptions) + } + + prefetchQuery(options: QueryOptions): Promise + prefetchQuery(queryKey: QueryKey, options?: QueryOptions): Promise + prefetchQuery( + queryKey: QueryKey, + queryFn: QueryFunction, + options?: QueryOptions + ): Promise + prefetchQuery( + arg1: QueryKey | QueryOptions, + arg2?: QueryFunction | QueryOptions, + arg3?: QueryOptions + ): Promise { + return this.fetchQueryData(arg1 as any, arg2 as any, arg3) + .then(noop) + .catch(noop) + } + + getCache(): QueryCache { + return this.cache + } + + defaultQueryOptions( + options?: QueryOptions + ): QueryOptions { + return { + ...this.defaultOptions.queries, + ...options, + } as QueryOptions + } + + defaultQueryObserverOptions( + options?: QueryObserverOptions + ): QueryObserverOptions { + return { + ...this.defaultOptions.queries, + ...options, + } as QueryObserverOptions + } + + defaultMutationOptions( + options?: MutationOptions + ): MutationOptions { + return { + ...this.defaultOptions.queries, + ...options, + } as MutationOptions + } +} + +const mountedClients: QueryClient[] = [] + +export function onExternalEvent(type: 'focus' | 'online') { + if (isDocumentVisible() && isOnline()) { + notifyManager.batch(() => { + uniq(mountedClients.map(x => x.getCache())).forEach(cache => { + cache.getAll().forEach(query => { + if (type === 'focus') { + query.onFocus() + } else { + query.onOnline() + } + }) + }) + }) + } +} diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index 775111521b..35538cca1b 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -4,39 +4,63 @@ import { isServer, isValidTimeout, noop, + timeUntilStale, } from './utils' import { notifyManager } from './notifyManager' -import type { QueryConfig, QueryResult, ResolvedQueryConfig } from './types' -import type { Query, Action, FetchMoreOptions, RefetchOptions } from './query' -import { DEFAULT_CONFIG, isResolvedQueryConfig } from './config' +import type { + FetchMoreOptions, + QueryObserverOptions, + QueryObserverResult, + QueryOptions, + RefetchOptions, +} from './types' +import type { Query, Action, FetchOptions } from './query' +import { QueryClient } from './queryClient' + +export interface QueryObserverConfig< + TData = unknown, + TError = unknown, + TQueryFnData = TData, + TQueryData = TQueryFnData +> { + client: QueryClient + options?: QueryObserverOptions +} -export type UpdateListener = ( - result: QueryResult +type QueryObserverListener = ( + result: QueryObserverResult ) => void interface NotifyOptions { - globalListeners?: boolean + cache?: boolean listener?: boolean onError?: boolean onSuccess?: boolean } -export class QueryObserver { - config: ResolvedQueryConfig - - private currentQuery!: Query - private currentResult!: QueryResult - private previousQueryResult?: QueryResult - private listener?: UpdateListener - private isStale: boolean - private initialUpdateCount: number +export class QueryObserver< + TData = unknown, + TError = unknown, + TQueryFnData = TData, + TQueryData = TQueryFnData +> { + options: QueryObserverOptions + + private client: QueryClient + private currentQuery!: Query + private currentResult!: QueryObserverResult + private previousQueryResult?: QueryObserverResult + private listener?: QueryObserverListener + private initialDataUpdateCount: number private staleTimeoutId?: number private refetchIntervalId?: number - constructor(config: ResolvedQueryConfig) { - this.config = config - this.isStale = true - this.initialUpdateCount = 0 + constructor( + config: QueryObserverConfig + ) { + this.client = config.client + this.options = config.client.defaultQueryObserverOptions(config.options) + this.initialDataUpdateCount = 0 // Bind exposed methods this.remove = this.remove.bind(this) @@ -48,17 +72,19 @@ export class QueryObserver { this.updateQuery() } - subscribe(listener?: UpdateListener): () => void { + subscribe(listener?: QueryObserverListener): () => void { this.listener = listener || noop this.currentQuery.subscribeObserver(this) - if ( - this.config.enabled && - (this.config.forceFetchOnMount || this.config.refetchOnMount === 'always') - ) { - this.fetch() - } else { - this.optionalFetch() + if (this.options.enabled) { + if (this.options.refetchOnMount === 'always') { + this.fetch() + } else if ( + this.options.refetchOnMount || + !this.currentQuery.state.updatedAt + ) { + this.optionalFetch() + } } this.updateTimers() @@ -72,18 +98,16 @@ export class QueryObserver { this.currentQuery.unsubscribeObserver(this) } - updateConfig( - config: QueryConfig | ResolvedQueryConfig + setOptions( + options?: QueryObserverOptions ): void { - const prevConfig = this.config + const prevOptions = this.options const prevQuery = this.currentQuery - this.config = isResolvedQueryConfig(config) - ? config - : this.config.queryCache.getResolvedQueryConfig( - this.config.queryKey, - config - ) + this.options = this.client.defaultQueryObserverOptions({ + ...prevOptions, + ...options, + }) this.updateQuery() @@ -100,119 +124,119 @@ export class QueryObserver { } // Optionally fetch if the query became enabled - if (config.enabled && !prevConfig.enabled) { + if (this.options.enabled && !prevOptions.enabled) { this.optionalFetch() } // Update stale interval if needed if ( - config.enabled !== prevConfig.enabled || - config.staleTime !== prevConfig.staleTime + this.options.enabled !== prevOptions.enabled || + this.options.staleTime !== prevOptions.staleTime ) { this.updateStaleTimeout() } // Update refetch interval if needed if ( - config.enabled !== prevConfig.enabled || - config.refetchInterval !== prevConfig.refetchInterval + this.options.enabled !== prevOptions.enabled || + this.options.refetchInterval !== prevOptions.refetchInterval ) { this.updateRefetchInterval() } } - getCurrentQuery(): Query { - return this.currentQuery - } - - getCurrentResult(): QueryResult { + getCurrentResult(): QueryObserverResult { return this.currentResult } - /** - * @deprecated - */ - clear(): void { - this.remove() + getCurrentQuery(): Query { + return this.currentQuery } remove(): void { this.currentQuery.remove() } - refetch(options?: RefetchOptions): Promise { - return this.currentQuery.refetch(options, this.config) + refetch(options?: RefetchOptions): Promise { + return this.fetch(options) } fetchMore( fetchMoreVariable?: unknown, options?: FetchMoreOptions - ): Promise { - return this.currentQuery - .fetchMore(fetchMoreVariable, options, this.config) - .catch(noop) + ): Promise { + return this.fetch({ fetchMore: { ...options, fetchMoreVariable } }) } - fetch(): Promise { + fetch(fetchOptions?: FetchOptions): Promise { + const queryOptions = this.getQueryOptions() + // Never try to fetch if no query function has been set - if (this.config.queryFn === DEFAULT_CONFIG.queries?.queryFn) { + if (!queryOptions.queryFn && !this.currentQuery.defaultOptions?.queryFn) { return Promise.resolve(this.currentResult.data) } - return this.currentQuery.fetch(undefined, this.config).catch(noop) + let promise: Promise = this.currentQuery + .fetch(queryOptions, fetchOptions) + .then(data => this.resolveData(data)) + + if (!fetchOptions?.throwOnError) { + promise = promise.catch(noop) + } + + return promise } private optionalFetch(): void { - if ( - this.config.enabled && // Only fetch if enabled - this.isStale && // Only fetch if stale - !(this.config.suspense && this.currentResult.isFetched) && // Don't refetch if in suspense mode and the data is already fetched - (this.config.refetchOnMount || this.currentQuery.observers.length === 1) - ) { + if (this.options.enabled && this.currentResult.isStale) { this.fetch() } } private updateStaleTimeout(): void { - if (isServer) { - return - } - this.clearStaleTimeout() - if (this.isStale || !isValidTimeout(this.config.staleTime)) { + if ( + isServer || + this.currentResult.isStale || + !isValidTimeout(this.options.staleTime) + ) { return } - const timeElapsed = Date.now() - this.currentResult.updatedAt - const timeUntilStale = this.config.staleTime - timeElapsed + 1 - const timeout = Math.max(timeUntilStale, 0) + const time = timeUntilStale( + this.currentResult.updatedAt, + this.options.staleTime + ) + + // The timeout is sometimes triggered 1 ms before the stale time expiration. + // To mitigate this issue we always add 1 ms to the timeout. + const timeout = time + 1 this.staleTimeoutId = setTimeout(() => { - if (!this.isStale) { - this.isStale = true + if (!this.currentResult.isStale) { this.updateResult() - this.notify({ listener: true, globalListeners: true }) + this.notify({ listener: true, cache: true }) } }, timeout) } private updateRefetchInterval(): void { - if (isServer) { - return - } - this.clearRefetchInterval() - if (!this.config.enabled || !isValidTimeout(this.config.refetchInterval)) { + if ( + isServer || + !this.options.enabled || + !isValidTimeout(this.options.refetchInterval) + ) { return } this.refetchIntervalId = setInterval(() => { - if (this.config.refetchIntervalInBackground || isDocumentVisible()) { + if (this.options.refetchIntervalInBackground || isDocumentVisible()) { this.fetch() } - }, this.config.refetchInterval) + }, this.options.refetchInterval) } updateTimers(): void { @@ -226,51 +250,58 @@ export class QueryObserver { } private clearStaleTimeout(): void { - if (this.staleTimeoutId) { - clearInterval(this.staleTimeoutId) - this.staleTimeoutId = undefined - } + clearInterval(this.staleTimeoutId) + this.staleTimeoutId = undefined } private clearRefetchInterval(): void { - if (this.refetchIntervalId) { - clearInterval(this.refetchIntervalId) - this.refetchIntervalId = undefined - } + clearInterval(this.refetchIntervalId) + this.refetchIntervalId = undefined + } + + private getQueryOptions(): QueryOptions { + return this.options as QueryOptions + } + + private resolveData(data?: TQueryData): TData { + return this.options.select && typeof data !== 'undefined' + ? this.options.select(data) + : ((data as unknown) as TData) } private updateResult(): void { const { state } = this.currentQuery - let { data, status, updatedAt } = state + let { status, updatedAt } = state let isPreviousData = false + let data // Keep previous data if needed if ( - this.config.keepPreviousData && - state.isInitialData && + this.options.keepPreviousData && + !state.dataUpdateCount && this.previousQueryResult?.isSuccess ) { data = this.previousQueryResult.data updatedAt = this.previousQueryResult.updatedAt status = this.previousQueryResult.status isPreviousData = true + } else { + data = this.resolveData(state.data) } this.currentResult = { ...getStatusProps(status), canFetchMore: state.canFetchMore, - clear: this.remove, data, error: state.error, failureCount: state.failureCount, fetchMore: this.fetchMore, - isFetched: state.updateCount > 0, - isFetchedAfterMount: state.updateCount > this.initialUpdateCount, + isFetched: state.dataUpdateCount > 0, + isFetchedAfterMount: state.dataUpdateCount > this.initialDataUpdateCount, isFetching: state.isFetching, isFetchingMore: state.isFetchingMore, - isInitialData: state.isInitialData, isPreviousData, - isStale: this.isStale, + isStale: this.currentQuery.isStaleByTime(this.options.staleTime), refetch: this.refetch, remove: this.remove, updatedAt, @@ -278,16 +309,10 @@ export class QueryObserver { } private updateQuery(): void { - const config = this.config const prevQuery = this.currentQuery - let query = config.queryCache.getQueryByHash( - config.queryHash - ) - - if (!query) { - query = config.queryCache.createQuery(config) - } + const queryOptions = this.getQueryOptions() + const query = this.client.getCache().build(queryOptions) if (query === prevQuery) { return @@ -295,67 +320,53 @@ export class QueryObserver { this.previousQueryResult = this.currentResult this.currentQuery = query - this.initialUpdateCount = query.state.updateCount - - // Update stale state on query switch - if (query.state.isInitialData) { - if (config.keepPreviousData && prevQuery) { - this.isStale = true - } else if (typeof config.initialStale === 'function') { - this.isStale = config.initialStale() - } else if (typeof config.initialStale === 'boolean') { - this.isStale = config.initialStale - } else { - this.isStale = typeof query.state.data === 'undefined' - } - } else { - this.isStale = query.isStaleByTime(config.staleTime) - } + this.initialDataUpdateCount = query.state.dataUpdateCount this.updateResult() - if (this.listener) { - prevQuery?.unsubscribeObserver(this) - this.currentQuery.subscribeObserver(this) + if (!this.listener) { + return } - } - onQueryUpdate(action: Action): void { - const { config } = this - const { type } = action + prevQuery?.unsubscribeObserver(this) + this.currentQuery.subscribeObserver(this) - // Update stale state on success, error or invalidation - if (type === 2 || type === 3 || type === 4) { - this.isStale = this.currentQuery.isStaleByTime(config.staleTime) + if (this.options.notifyOnStatusChange) { + this.notify({ listener: true }) } + } + + onQueryUpdate(action: Action): void { + const { options } = this + const { type } = action // Store current result and get new result const prevResult = this.currentResult this.updateResult() const currentResult = this.currentResult - // Update timers on success, error or invalidation - if (type === 2 || type === 3 || type === 4) { + // Update timers if needed + if (type === 'success' || type === 'error' || type === 'invalidate') { this.updateTimers() } // Do not notify if the query was invalidated but the stale state did not changed - if (type === 4 && currentResult.isStale === prevResult.isStale) { + if (type === 'invalidate' && currentResult.isStale === prevResult.isStale) { return } // Determine which callbacks to trigger const notifyOptions: NotifyOptions = {} - if (type === 2) { + if (type === 'success') { notifyOptions.onSuccess = true - } else if (type === 3) { + } else if (type === 'error') { notifyOptions.onError = true } if ( // Always notify if notifyOnStatusChange is set - config.notifyOnStatusChange || + options.notifyOnStatusChange || // Otherwise only notify on data or error change currentResult.data !== prevResult.data || currentResult.error !== prevResult.error @@ -366,13 +377,13 @@ export class QueryObserver { this.notify(notifyOptions) } - private notify(options: NotifyOptions): void { - const { config, currentResult, currentQuery, listener } = this - const { onSuccess, onSettled, onError } = config + private notify(notifyOptions: NotifyOptions): void { + const { options, currentResult, currentQuery, listener } = this + const { onSuccess, onSettled, onError } = options notifyManager.batch(() => { // First trigger the configuration callbacks - if (options.onSuccess) { + if (notifyOptions.onSuccess) { if (onSuccess) { notifyManager.schedule(() => { onSuccess(currentResult.data!) @@ -383,7 +394,7 @@ export class QueryObserver { onSettled(currentResult.data!, null) }) } - } else if (options.onError) { + } else if (notifyOptions.onError) { if (onError) { notifyManager.schedule(() => { onError(currentResult.error!) @@ -397,15 +408,15 @@ export class QueryObserver { } // Then trigger the listener - if (options.listener && listener) { + if (notifyOptions.listener && listener) { notifyManager.schedule(() => { listener(currentResult) }) } - // Then the global listeners - if (options.globalListeners) { - config.queryCache.notifyGlobalListeners(currentQuery) + // Then the cache listeners + if (notifyOptions.cache) { + this.client.getCache().notify(currentQuery) } }) } diff --git a/src/core/setConsole.ts b/src/core/setConsole.ts new file mode 100644 index 0000000000..b177671f74 --- /dev/null +++ b/src/core/setConsole.ts @@ -0,0 +1,27 @@ +import { noop } from './utils' + +// TYPES + +type ConsoleFunction = (...args: any[]) => void + +export interface ConsoleObject { + log: ConsoleFunction + warn: ConsoleFunction + error: ConsoleFunction +} + +// FUNCTIONS + +let consoleObject: ConsoleObject = console || { + error: noop, + warn: noop, + log: noop, +} + +export function getConsole(): ConsoleObject { + return consoleObject +} + +export function setConsole(c: ConsoleObject) { + consoleObject = c +} diff --git a/src/core/setFocusHandler.ts b/src/core/setFocusHandler.ts index 4b69754155..0ec80d1cc7 100644 --- a/src/core/setFocusHandler.ts +++ b/src/core/setFocusHandler.ts @@ -1,9 +1,7 @@ import { createSetHandler, isServer } from './utils' -import { onVisibilityOrOnlineChange } from './queryCache' +import { onExternalEvent } from './queryClient' -export const setFocusHandler = createSetHandler(() => - onVisibilityOrOnlineChange('focus') -) +export const setFocusHandler = createSetHandler(() => onExternalEvent('focus')) setFocusHandler(handleFocus => { if (isServer || !window?.addEventListener) { diff --git a/src/core/setOnlineHandler.ts b/src/core/setOnlineHandler.ts index a7f6303cf8..0805f7e529 100644 --- a/src/core/setOnlineHandler.ts +++ b/src/core/setOnlineHandler.ts @@ -1,8 +1,8 @@ import { createSetHandler, isServer } from './utils' -import { onVisibilityOrOnlineChange } from './queryCache' +import { onExternalEvent } from './queryClient' export const setOnlineHandler = createSetHandler(() => - onVisibilityOrOnlineChange('online') + onExternalEvent('online') ) setOnlineHandler(handleOnline => { diff --git a/src/core/tests/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx index 32b91e6746..2e5222ceb2 100644 --- a/src/core/tests/queryCache.test.tsx +++ b/src/core/tests/queryCache.test.tsx @@ -4,42 +4,55 @@ import { mockVisibilityState, mockConsoleError, mockNavigatorOnLine, + expectType, } from '../../react/tests/utils' -import { QueryCache, queryCache as defaultQueryCache } from '../..' +import { QueryCache, QueryClient } from '../..' import { isCancelledError, isError } from '../utils' -import { QueryResult } from '../types' +import { QueryObserverResult } from '../types' describe('queryCache', () => { + const cache = new QueryCache() + const client = new QueryClient({ cache }) + client.mount() + + test('setQueryDefaults does not trigger a fetch', async () => { + const key = queryKey() + client.setQueryDefaults(key, () => 'data') + await sleep(1) + const data = client.getQueryData(key) + expect(data).toBeUndefined() + }) + test('setQueryData does not crash if query could not be found', () => { const key = queryKey() const user = { userId: 1 } - expect(() => - defaultQueryCache.setQueryData([key, user], (prevUser?: typeof user) => ({ + expect(() => { + client.setQueryData([key, user], (prevUser?: typeof user) => ({ ...prevUser!, name: 'Edvin', })) - ).not.toThrow() + }).not.toThrow() }) test('setQueryData does not crash when variable is null', () => { const key = queryKey() - defaultQueryCache.setQueryData([key, { userId: null }], 'Old Data') + client.setQueryData([key, { userId: null }], 'Old Data') - expect(() => - defaultQueryCache.setQueryData([key, { userId: null }], 'New Data') - ).not.toThrow() + expect(() => { + client.setQueryData([key, { userId: null }], 'New Data') + }).not.toThrow() }) // https://github.com/tannerlinsley/react-query/issues/652 - test('fetchQuery should not retry by default', async () => { + test('fetchQueryData should not retry by default', async () => { const consoleMock = mockConsoleError() const key = queryKey() await expect( - defaultQueryCache.fetchQuery(key, async () => { + client.fetchQueryData(key, async () => { throw new Error('error') }) ).rejects.toEqual(new Error('error')) @@ -47,47 +60,47 @@ describe('queryCache', () => { consoleMock.mockRestore() }) - test('fetchQuery returns the cached data on cache hits', async () => { + test('fetchQueryData returns the cached data on cache hits', async () => { const key = queryKey() const fetchFn = () => Promise.resolve('data') - const first = await defaultQueryCache.fetchQuery(key, fetchFn) - const second = await defaultQueryCache.fetchQuery(key, fetchFn) + const first = await client.fetchQueryData(key, fetchFn) + const second = await client.fetchQueryData(key, fetchFn) expect(second).toBe(first) }) - test('fetchQuery should not force fetch', async () => { + test('fetchQueryData should not force fetch', async () => { const key = queryKey() - defaultQueryCache.setQueryData(key, 'og', { staleTime: 100 }) + client.setQueryData(key, 'og') const fetchFn = () => Promise.resolve('new') - const first = await defaultQueryCache.fetchQuery(key, fetchFn, { + const first = await client.fetchQueryData(key, fetchFn, { initialData: 'initial', staleTime: 100, }) expect(first).toBe('og') }) - test('fetchQuery should only fetch if the data is older then the given stale time', async () => { + test('fetchQueryData should only fetch if the data is older then the given stale time', async () => { const key = queryKey() let count = 0 const fetchFn = () => ++count - defaultQueryCache.setQueryData(key, count) - const first = await defaultQueryCache.fetchQuery(key, fetchFn, { + client.setQueryData(key, count) + const first = await client.fetchQueryData(key, fetchFn, { staleTime: 100, }) await sleep(11) - const second = await defaultQueryCache.fetchQuery(key, fetchFn, { + const second = await client.fetchQueryData(key, fetchFn, { staleTime: 10, }) - const third = await defaultQueryCache.fetchQuery(key, fetchFn, { + const third = await client.fetchQueryData(key, fetchFn, { staleTime: 10, }) await sleep(11) - const fourth = await defaultQueryCache.fetchQuery(key, fetchFn, { + const fourth = await client.fetchQueryData(key, fetchFn, { staleTime: 10, }) expect(first).toBe(0) @@ -96,25 +109,15 @@ describe('queryCache', () => { expect(fourth).toBe(2) }) - test('prefetchQuery should throw error when throwOnError is true', async () => { - const consoleMock = mockConsoleError() - + test('fetchQueryData should be able to perform an infinite query', async () => { const key = queryKey() - - await expect( - defaultQueryCache.prefetchQuery( - key, - async () => { - throw new Error('error') - }, - { - retry: false, - }, - { throwOnError: true } - ) - ).rejects.toEqual(new Error('error')) - - consoleMock.mockRestore() + const fetchFn = () => Promise.resolve('data') + const data = await client.fetchQueryData( + key, + fetchFn, + { infinite: true } + ) + expect(data).toMatchObject(['data']) }) test('prefetchQuery should return undefined when an error is thrown', async () => { @@ -122,7 +125,7 @@ describe('queryCache', () => { const key = queryKey() - const result = await defaultQueryCache.prefetchQuery( + const result = await client.prefetchQuery( key, async () => { throw new Error('error') @@ -143,9 +146,9 @@ describe('queryCache', () => { const callback = jest.fn() - defaultQueryCache.subscribe(callback) + cache.subscribe(callback) - defaultQueryCache.prefetchQuery(key, () => 'data') + client.prefetchQuery(key, () => 'data') await sleep(100) @@ -157,14 +160,14 @@ describe('queryCache', () => { const callback = jest.fn() - defaultQueryCache.subscribe(callback) + cache.subscribe(callback) - defaultQueryCache.prefetchQuery(key, () => 'data') - const query = defaultQueryCache.getQuery(key) + client.prefetchQuery(key, () => 'data') + const query = cache.find(key) await sleep(100) - expect(callback).toHaveBeenCalledWith(defaultQueryCache, query) + expect(callback).toHaveBeenCalledWith(cache, query) }) test('should notify subscribers when new query with initialData is added', async () => { @@ -172,9 +175,9 @@ describe('queryCache', () => { const callback = jest.fn() - defaultQueryCache.subscribe(callback) + cache.subscribe(callback) - defaultQueryCache.prefetchQuery(key, () => 'data', { + client.prefetchQuery(key, () => 'data', { initialData: 'initial', }) @@ -186,17 +189,17 @@ describe('queryCache', () => { test('setQueryData creates a new query if query was not found', () => { const key = queryKey() - defaultQueryCache.setQueryData(key, 'bar') + client.setQueryData(key, 'bar') - expect(defaultQueryCache.getQueryData(key)).toBe('bar') + expect(client.getQueryData(key)).toBe('bar') }) test('setQueryData creates a new query if query was not found', () => { const key = queryKey() - defaultQueryCache.setQueryData(key, 'qux') + client.setQueryData(key, 'qux') - expect(defaultQueryCache.getQueryData(key)).toBe('qux') + expect(client.getQueryData(key)).toBe('qux') }) test('removeQueries does not crash when exact is provided', async () => { @@ -205,16 +208,16 @@ describe('queryCache', () => { const fetchFn = () => Promise.resolve('data') // check the query was added to the cache - await defaultQueryCache.prefetchQuery(key, fetchFn) - expect(defaultQueryCache.getQuery(key)).toBeTruthy() + await client.prefetchQuery(key, fetchFn) + expect(cache.find(key)).toBeTruthy() // check the error doesn't occur expect(() => - defaultQueryCache.removeQueries(key, { exact: true }) + client.removeQueries({ queryKey: key, exact: true }) ).not.toThrow() // check query was successful removed - expect(defaultQueryCache.getQuery(key)).toBeFalsy() + expect(cache.find(key)).toBeFalsy() }) test('setQueryData updater function works as expected', () => { @@ -222,46 +225,288 @@ describe('queryCache', () => { const updater = jest.fn(oldData => `new data + ${oldData}`) - defaultQueryCache.setQueryData(key, 'test data') - defaultQueryCache.setQueryData(key, updater) + client.setQueryData(key, 'test data') + client.setQueryData(key, updater) expect(updater).toHaveBeenCalled() - expect(defaultQueryCache.getQuery(key)!.state.data).toEqual( - 'new data + test data' - ) + expect(cache.find(key)!.state.data).toEqual('new data + test data') + }) + + test('watchQueries should return an array with all query results', async () => { + const key1 = queryKey() + const key2 = queryKey() + const queryFn1 = jest.fn().mockReturnValue(1) + const queryFn2 = jest.fn().mockReturnValue(2) + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + const observer = testClient.watchQueries([ + { queryKey: key1, queryFn: queryFn1 }, + { queryKey: key2, queryFn: queryFn2 }, + ]) + let observerResult + observer.subscribe(result => { + observerResult = result + }) + await sleep(1) + observer.unsubscribe() + testCache.clear() + expect(observerResult).toMatchObject([{ data: 1 }, { data: 2 }]) + }) + + test('watchQueries should update when a query updates', async () => { + const key1 = queryKey() + const key2 = queryKey() + const queryFn1 = jest.fn().mockReturnValue(1) + const queryFn2 = jest.fn().mockReturnValue(2) + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + const observer = testClient.watchQueries([ + { queryKey: key1, queryFn: queryFn1 }, + { queryKey: key2, queryFn: queryFn2 }, + ]) + const results: QueryObserverResult[][] = [] + results.push(observer.getCurrentResult()) + observer.subscribe(result => { + results.push(result) + }) + await sleep(1) + testClient.setQueryData(key2, 3) + await sleep(1) + observer.unsubscribe() + testCache.clear() + expect(results.length).toBe(4) + expect(results).toMatchObject([ + [{ data: undefined }, { data: undefined }], + [{ data: 1 }, { data: undefined }], + [{ data: 1 }, { data: 2 }], + [{ data: 1 }, { data: 3 }], + ]) + }) + + test('watchQueries should update when a query is removed', async () => { + const key1 = queryKey() + const key2 = queryKey() + const queryFn1 = jest.fn().mockReturnValue(1) + const queryFn2 = jest.fn().mockReturnValue(2) + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + const observer = testClient.watchQueries([ + { queryKey: key1, queryFn: queryFn1 }, + { queryKey: key2, queryFn: queryFn2 }, + ]) + const results: QueryObserverResult[][] = [] + results.push(observer.getCurrentResult()) + observer.subscribe(result => { + results.push(result) + }) + await sleep(1) + observer.setQueries([{ queryKey: key2, queryFn: queryFn2 }]) + await sleep(1) + expect(testCache.find(key1, { active: true })).toBeUndefined() + expect(testCache.find(key2, { active: true })).toBeDefined() + observer.unsubscribe() + expect(testCache.find(key1, { active: true })).toBeUndefined() + expect(testCache.find(key2, { active: true })).toBeUndefined() + testCache.clear() + expect(results.length).toBe(4) + expect(results).toMatchObject([ + [{ data: undefined }, { data: undefined }], + [{ data: 1 }, { data: undefined }], + [{ data: 1 }, { data: 2 }], + [{ data: 2 }], + ]) + }) + + test('watchQueries should update when a query changed position', async () => { + const key1 = queryKey() + const key2 = queryKey() + const queryFn1 = jest.fn().mockReturnValue(1) + const queryFn2 = jest.fn().mockReturnValue(2) + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + const observer = testClient.watchQueries([ + { queryKey: key1, queryFn: queryFn1 }, + { queryKey: key2, queryFn: queryFn2 }, + ]) + const results: QueryObserverResult[][] = [] + results.push(observer.getCurrentResult()) + observer.subscribe(result => { + results.push(result) + }) + await sleep(1) + observer.setQueries([ + { queryKey: key2, queryFn: queryFn2 }, + { queryKey: key1, queryFn: queryFn1 }, + ]) + await sleep(1) + observer.unsubscribe() + testCache.clear() + expect(results.length).toBe(4) + expect(results).toMatchObject([ + [{ data: undefined }, { data: undefined }], + [{ data: 1 }, { data: undefined }], + [{ data: 1 }, { data: 2 }], + [{ data: 2 }, { data: 1 }], + ]) + }) + + test('watchQueries should not update when nothing has changed', async () => { + const key1 = queryKey() + const key2 = queryKey() + const queryFn1 = jest.fn().mockReturnValue(1) + const queryFn2 = jest.fn().mockReturnValue(2) + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + const observer = testClient.watchQueries([ + { queryKey: key1, queryFn: queryFn1 }, + { queryKey: key2, queryFn: queryFn2 }, + ]) + const results: QueryObserverResult[][] = [] + results.push(observer.getCurrentResult()) + observer.subscribe(result => { + results.push(result) + }) + await sleep(1) + observer.setQueries([ + { queryKey: key1, queryFn: queryFn1 }, + { queryKey: key2, queryFn: queryFn2 }, + ]) + await sleep(1) + observer.unsubscribe() + testCache.clear() + expect(results.length).toBe(3) + expect(results).toMatchObject([ + [{ data: undefined }, { data: undefined }], + [{ data: 1 }, { data: undefined }], + [{ data: 1 }, { data: 2 }], + ]) + }) + + test('watchQueries should trigger all fetches when subscribed', async () => { + const key1 = queryKey() + const key2 = queryKey() + const queryFn1 = jest.fn() + const queryFn2 = jest.fn() + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + const observer = testClient.watchQueries([ + { queryKey: key1, queryFn: queryFn1 }, + { queryKey: key2, queryFn: queryFn2 }, + ]) + observer.subscribe() + await sleep(1) + observer.unsubscribe() + testCache.clear() + expect(queryFn1).toHaveBeenCalledTimes(1) + expect(queryFn2).toHaveBeenCalledTimes(1) }) test('watchQuery should trigger a fetch when subscribed', async () => { const key = queryKey() const queryFn = jest.fn() - const cache = new QueryCache() - const observer = cache.watchQuery(key, queryFn) + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + const observer = testClient.watchQuery(key, queryFn) observer.subscribe() await sleep(1) observer.unsubscribe() - cache.clear() + testCache.clear() expect(queryFn).toHaveBeenCalledTimes(1) }) + test('watchQuery should be able to fetch with a selector', async () => { + const key = queryKey() + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + const observer = testClient.watchQuery(key, () => ({ count: 1 }), { + select: data => ({ myCount: data.count }), + }) + let observerResult + observer.subscribe(result => { + expectType>(result) + observerResult = result + }) + await sleep(1) + observer.unsubscribe() + testCache.clear() + expect(observerResult).toMatchObject({ data: { myCount: 1 } }) + }) + + test('watchQuery should be able to fetch with a selector using the fetch method', async () => { + const key = queryKey() + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + const observer = testClient.watchQuery(key, () => ({ count: 1 }), { + select: data => ({ myCount: data.count }), + }) + const observerResult = await observer.fetch() + testCache.clear() + expectType<{ myCount: number } | undefined>(observerResult) + expect(observerResult).toMatchObject({ myCount: 1 }) + }) + + test('watchQuery should be able to fetch an infinite query with selector', async () => { + const key = queryKey() + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + const observer = testClient.watchQuery( + { + queryKey: key, + queryFn: () => 1, + select: data => data.map(x => `${x}`), + infinite: true, + } + ) + let observerResult + observer.subscribe(result => { + observerResult = result + }) + await sleep(1) + observer.unsubscribe() + testCache.clear() + expect(observerResult).toMatchObject({ data: ['1'] }) + }) + + test('watchQuery should be able to fetch with a selector and object syntax', async () => { + const key = queryKey() + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + const observer = testClient.watchQuery({ + queryKey: key, + queryFn: () => ({ count: 1 }), + select: data => ({ myCount: data.count }), + }) + let observerResult + observer.subscribe(result => { + observerResult = result + }) + await sleep(1) + observer.unsubscribe() + testCache.clear() + expect(observerResult).toMatchObject({ data: { myCount: 1 } }) + }) + test('watchQuery should not trigger a fetch when subscribed and disabled', async () => { const key = queryKey() const queryFn = jest.fn() - const cache = new QueryCache() - const observer = cache.watchQuery(key, queryFn, { enabled: false }) + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + const observer = testClient.watchQuery(key, queryFn, { enabled: false }) observer.subscribe() await sleep(1) observer.unsubscribe() - cache.clear() + testCache.clear() expect(queryFn).toHaveBeenCalledTimes(0) }) test('watchQuery should not trigger a fetch when not subscribed', async () => { const key = queryKey() const queryFn = jest.fn() - const cache = new QueryCache() - cache.watchQuery(key, queryFn) + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + testClient.watchQuery(key, queryFn) await sleep(1) - cache.clear() + testCache.clear() expect(queryFn).toHaveBeenCalledTimes(0) }) @@ -269,12 +514,13 @@ describe('queryCache', () => { const key = queryKey() const queryFn = jest.fn() const callback = jest.fn() - const cache = new QueryCache() - const observer = cache.watchQuery(key) + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + const observer = testClient.watchQuery(key) observer.subscribe(callback) - await cache.fetchQuery(key, queryFn) + await testClient.fetchQueryData(key, queryFn) observer.unsubscribe() - cache.clear() + testCache.clear() expect(queryFn).toHaveBeenCalledTimes(1) expect(callback).toHaveBeenCalledTimes(1) }) @@ -282,17 +528,18 @@ describe('queryCache', () => { test('watchQuery should accept unresolved query config in update function', async () => { const key = queryKey() const queryFn = jest.fn() - const cache = new QueryCache() - const observer = cache.watchQuery(key) - const results: QueryResult[] = [] + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + const observer = testClient.watchQuery(key) + const results: QueryObserverResult[] = [] observer.subscribe(x => { results.push(x) }) - observer.updateConfig({ staleTime: 10 }) - await cache.fetchQuery(key, queryFn) + observer.setOptions({ staleTime: 10 }) + await testClient.fetchQueryData(key, queryFn) await sleep(100) observer.unsubscribe() - cache.clear() + testCache.clear() expect(queryFn).toHaveBeenCalledTimes(1) expect(results.length).toBe(2) expect(results[0]).toMatchObject({ isStale: false }) @@ -304,11 +551,18 @@ describe('queryCache', () => { const key2 = queryKey() const queryFn1 = jest.fn() const queryFn2 = jest.fn() - const cache = new QueryCache() - await cache.fetchQuery(key1, queryFn1) - await cache.fetchQuery(key2, queryFn2) - await cache.refetchQueries() - cache.clear() + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + await testClient.fetchQueryData(key1, queryFn1) + await testClient.fetchQueryData(key2, queryFn2) + const observer1 = testClient.watchQuery(key1) + const observer2 = testClient.watchQuery(key2) + observer1.subscribe() + observer2.subscribe() + await testClient.refetchQueries() + observer1.unsubscribe() + observer2.unsubscribe() + testCache.clear() expect(queryFn1).toHaveBeenCalledTimes(2) expect(queryFn2).toHaveBeenCalledTimes(2) }) @@ -318,13 +572,17 @@ describe('queryCache', () => { const key2 = queryKey() const queryFn1 = jest.fn() const queryFn2 = jest.fn() - const cache = new QueryCache() - await cache.fetchQuery(key1, queryFn1) - await cache.fetchQuery(key2, queryFn2) - await cache.refetchQueries([], { stale: false }) - cache.clear() + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + await testClient.fetchQueryData(key1, queryFn1) + await testClient.fetchQueryData(key2, queryFn2) + const observer = testClient.watchQuery(key1, { staleTime: Infinity }) + observer.subscribe() + await testClient.refetchQueries({ active: true, stale: false }) + observer.unsubscribe() + testCache.clear() expect(queryFn1).toHaveBeenCalledTimes(2) - expect(queryFn2).toHaveBeenCalledTimes(2) + expect(queryFn2).toHaveBeenCalledTimes(1) }) test('refetchQueries should be able to refetch all stale queries', async () => { @@ -332,12 +590,16 @@ describe('queryCache', () => { const key2 = queryKey() const queryFn1 = jest.fn() const queryFn2 = jest.fn() - const cache = new QueryCache() - await cache.fetchQuery(key1, queryFn1) - await cache.fetchQuery(key2, queryFn2) - cache.getQuery(key1)!.invalidate() - await cache.refetchQueries([], { stale: true }) - cache.clear() + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + await testClient.fetchQueryData(key1, queryFn1) + await testClient.fetchQueryData(key2, queryFn2) + const observer = testClient.watchQuery(key1) + observer.subscribe() + testClient.invalidateQueries(key1) + await testClient.refetchQueries({ stale: true }) + observer.unsubscribe() + testCache.clear() expect(queryFn1).toHaveBeenCalledTimes(2) expect(queryFn2).toHaveBeenCalledTimes(1) }) @@ -347,34 +609,53 @@ describe('queryCache', () => { const key2 = queryKey() const queryFn1 = jest.fn() const queryFn2 = jest.fn() - const cache = new QueryCache() - await cache.fetchQuery(key1, queryFn1) - await cache.fetchQuery(key2, queryFn2) - await cache.invalidateQueries(key1) - const observer = cache.watchQuery(key1) + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + await testClient.fetchQueryData(key1, queryFn1) + await testClient.fetchQueryData(key2, queryFn2) + testClient.invalidateQueries(key1) + const observer = testClient.watchQuery(key1) observer.subscribe() - await cache.refetchQueries([], { active: true, stale: true }) + await testClient.refetchQueries({ active: true, stale: true }) observer.unsubscribe() - cache.clear() + testCache.clear() expect(queryFn1).toHaveBeenCalledTimes(2) expect(queryFn2).toHaveBeenCalledTimes(1) }) - test('refetchQueries should be able to refetch all inactive queries', async () => { + test('refetchQueries should be able to refetch all active and inactive queries', async () => { const key1 = queryKey() const key2 = queryKey() const queryFn1 = jest.fn() const queryFn2 = jest.fn() - const cache = new QueryCache() - await cache.fetchQuery(key1, queryFn1) - await cache.fetchQuery(key2, queryFn2) - const observer = cache.watchQuery(key1, { staleTime: Infinity }) + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + await testClient.fetchQueryData(key1, queryFn1) + await testClient.fetchQueryData(key2, queryFn2) + const observer = testClient.watchQuery(key1, { staleTime: Infinity }) observer.subscribe() - await cache.refetchQueries([], { active: false }) - expect(queryFn1).toHaveBeenCalledTimes(1) + await testClient.refetchQueries() observer.unsubscribe() - cache.clear() - expect(queryFn1).toHaveBeenCalledTimes(1) + testCache.clear() + expect(queryFn1).toHaveBeenCalledTimes(2) + expect(queryFn2).toHaveBeenCalledTimes(2) + }) + + test('refetchQueries should be able to refetch all active and inactive queries', async () => { + const key1 = queryKey() + const key2 = queryKey() + const queryFn1 = jest.fn() + const queryFn2 = jest.fn() + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + await testClient.fetchQueryData(key1, queryFn1) + await testClient.fetchQueryData(key2, queryFn2) + const observer = testClient.watchQuery(key1, { staleTime: Infinity }) + observer.subscribe() + await testClient.refetchQueries({ active: true, inactive: true }) + observer.unsubscribe() + testCache.clear() + expect(queryFn1).toHaveBeenCalledTimes(2) expect(queryFn2).toHaveBeenCalledTimes(2) }) @@ -383,72 +664,93 @@ describe('queryCache', () => { const key2 = queryKey() const queryFn1 = jest.fn() const queryFn2 = jest.fn() - const cache = new QueryCache() - await cache.fetchQuery(key1, queryFn1) - await cache.fetchQuery(key2, queryFn2) - const observer = cache.watchQuery(key1, { + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + await testClient.fetchQueryData(key1, queryFn1) + await testClient.fetchQueryData(key2, queryFn2) + const observer = testClient.watchQuery(key1, { enabled: false, staleTime: Infinity, }) observer.subscribe() - await cache.invalidateQueries(key1) + testClient.invalidateQueries(key1) observer.unsubscribe() - cache.clear() + testCache.clear() expect(queryFn1).toHaveBeenCalledTimes(1) expect(queryFn2).toHaveBeenCalledTimes(1) }) - test('getQueries should return queries that partially match queryKey', async () => { - const key1 = queryKey() - const key2 = queryKey() - - const fetchData1 = () => Promise.resolve('data1') - const fetchData2 = () => Promise.resolve('data2') - const fetchDifferentData = () => Promise.resolve('data3') - await defaultQueryCache.prefetchQuery([key1, { page: 1 }], fetchData1) - await defaultQueryCache.prefetchQuery([key1, { page: 2 }], fetchData2) - await defaultQueryCache.prefetchQuery([key2], fetchDifferentData) - const queries = defaultQueryCache.getQueries(key1) - const data = queries.map(query => query.state.data) - expect(data).toEqual(['data1', 'data2']) + test('find should filter correctly', async () => { + const key = queryKey() + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + await testClient.prefetchQuery(key, () => 'data1') + const query = testCache.find(key)! + expect(query).toBeDefined() }) - test('getQueries should filter correctly', async () => { + test('findAll should filter correctly', async () => { const key1 = queryKey() const key2 = queryKey() - const cache = defaultQueryCache - - await cache.prefetchQuery(key1, () => 'data1') - await cache.prefetchQuery(key2, () => 'data2') - await cache.invalidateQueries(key2) - const query1 = cache.getQuery(key1)! - const query2 = cache.getQuery(key2)! - - expect(cache.getQueries(key1)).toEqual([query1]) - expect(cache.getQueries(key1, {})).toEqual([query1]) - expect(cache.getQueries(key1, { active: false })).toEqual([query1]) - expect(cache.getQueries(key1, { active: true })).toEqual([]) - expect(cache.getQueries(key1, { stale: true })).toEqual([]) - expect(cache.getQueries(key1, { stale: false })).toEqual([query1]) - expect(cache.getQueries(key1, { stale: false, active: true })).toEqual([]) - expect(cache.getQueries(key1, { stale: false, active: false })).toEqual([ + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) + await testClient.prefetchQuery(key1, () => 'data1') + await testClient.prefetchQuery(key2, () => 'data2') + await testClient.prefetchQuery([{ a: 'a', b: 'b' }], () => 'data3') + testClient.invalidateQueries(key2) + const query1 = testCache.find(key1)! + const query2 = testCache.find(key2)! + const query3 = testCache.find([{ a: 'a', b: 'b' }])! + + expect(testCache.findAll(key1)).toEqual([query1]) + expect(testCache.findAll()).toEqual([query1, query2, query3]) + expect(testCache.findAll({})).toEqual([query1, query2, query3]) + expect(testCache.findAll(key1, { active: false })).toEqual([query1]) + expect(testCache.findAll(key1, { active: true })).toEqual([]) + expect(testCache.findAll(key1, { stale: true })).toEqual([]) + expect(testCache.findAll(key1, { stale: false })).toEqual([query1]) + expect(testCache.findAll(key1, { stale: false, active: true })).toEqual([]) + expect(testCache.findAll(key1, { stale: false, active: false })).toEqual([ query1, ]) expect( - cache.getQueries(key1, { stale: false, active: false, exact: true }) + testCache.findAll(key1, { + stale: false, + active: false, + exact: true, + }) ).toEqual([query1]) - expect(cache.getQueries(key2)).toEqual([query2]) - expect(cache.getQueries(key2, { stale: undefined })).toEqual([query2]) - expect(cache.getQueries(key2, { stale: true })).toEqual([query2]) - expect(cache.getQueries(key2, { stale: false })).toEqual([]) + expect(testCache.findAll(key2)).toEqual([query2]) + expect(testCache.findAll(key2, { stale: undefined })).toEqual([query2]) + expect(testCache.findAll(key2, { stale: true })).toEqual([query2]) + expect(testCache.findAll(key2, { stale: false })).toEqual([]) + expect(testCache.findAll([{ b: 'b' }])).toEqual([query3]) + expect(testCache.findAll([{ a: 'a' }], { exact: false })).toEqual([query3]) + expect(testCache.findAll([{ a: 'a' }], { exact: true })).toEqual([]) + expect(testCache.findAll([{ a: 'a', b: 'b' }], { exact: true })).toEqual([ + query3, + ]) + expect(testCache.findAll([{ a: 'a', b: 'b' }])).toEqual([query3]) + expect(testCache.findAll([{ a: 'a', b: 'b', c: 'c' }])).toEqual([]) + expect(testCache.findAll([{ a: 'a' }], { stale: false })).toEqual([query3]) + expect(testCache.findAll([{ a: 'a' }], { stale: true })).toEqual([]) + expect(testCache.findAll([{ a: 'a' }], { fresh: false })).toEqual([]) + expect(testCache.findAll([{ a: 'a' }], { fresh: true })).toEqual([query3]) + expect(testCache.findAll([{ a: 'a' }], { active: true })).toEqual([]) + expect(testCache.findAll([{ a: 'a' }], { inactive: true })).toEqual([ + query3, + ]) + expect( + testCache.findAll({ predicate: query => query === query3 }) + ).toEqual([query3]) }) test('query interval is cleared when unsubscribed to a refetchInterval query', async () => { const key = queryKey() const fetchData = () => Promise.resolve('data') - const observer = defaultQueryCache.watchQuery(key, fetchData, { + const observer = client.watchQuery(key, fetchData, { cacheTime: 0, refetchInterval: 1, }) @@ -459,105 +761,93 @@ describe('queryCache', () => { // @ts-expect-error expect(observer.refetchIntervalId).toBeUndefined() await sleep(10) - expect(defaultQueryCache.getQuery(key)).toBeUndefined() + expect(cache.find(key)).toBeUndefined() }) test('query is garbage collected when unsubscribed to', async () => { const key = queryKey() - const observer = defaultQueryCache.watchQuery(key, async () => 'data', { + const observer = client.watchQuery(key, async () => 'data', { cacheTime: 0, }) - expect(defaultQueryCache.getQuery(key)).toBeDefined() + expect(cache.find(key)).toBeDefined() observer.subscribe() observer.unsubscribe() - expect(defaultQueryCache.getQuery(key)).toBeDefined() + expect(cache.find(key)).toBeDefined() await sleep(100) - expect(defaultQueryCache.getQuery(key)).toBeUndefined() + expect(cache.find(key)).toBeUndefined() }) test('query is not garbage collected unless there are no subscribers', async () => { const key = queryKey() - const observer = defaultQueryCache.watchQuery(key, async () => 'data', { + const observer = client.watchQuery(key, async () => 'data', { cacheTime: 0, }) - expect(defaultQueryCache.getQuery(key)).toBeDefined() + expect(cache.find(key)).toBeDefined() observer.subscribe() await sleep(100) - expect(defaultQueryCache.getQuery(key)).toBeDefined() + expect(cache.find(key)).toBeDefined() observer.unsubscribe() await sleep(100) - expect(defaultQueryCache.getQuery(key)).toBeUndefined() - defaultQueryCache.setQueryData(key, 'data') + expect(cache.find(key)).toBeUndefined() + client.setQueryData(key, 'data') await sleep(100) - expect(defaultQueryCache.getQuery(key)).toBeDefined() + expect(cache.find(key)).toBeDefined() }) describe('QueryCache', () => { - test('merges defaultConfig so providing a queryFn does not overwrite the default queryKeySerializerFn', async () => { + test('merges defaultOptions so providing a queryFn does not overwrite the default queryKeySerializerFn', async () => { const key = queryKey() const queryFn = () => 'data' - const queryCache = new QueryCache({ - defaultConfig: { queries: { queryFn } }, + const testClient = new QueryClient({ + cache: cache, + defaultOptions: { queries: { queryFn } }, }) - expect(() => queryCache.buildQuery(key)).not.toThrow( - 'config.queryKeySerializerFn is not a function' - ) + expect(() => testClient.prefetchQuery(key)).not.toThrow() }) - test('merges defaultConfig when query is added to cache', async () => { + test('merges defaultOptions when query is added to cache', async () => { const key = queryKey() - const queryCache = new QueryCache({ - defaultConfig: { - queries: { refetchOnMount: false, staleTime: Infinity }, + const testClient = new QueryClient({ + cache: cache, + defaultOptions: { + queries: { cacheTime: Infinity }, }, }) const fetchData = () => Promise.resolve(undefined) - await queryCache.prefetchQuery(key, fetchData) - const newQuery = queryCache.getQuery(key) - expect(newQuery?.config.staleTime).toBe(Infinity) - expect(newQuery?.config.refetchOnMount).toBe(false) - }) - - test('built queries are referencing the correct queryCache', () => { - const key = queryKey() - - const queryCache = new QueryCache() - const query = queryCache.buildQuery(key) - - // @ts-expect-error - expect(query.queryCache).toBe(queryCache) + await testClient.prefetchQuery(key, fetchData) + const newQuery = cache.find(key) + expect(newQuery?.options.cacheTime).toBe(Infinity) }) test('notifyGlobalListeners passes the same instance', async () => { const key = queryKey() - - const queryCache = new QueryCache() + const testCache = new QueryCache() + const testClient = new QueryClient({ cache: testCache }) const subscriber = jest.fn() - const unsubscribe = queryCache.subscribe(subscriber) - const query = queryCache.buildQuery(key) - query.setData('foo') + const unsubscribe = testCache.subscribe(subscriber) + testClient.setQueryData(key, 'foo') + const query = testCache.find(key) await sleep(1) - expect(subscriber).toHaveBeenCalledWith(queryCache, query) - + expect(subscriber).toHaveBeenCalledWith(testCache, query) unsubscribe() }) test('query should use the longest cache time it has seen', async () => { const key = queryKey() - await defaultQueryCache.prefetchQuery(key, () => 'data', { + await client.prefetchQuery(key, () => 'data', { cacheTime: 100, }) - await defaultQueryCache.prefetchQuery(key, () => 'data', { + await client.prefetchQuery(key, () => 'data', { cacheTime: 200, }) - await defaultQueryCache.prefetchQuery(key, () => 'data', { + await client.prefetchQuery(key, () => 'data', { cacheTime: 10, }) - const query = defaultQueryCache.getQuery(key)! + const query = cache.find(key)! expect(query.cacheTime).toBe(200) }) @@ -572,7 +862,7 @@ describe('queryCache', () => { let count = 0 let result - const promise = defaultQueryCache.fetchQuery( + const promise = client.fetchQueryData( key, async () => { count++ @@ -620,7 +910,7 @@ describe('queryCache', () => { let count = 0 let result - const promise = defaultQueryCache.fetchQuery( + const promise = client.fetchQueryData( key, async () => { count++ @@ -671,7 +961,7 @@ describe('queryCache', () => { let count = 0 let result - const promise = defaultQueryCache.fetchQuery( + const promise = client.fetchQueryData( key, async () => { count++ @@ -687,7 +977,7 @@ describe('queryCache', () => { result = data }) - const query = defaultQueryCache.getQuery(key)! + const query = cache.find(key)! // Check if the query is really paused await sleep(50) @@ -708,7 +998,7 @@ describe('queryCache', () => { test('query should continue if cancellation is not supported', async () => { const key = queryKey() - defaultQueryCache.prefetchQuery(key, async () => { + client.prefetchQuery(key, async () => { await sleep(100) return 'data' }) @@ -716,18 +1006,18 @@ describe('queryCache', () => { await sleep(10) // Subscribe and unsubscribe to simulate cancellation because the last observer unsubscribed - const observer = defaultQueryCache.watchQuery(key) + const observer = client.watchQuery(key) observer.subscribe() observer.unsubscribe() await sleep(100) - const query = defaultQueryCache.getQuery(key)! + const query = cache.find(key)! expect(query.state).toMatchObject({ data: 'data', status: 'success', - updateCount: 1, + dataUpdateCount: 1, }) }) @@ -736,7 +1026,7 @@ describe('queryCache', () => { const cancel = jest.fn() - defaultQueryCache.prefetchQuery(key, async () => { + client.prefetchQuery(key, async () => { const promise = new Promise((resolve, reject) => { sleep(100).then(() => resolve('data')) cancel.mockImplementation(() => { @@ -750,19 +1040,19 @@ describe('queryCache', () => { await sleep(10) // Subscribe and unsubscribe to simulate cancellation because the last observer unsubscribed - const observer = defaultQueryCache.watchQuery(key) + const observer = client.watchQuery(key) observer.subscribe() observer.unsubscribe() await sleep(100) - const query = defaultQueryCache.getQuery(key)! + const query = cache.find(key)! expect(cancel).toHaveBeenCalled() expect(query.state).toMatchObject({ data: undefined, status: 'error', - updateCount: 1, + errorUpdateCount: 1, }) }) @@ -778,7 +1068,7 @@ describe('queryCache', () => { let error - const promise = defaultQueryCache.fetchQuery(key, queryFn, { + const promise = client.fetchQueryData(key, queryFn, { retry: 3, retryDelay: 10, }) @@ -787,7 +1077,7 @@ describe('queryCache', () => { error = e }) - const query = defaultQueryCache.getQuery(key)! + const query = cache.find(key)! query.cancel() await sleep(100) @@ -806,8 +1096,8 @@ describe('queryCache', () => { return 'data' }) - defaultQueryCache.prefetchQuery(key, queryFn) - const query = defaultQueryCache.getQuery(key)! + client.prefetchQuery(key, queryFn) + const query = cache.find(key)! await sleep(10) query.cancel() await sleep(100) @@ -822,8 +1112,8 @@ describe('queryCache', () => { test('cancelling a resolved query should not have any effect', async () => { const key = queryKey() - await defaultQueryCache.prefetchQuery(key, async () => 'data') - const query = defaultQueryCache.getQuery(key)! + await client.prefetchQuery(key, async () => 'data') + const query = cache.find(key)! query.cancel() await sleep(10) expect(query.state.data).toBe('data') @@ -834,10 +1124,10 @@ describe('queryCache', () => { const key = queryKey() - await defaultQueryCache.prefetchQuery(key, async () => { + await client.prefetchQuery(key, async () => { throw new Error('error') }) - const query = defaultQueryCache.getQuery(key)! + const query = cache.find(key)! query.cancel() await sleep(10) diff --git a/src/core/tests/utils.test.tsx b/src/core/tests/utils.test.tsx index 1e8cd54225..f77201384b 100644 --- a/src/core/tests/utils.test.tsx +++ b/src/core/tests/utils.test.tsx @@ -1,11 +1,14 @@ import { replaceEqualDeep, deepIncludes, isPlainObject } from '../utils' -import { setConsole, queryCache } from '../..' +import { QueryClient, QueryCache, setConsole } from '../..' import { queryKey } from '../../react/tests/utils' describe('core/utils', () => { it('setConsole should override Console object', async () => { const key = queryKey() + const cache = new QueryCache() + const client = new QueryClient({ cache }) + const mockConsole = { error: jest.fn(), log: jest.fn(), @@ -14,7 +17,7 @@ describe('core/utils', () => { setConsole(mockConsole) - await queryCache.prefetchQuery( + await client.prefetchQuery( key, async () => { throw new Error('Test') diff --git a/src/core/types.ts b/src/core/types.ts index af7c9879cf..3c2462377d 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,54 +1,54 @@ -import type { FetchMoreOptions, RefetchOptions } from './query' -import type { QueryCache } from './queryCache' +import { QueryFilters } from './utils' -export type QueryKey = - | boolean - | null - | number - | object - | string - | undefined - | { [key: number]: QueryKey } - | { [key: string]: QueryKey } - | readonly QueryKey[] +export type QueryKey = string | unknown[] -export type ArrayQueryKey = QueryKey[] +export type QueryFunction = (...args: any[]) => T | Promise -export type QueryFunction = ( - ...args: any[] -) => TResult | Promise +export type InitialDataFunction = () => T | undefined -export type TypedQueryFunction< - TResult, - TArgs extends TypedQueryFunctionArgs = TypedQueryFunctionArgs -> = (...args: TArgs) => TResult | Promise - -export type TypedQueryFunctionArgs = readonly [unknown, ...unknown[]] +export type InitialStaleFunction = () => boolean -export type InitialDataFunction = () => TResult | undefined +export type QueryKeySerializerFunction = (queryKey: QueryKey) => string -export type InitialStaleFunction = () => boolean +export type ShouldRetryFunction = ( + failureCount: number, + error: TError +) => boolean -export type QueryKeySerializerFunction = ( - queryKey: QueryKey -) => [string, QueryKey[]] +export type RetryDelayFunction = (attempt: number) => number -export interface BaseQueryConfig { +export interface QueryOptions< + TData = unknown, + TError = unknown, + TQueryFnData = TData +> { + /** + * Set this to `false` to disable automatic refetching when the query mounts or changes query keys. + * To refetch the query, use the `refetch` method returned from the `useQuery` instance. + * Defaults to `true`. + */ + enabled?: boolean /** * If `false`, failed queries will not retry by default. * If `true`, failed queries will retry infinitely., failureCount: num * If set to an integer number, e.g. 3, failed queries will retry until the failed query count meets that number. * If set to a function `(failureCount, error) => boolean` failed queries will retry until the function returns false. */ - retry?: boolean | number | ((failureCount: number, error: TError) => boolean) - retryDelay?: number | ((retryAttempt: number) => number) + retry?: boolean | number | ShouldRetryFunction + retryDelay?: number | RetryDelayFunction cacheTime?: number + /** + * The time in milliseconds after data is considered stale. + * If set to `Infinity`, the data will never be stale. + */ + staleTime?: number isDataEqual?: (oldData: unknown, newData: unknown) => boolean - queryFn?: QueryFunction + queryFn?: QueryFunction queryKey?: QueryKey + queryHash?: string queryKeySerializerFn?: QueryKeySerializerFunction - queryFnParamsFilter?: (args: ArrayQueryKey) => ArrayQueryKey - initialData?: TResult | InitialDataFunction + queryFnParamsFilter?: (args: unknown[]) => unknown[] + initialData?: TData | InitialDataFunction infinite?: true /** * Set this to `false` to disable structural sharing between query results. @@ -59,31 +59,15 @@ export interface BaseQueryConfig { * This function can be set to automatically get the next cursor for infinite queries. * The result will also be used to determine the value of `canFetchMore`. */ - getFetchMore?: (lastPage: TData, allPages: TData[]) => unknown + getFetchMore?: (lastPage: TQueryFnData, allPages: TQueryFnData[]) => unknown } -export interface QueryObserverConfig< - TResult, +export interface QueryObserverOptions< + TData = unknown, TError = unknown, - TData = TResult -> extends BaseQueryConfig { - /** - * Set this to `false` to disable automatic refetching when the query mounts or changes query keys. - * To refetch the query, use the `refetch` method returned from the `useQuery` instance. - * Defaults to `true`. - */ - enabled?: boolean | unknown - /** - * The time in milliseconds after data is considered stale. - * If set to `Infinity`, the data will never be stale. - */ - staleTime?: number - /** - * If set, this will mark any `initialData` provided as stale and will likely cause it to be refetched on mount. - * If a function is passed, it will be called only when appropriate to resolve the `initialStale` value. - * This can be useful if your `initialStale` value is costly to calculate. - */ - initialStale?: boolean | InitialStaleFunction + TQueryFnData = TData, + TQueryData = TQueryFnData +> extends QueryOptions { /** * If set to a number, the query will continuously refetch at this frequency in milliseconds. * Defaults to `false`. @@ -115,11 +99,6 @@ export interface QueryObserverConfig< * Defaults to `true`. */ refetchOnMount?: boolean | 'always' - /** - * Set this to `true` to always fetch when the component mounts (regardless of staleness). - * Defaults to `false`. - */ - forceFetchOnMount?: boolean /** * Whether a change to the query status should re-render a component. * If set to `false`, the component will only re-render when the actual `data` or `error` changes. @@ -129,7 +108,7 @@ export interface QueryObserverConfig< /** * This callback will fire any time the query successfully fetches new data. */ - onSuccess?: (data: TResult) => void + onSuccess?: (data: TData) => void /** * This callback will fire if the query encounters an error and will be passed the error. */ @@ -137,12 +116,16 @@ export interface QueryObserverConfig< /** * This callback will fire any time the query is either successfully fetched or errors and be passed either the data or error. */ - onSettled?: (data: TResult | undefined, error: TError | null) => void + onSettled?: (data: TData | undefined, error: TError | null) => void /** * Whether errors should be thrown instead of setting the `error` property. * Defaults to `false`. */ useErrorBoundary?: boolean + /** + * This option can be used to transform or select a part of the data returned by the query function. + */ + select?: (data: TQueryData) => TData /** * If set to `true`, the query will suspend when `status === 'loading'` * and throw errors when `status === 'error'`. @@ -156,156 +139,86 @@ export interface QueryObserverConfig< keepPreviousData?: boolean } -export interface QueryConfig - extends QueryObserverConfig {} +export interface RefetchOptions { + throwOnError?: boolean +} -export interface PaginatedQueryConfig - extends QueryObserverConfig {} +export interface InvalidateQueryFilters extends QueryFilters { + refetchActive?: boolean + refetchInactive?: boolean +} -export interface InfiniteQueryConfig - extends QueryObserverConfig {} +export interface InvalidateOptions { + throwOnError?: boolean +} -export interface ResolvedQueryConfig - extends QueryConfig { - cacheTime: number - queryCache: QueryCache - queryFn: QueryFunction - queryHash: string - queryKey: ArrayQueryKey - queryKeySerializerFn: QueryKeySerializerFunction - staleTime: number +export interface FetchMoreOptions { + fetchMoreVariable?: unknown + previous?: boolean } export type IsFetchingMoreValue = 'previous' | 'next' | false -export enum QueryStatus { - Idle = 'idle', - Loading = 'loading', - Error = 'error', - Success = 'success', -} +export type QueryStatus = 'idle' | 'loading' | 'error' | 'success' -export interface QueryResultBase { +export interface QueryObserverResult { canFetchMore: boolean | undefined - clear: () => void - data: TResult | undefined + data: TData | undefined error: TError | null failureCount: number fetchMore: ( fetchMoreVariable?: unknown, options?: FetchMoreOptions - ) => Promise + ) => Promise isError: boolean isFetched: boolean isFetchedAfterMount: boolean isFetching: boolean isFetchingMore?: IsFetchingMoreValue isIdle: boolean - isInitialData: boolean isLoading: boolean isPreviousData: boolean isStale: boolean isSuccess: boolean - refetch: (options?: RefetchOptions) => Promise + refetch: (options?: RefetchOptions) => Promise remove: () => void status: QueryStatus updatedAt: number } -export interface QueryResult - extends QueryResultBase {} - -export interface PaginatedQueryResult - extends QueryResultBase { - resolvedData: TResult | undefined - latestData: TResult | undefined -} - -export interface InfiniteQueryResult - extends QueryResultBase {} - -export interface MutateConfig< - TResult, +export interface MutateOptions< + TData, TError = unknown, TVariables = unknown, TSnapshot = unknown > { - onSuccess?: (data: TResult, variables: TVariables) => Promise | void + onSuccess?: (data: TData, variables: TVariables) => Promise | void onError?: ( error: TError, variables: TVariables, - snapshotValue: TSnapshot - ) => Promise | void + snapshotValue?: TSnapshot + ) => Promise | void onSettled?: ( - data: undefined | TResult, + data: TData | undefined, error: TError | null, variables: TVariables, snapshotValue?: TSnapshot - ) => Promise | void + ) => Promise | void throwOnError?: boolean } -export interface MutationConfig< - TResult, +export interface MutationOptions< + TData, TError = unknown, TVariables = unknown, TSnapshot = unknown -> extends MutateConfig { +> extends MutateOptions { onMutate?: (variables: TVariables) => Promise | TSnapshot useErrorBoundary?: boolean suspense?: boolean - /** - * By default the query cache from the context is used, but a different cache can be specified. - */ - queryCache?: QueryCache } -export type MutationFunction = ( - variables: TVariables -) => Promise - -export type MutateFunction< - TResult, - TError = unknown, - TVariables = unknown, - TSnapshot = unknown -> = ( - variables?: TVariables, - config?: MutateConfig -) => Promise - -export type MutationResultPair = [ - MutateFunction, - MutationResult -] - -export interface MutationResult { - status: QueryStatus - data: TResult | undefined - error: TError | null - isIdle: boolean - isLoading: boolean - isSuccess: boolean - isError: boolean - reset: () => void -} - -export interface ReactQueryConfig { - queries?: ReactQueryQueriesConfig - shared?: ReactQuerySharedConfig - mutations?: ReactQueryMutationsConfig +export interface DefaultOptions { + queries?: QueryObserverOptions + mutations?: MutationOptions } - -export interface ReactQuerySharedConfig { - suspense?: boolean -} - -export interface ReactQueryQueriesConfig - extends QueryObserverConfig {} - -export interface ReactQueryMutationsConfig< - TResult, - TError = unknown, - TVariables = unknown, - TSnapshot = unknown -> extends MutationConfig {} diff --git a/src/core/utils.ts b/src/core/utils.ts index dcaedf3a82..e3cb8810cb 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,21 +1,50 @@ -import { QueryConfig, QueryStatus, QueryKey, QueryFunction } from './types' +import type { Query } from './query' +import type { + QueryOptions, + QueryFunction, + QueryKey, + QueryStatus, +} from './types' // TYPES +export interface QueryFilters { + /** + * Include or exclude active queries + */ + active?: boolean + /** + * Match query key exactly + */ + exact?: boolean + /** + * Include or exclude fresh queries + */ + fresh?: boolean + /** + * Include or exclude inactive queries + */ + inactive?: boolean + /** + * Include queries matching this predicate function + */ + predicate?: (query: Query) => boolean + /** + * Include queries matching this query key + */ + queryKey?: QueryKey + /** + * Include or exclude stale queries + */ + stale?: boolean +} + export type DataUpdateFunction = (input: TInput) => TOutput export type Updater = | TOutput | DataUpdateFunction -type ConsoleFunction = (...args: any[]) => void - -export interface ConsoleObject { - log: ConsoleFunction - warn: ConsoleFunction - error: ConsoleFunction -} - interface Cancelable { cancel(): void } @@ -29,27 +58,12 @@ export class CancelledError { // UTILS -let _uid = 0 -export function uid(): number { - return _uid++ -} - export const isServer = typeof window === 'undefined' export function noop(): undefined { return undefined } -export let Console: ConsoleObject = console || { - error: noop, - warn: noop, - log: noop, -} - -export function setConsole(c: ConsoleObject) { - Console = c -} - export function functionalUpdate( updater: Updater, input: TInput @@ -76,10 +90,27 @@ function stableStringifyReplacer(_key: string, value: any): unknown { return value } -export function stableStringify(value: any): string { +function stableStringify(value: any): string { return JSON.stringify(value, stableStringifyReplacer) } +export function defaultQueryKeySerializerFn(queryKey: QueryKey): string { + try { + return stableStringify(queryKey) + } catch { + throw new Error('Failed to serialize query key') + } +} + +export function hashQueryKey( + queryKey: QueryKey, + options?: QueryOptions +): string { + return options?.queryKeySerializerFn + ? options.queryKeySerializerFn(queryKey) + : defaultQueryKeySerializerFn(queryKey) +} + export function deepIncludes(a: any, b: any): boolean { if (a === b) { return true @@ -112,40 +143,101 @@ export function isOnline(): boolean { return navigator.onLine === undefined || navigator.onLine } -export function getQueryArgs( - arg1: any, - arg2?: any, - arg3?: any, - arg4?: any -): [QueryKey, QueryConfig, TOptions] { - let queryKey: QueryKey - let queryFn: QueryFunction | undefined - let config: QueryConfig | undefined - let options: TOptions - - if (isPlainObject(arg1)) { - queryKey = arg1.queryKey - queryFn = arg1.queryFn - config = arg1.config - options = arg2 - } else if (isPlainObject(arg2)) { - queryKey = arg1 - config = arg2 - options = arg3 - } else { - queryKey = arg1 - queryFn = arg2 - config = arg3 - options = arg4 +export function ensureArray(value: T | T[]): T[] { + return Array.isArray(value) ? value : [value] +} + +export function uniq(array: T[]): T[] { + return array.filter((value, i) => array.indexOf(value) === i) +} + +export function difference(array1: T[], array2: T[]): T[] { + return array1.filter(x => array2.indexOf(x) === -1) +} + +export function replaceAt(array: T[], index: number, value: T): T[] { + const copy = array.slice(0) + copy[index] = value + return copy +} + +export function timeUntilStale(updatedAt: number, staleTime: number): number { + return Math.max(updatedAt + staleTime - Date.now(), 0) +} + +export function parseQueryArgs>( + arg1: QueryKey | TOptions, + arg2?: QueryFunction | TOptions, + arg3?: TOptions +): TOptions { + if (!isQueryKey(arg1)) { + return arg1 as TOptions } - config = config || {} + if (typeof arg2 === 'function') { + return { ...arg3, queryKey: arg1, queryFn: arg2 } as TOptions + } + + return { ...arg2, queryKey: arg1 } as TOptions +} - if (queryFn) { - config = { ...config, queryFn } +export function parseFilterArgs< + TFilters extends QueryFilters, + TOptions = unknown +>( + arg1?: QueryKey | TFilters, + arg2?: TFilters | TOptions, + arg3?: TOptions +): [TFilters, TOptions | undefined] { + return (isQueryKey(arg1) + ? [{ ...arg2, queryKey: arg1 }, arg3] + : [arg1 || {}, arg2]) as [TFilters, TOptions] +} + +export function matchQuery( + filters: QueryFilters, + query: Query +): boolean { + const { active, exact, fresh, inactive, predicate, queryKey, stale } = filters + + if ( + queryKey && + (exact + ? query.queryHash !== hashQueryKey(queryKey, query.options) + : !deepIncludes(query.queryKey, queryKey)) + ) { + return false + } + + let isActive + + if (inactive === false || (active && !inactive)) { + isActive = true + } else if (active === false || (inactive && !active)) { + isActive = false + } + + if (typeof isActive === 'boolean' && query.isActive() !== isActive) { + return false + } + + let isStale + + if (fresh === false || (stale && !fresh)) { + isStale = true + } else if (stale === false || (fresh && !stale)) { + isStale = false + } + + if (typeof isStale === 'boolean' && query.isStale() !== isStale) { + return false + } + + if (predicate && !predicate(query)) { + return false } - return [queryKey, config, options] + return true } /** @@ -214,6 +306,10 @@ 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 isCancelable(value: any): value is Cancelable { return typeof value?.cancel === 'function' } @@ -235,10 +331,10 @@ export function sleep(timeout: number): Promise { export function getStatusProps(status: T) { return { status, - isLoading: status === QueryStatus.Loading, - isSuccess: status === QueryStatus.Success, - isError: status === QueryStatus.Error, - isIdle: status === QueryStatus.Idle, + isLoading: status === 'loading', + isSuccess: status === 'success', + isError: status === 'error', + isIdle: status === 'idle', } } @@ -267,20 +363,3 @@ export function scheduleMicrotask(callback: () => void): void { }) ) } - -type BatchUpdateFunction = (callback: () => void) => void - -// Default to a dummy "batch" implementation that just runs the callback -let batchedUpdates: BatchUpdateFunction = (callback: () => void) => { - callback() -} - -// Allow injecting another batching function later -export function setBatchedUpdates(fn: BatchUpdateFunction) { - batchedUpdates = fn -} - -// Supply a getter just to skip dealing with ESM bindings -export function getBatchedUpdates(): BatchUpdateFunction { - return batchedUpdates -} diff --git a/src/hydration/hydration.ts b/src/hydration/hydration.ts index 1679dcd85a..08c12bd9ef 100644 --- a/src/hydration/hydration.ts +++ b/src/hydration/hydration.ts @@ -1,11 +1,12 @@ -import type { Query, QueryCache, QueryKey } from 'react-query' +import { Query, QueryCache, QueryKey } from '../core' export interface DehydratedQueryConfig { - cacheTime?: number + cacheTime: number } export interface DehydratedQuery { queryKey: QueryKey + queryHash: string data?: unknown updatedAt: number config: DehydratedQueryConfig @@ -15,9 +16,7 @@ export interface DehydratedState { queries: Array } -export type ShouldDehydrateFunction = ( - query: Query -) => boolean +export type ShouldDehydrateFunction = (query: Query) => boolean export interface DehydrateConfig { shouldDehydrate?: ShouldDehydrateFunction @@ -27,34 +26,31 @@ export interface DehydrateConfig { // consuming the de/rehydrated data, typically with useQuery on the client. // Sometimes it might make sense to prefetch data on the server and include // in the html-payload, but not consume it on the initial render. -function dehydrateQuery( - query: Query -): DehydratedQuery { +function dehydrateQuery(query: Query): DehydratedQuery { return { config: { cacheTime: query.cacheTime, }, data: query.state.data, queryKey: query.queryKey, + queryHash: query.queryHash, updatedAt: query.state.updatedAt, } } -function defaultShouldDehydrate( - query: Query -) { +function defaultShouldDehydrate(query: Query) { return query.state.status === 'success' } export function dehydrate( - queryCache: QueryCache, + cache: QueryCache, dehydrateConfig?: DehydrateConfig ): DehydratedState { const config = dehydrateConfig || {} const shouldDehydrate = config.shouldDehydrate || defaultShouldDehydrate const queries: DehydratedQuery[] = [] - queryCache.getQueries().forEach(query => { + cache.getAll().forEach(query => { if (shouldDehydrate(query)) { queries.push(dehydrateQuery(query)) } @@ -63,10 +59,7 @@ export function dehydrate( return { queries } } -export function hydrate( - queryCache: QueryCache, - dehydratedState: unknown -): void { +export function hydrate(cache: QueryCache, dehydratedState: unknown): void { if (typeof dehydratedState !== 'object' || dehydratedState === null) { return } @@ -74,12 +67,7 @@ export function hydrate( const queries = (dehydratedState as DehydratedState).queries || [] queries.forEach(dehydratedQuery => { - const resolvedConfig = queryCache.getResolvedQueryConfig( - dehydratedQuery.queryKey, - dehydratedQuery.config - ) - - let query = queryCache.getQueryByHash(resolvedConfig.queryHash) + let query = cache.get(dehydratedQuery.queryHash) // Do not hydrate if an existing query exists with newer data if (query && query.state.updatedAt >= dehydratedQuery.updatedAt) { @@ -87,7 +75,15 @@ export function hydrate( } if (!query) { - query = queryCache.createQuery(resolvedConfig) + query = new Query({ + cache: cache, + queryKey: dehydratedQuery.queryKey, + queryHash: dehydratedQuery.queryHash, + options: { + cacheTime: dehydratedQuery.config.cacheTime, + }, + }) + cache.add(query) } query.setData(dehydratedQuery.data, { diff --git a/src/hydration/react.tsx b/src/hydration/react.tsx index 2a0339b042..dba0b8fe1e 100644 --- a/src/hydration/react.tsx +++ b/src/hydration/react.tsx @@ -1,20 +1,20 @@ import React from 'react' -import { useQueryCache } from 'react-query' +import { useQueryClient } from '../react' import { hydrate } from './hydration' export function useHydrate(queries: unknown) { - const queryCache = useQueryCache() + const client = useQueryClient() + const cache = client.getCache() // Running hydrate again with the same queries is safe, // it wont overwrite or initialize existing queries, // relying on useMemo here is only a performance optimization React.useMemo(() => { if (queries) { - hydrate(queryCache, queries) + hydrate(cache, queries) } - return undefined - }, [queryCache, queries]) + }, [cache, queries]) } export interface HydrateProps { diff --git a/src/hydration/tests/hydration.test.tsx b/src/hydration/tests/hydration.test.tsx index 97489c6889..c260162c40 100644 --- a/src/hydration/tests/hydration.test.tsx +++ b/src/hydration/tests/hydration.test.tsx @@ -1,81 +1,74 @@ import { sleep } from '../../react/tests/utils' -import { QueryCache } from '../..' +import { QueryCache, QueryClient } from '../..' import { dehydrate, hydrate } from '../hydration' -const fetchData: ( - value: TResult, - ms?: number -) => Promise = async (value, ms) => { +async function fetchData(value: TData, ms?: number): Promise { await sleep(ms || 0) return value } describe('dehydration and rehydration', () => { test('should work with serializeable values', async () => { - const queryCache = new QueryCache() - await queryCache.prefetchQuery('string', () => fetchData('string')) - await queryCache.prefetchQuery('number', () => fetchData(1)) - await queryCache.prefetchQuery('boolean', () => fetchData(true)) - await queryCache.prefetchQuery('null', () => fetchData(null)) - await queryCache.prefetchQuery('array', () => fetchData(['string', 0])) - await queryCache.prefetchQuery('nested', () => + const cache = new QueryCache() + const client = new QueryClient({ cache }) + await client.prefetchQuery('string', () => fetchData('string')) + await client.prefetchQuery('number', () => fetchData(1)) + await client.prefetchQuery('boolean', () => fetchData(true)) + await client.prefetchQuery('null', () => fetchData(null)) + await client.prefetchQuery('array', () => fetchData(['string', 0])) + await client.prefetchQuery('nested', () => fetchData({ key: [{ nestedKey: 1 }] }) ) - const dehydrated = dehydrate(queryCache) + const dehydrated = dehydrate(cache) const stringified = JSON.stringify(dehydrated) // --- const parsed = JSON.parse(stringified) - const hydrationQueryCache = new QueryCache() - hydrate(hydrationQueryCache, parsed) - expect(hydrationQueryCache.getQuery('string')?.state.data).toBe('string') - expect(hydrationQueryCache.getQuery('number')?.state.data).toBe(1) - expect(hydrationQueryCache.getQuery('boolean')?.state.data).toBe(true) - expect(hydrationQueryCache.getQuery('null')?.state.data).toBe(null) - expect(hydrationQueryCache.getQuery('array')?.state.data).toEqual([ - 'string', - 0, - ]) - expect(hydrationQueryCache.getQuery('nested')?.state.data).toEqual({ + const hydrationCache = new QueryCache() + hydrate(hydrationCache, 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({ key: [{ nestedKey: 1 }], }) const fetchDataAfterHydration = jest.fn() - await hydrationQueryCache.prefetchQuery('string', fetchDataAfterHydration, { + const hydrationClient = new QueryClient({ cache: hydrationCache }) + await hydrationClient.prefetchQuery('string', fetchDataAfterHydration, { staleTime: 1000, }) - await hydrationQueryCache.prefetchQuery('number', fetchDataAfterHydration, { + await hydrationClient.prefetchQuery('number', fetchDataAfterHydration, { staleTime: 1000, }) - await hydrationQueryCache.prefetchQuery( - 'boolean', - fetchDataAfterHydration, - { - staleTime: 1000, - } - ) - await hydrationQueryCache.prefetchQuery('null', fetchDataAfterHydration, { + await hydrationClient.prefetchQuery('boolean', fetchDataAfterHydration, { + staleTime: 1000, + }) + await hydrationClient.prefetchQuery('null', fetchDataAfterHydration, { staleTime: 1000, }) - await hydrationQueryCache.prefetchQuery('array', fetchDataAfterHydration, { + await hydrationClient.prefetchQuery('array', fetchDataAfterHydration, { staleTime: 1000, }) - await hydrationQueryCache.prefetchQuery('nested', fetchDataAfterHydration, { + await hydrationClient.prefetchQuery('nested', fetchDataAfterHydration, { staleTime: 1000, }) expect(fetchDataAfterHydration).toHaveBeenCalledTimes(0) - queryCache.clear({ notify: false }) - hydrationQueryCache.clear({ notify: false }) + cache.clear() + hydrationCache.clear() }) test('should schedule garbage collection, measured from hydration', async () => { - const queryCache = new QueryCache() - await queryCache.prefetchQuery('string', () => fetchData('string'), { + const cache = new QueryCache() + const client = new QueryClient({ cache }) + await client.prefetchQuery('string', () => fetchData('string'), { cacheTime: 50, }) - const dehydrated = dehydrate(queryCache) + const dehydrated = dehydrate(cache) const stringified = JSON.stringify(dehydrated) await sleep(20) @@ -83,89 +76,91 @@ describe('dehydration and rehydration', () => { // --- const parsed = JSON.parse(stringified) - const hydrationQueryCache = new QueryCache() - hydrate(hydrationQueryCache, parsed) - expect(hydrationQueryCache.getQuery('string')?.state.data).toBe('string') + const hydrationCache = new QueryCache() + hydrate(hydrationCache, parsed) + expect(hydrationCache.find('string')?.state.data).toBe('string') await sleep(40) - expect(hydrationQueryCache.getQuery('string')).toBeTruthy() + expect(hydrationCache.find('string')).toBeTruthy() await sleep(20) - expect(hydrationQueryCache.getQuery('string')).toBeFalsy() + expect(hydrationCache.find('string')).toBeFalsy() - queryCache.clear({ notify: false }) - hydrationQueryCache.clear({ notify: false }) + cache.clear() + hydrationCache.clear() }) test('should work with complex keys', async () => { - const queryCache = new QueryCache() - await queryCache.prefetchQuery( - ['string', { key: ['string'], key2: 0 }], - () => fetchData('string') + const cache = new QueryCache() + const client = new QueryClient({ cache }) + await client.prefetchQuery(['string', { key: ['string'], key2: 0 }], () => + fetchData('string') ) - const dehydrated = dehydrate(queryCache) + const dehydrated = dehydrate(cache) const stringified = JSON.stringify(dehydrated) // --- const parsed = JSON.parse(stringified) - const hydrationQueryCache = new QueryCache() - hydrate(hydrationQueryCache, parsed) + const hydrationCache = new QueryCache() + hydrate(hydrationCache, parsed) expect( - hydrationQueryCache.getQuery(['string', { key: ['string'], key2: 0 }]) - ?.state.data + hydrationCache.find(['string', { key: ['string'], key2: 0 }])?.state.data ).toBe('string') const fetchDataAfterHydration = jest.fn() - await hydrationQueryCache.prefetchQuery( + const hydrationClient = new QueryClient({ cache: hydrationCache }) + await hydrationClient.prefetchQuery( ['string', { key: ['string'], key2: 0 }], fetchDataAfterHydration, { staleTime: 10 } ) expect(fetchDataAfterHydration).toHaveBeenCalledTimes(0) - queryCache.clear({ notify: false }) - hydrationQueryCache.clear({ notify: false }) + cache.clear() + hydrationCache.clear() }) test('should only hydrate successful queries by default', async () => { const consoleMock = jest.spyOn(console, 'error') consoleMock.mockImplementation(() => undefined) - const queryCache = new QueryCache() - await queryCache.prefetchQuery('success', () => fetchData('success')) - queryCache.prefetchQuery('loading', () => fetchData('loading', 10000)) - await queryCache.prefetchQuery('error', () => { + const cache = new QueryCache() + const client = new QueryClient({ cache }) + await client.prefetchQuery('success', () => fetchData('success')) + client.prefetchQuery('loading', () => fetchData('loading', 10000)) + await client.prefetchQuery('error', () => { throw new Error() }) - const dehydrated = dehydrate(queryCache) + const dehydrated = dehydrate(cache) const stringified = JSON.stringify(dehydrated) // --- const parsed = JSON.parse(stringified) - const hydrationQueryCache = new QueryCache() - hydrate(hydrationQueryCache, parsed) + const hydrationCache = new QueryCache() + hydrate(hydrationCache, parsed) - expect(hydrationQueryCache.getQuery('success')).toBeTruthy() - expect(hydrationQueryCache.getQuery('loading')).toBeFalsy() - expect(hydrationQueryCache.getQuery('error')).toBeFalsy() + expect(hydrationCache.find('success')).toBeTruthy() + expect(hydrationCache.find('loading')).toBeFalsy() + expect(hydrationCache.find('error')).toBeFalsy() - queryCache.clear({ notify: false }) - hydrationQueryCache.clear({ notify: false }) + cache.clear() + hydrationCache.clear() consoleMock.mockRestore() }) test('should filter queries via shouldDehydrate', async () => { - const queryCache = new QueryCache() - await queryCache.prefetchQuery('string', () => fetchData('string')) - await queryCache.prefetchQuery('number', () => fetchData(1)) - const dehydrated = dehydrate(queryCache, { - shouldDehydrate: query => query.queryKey[0] !== 'string', + const cache = new QueryCache() + const client = new QueryClient({ cache }) + await client.prefetchQuery('string', () => fetchData('string')) + await client.prefetchQuery('number', () => fetchData(1)) + const dehydrated = dehydrate(cache, { + shouldDehydrate: query => query.queryKey !== '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 as Array)[0] === 'string' + query => query?.queryKey === 'string' ) expect(dehydratedQuery).toBeUndefined() @@ -174,60 +169,60 @@ describe('dehydration and rehydration', () => { // --- const parsed = JSON.parse(stringified) - const hydrationQueryCache = new QueryCache() - hydrate(hydrationQueryCache, parsed) - expect(hydrationQueryCache.getQuery('string')).toBeUndefined() - expect(hydrationQueryCache.getQuery('number')?.state.data).toBe(1) + const hydrationCache = new QueryCache() + hydrate(hydrationCache, parsed) + expect(hydrationCache.find('string')).toBeUndefined() + expect(hydrationCache.find('number')?.state.data).toBe(1) - queryCache.clear({ notify: false }) - hydrationQueryCache.clear({ notify: false }) + cache.clear() + hydrationCache.clear() }) test('should not overwrite query in cache if hydrated query is older', async () => { - const queryCache = new QueryCache() - await queryCache.prefetchQuery('string', () => fetchData('string-older', 5)) - const dehydrated = dehydrate(queryCache) + const cache = new QueryCache() + const client = new QueryClient({ cache }) + await client.prefetchQuery('string', () => fetchData('string-older', 5)) + const dehydrated = dehydrate(cache) const stringified = JSON.stringify(dehydrated) // --- const parsed = JSON.parse(stringified) - const hydrationQueryCache = new QueryCache() - await hydrationQueryCache.prefetchQuery('string', () => + const hydrationCache = new QueryCache() + const hydrationClient = new QueryClient({ cache: hydrationCache }) + await hydrationClient.prefetchQuery('string', () => fetchData('string-newer', 5) ) - hydrate(hydrationQueryCache, parsed) - expect(hydrationQueryCache.getQuery('string')?.state.data).toBe( - 'string-newer' - ) + hydrate(hydrationCache, parsed) + expect(hydrationCache.find('string')?.state.data).toBe('string-newer') - queryCache.clear({ notify: false }) - hydrationQueryCache.clear({ notify: false }) + cache.clear() + hydrationCache.clear() }) test('should overwrite query in cache if hydrated query is newer', async () => { - const hydrationQueryCache = new QueryCache() - await hydrationQueryCache.prefetchQuery('string', () => + const hydrationCache = new QueryCache() + const hydrationClient = new QueryClient({ cache: hydrationCache }) + await hydrationClient.prefetchQuery('string', () => fetchData('string-older', 5) ) // --- - const queryCache = new QueryCache() - await queryCache.prefetchQuery('string', () => fetchData('string-newer', 5)) - const dehydrated = dehydrate(queryCache) + const cache = new QueryCache() + const client = new QueryClient({ cache }) + await client.prefetchQuery('string', () => fetchData('string-newer', 5)) + const dehydrated = dehydrate(cache) const stringified = JSON.stringify(dehydrated) // --- const parsed = JSON.parse(stringified) - hydrate(hydrationQueryCache, parsed) - expect(hydrationQueryCache.getQuery('string')?.state.data).toBe( - 'string-newer' - ) + hydrate(hydrationCache, parsed) + expect(hydrationCache.find('string')?.state.data).toBe('string-newer') - queryCache.clear({ notify: false }) - hydrationQueryCache.clear({ notify: false }) + cache.clear() + hydrationCache.clear() }) }) diff --git a/src/hydration/tests/react.test.tsx b/src/hydration/tests/react.test.tsx index a52e8a8378..22b0a5beb2 100644 --- a/src/hydration/tests/react.test.tsx +++ b/src/hydration/tests/react.test.tsx @@ -1,7 +1,7 @@ import React from 'react' import { render } from '@testing-library/react' -import { ReactQueryCacheProvider, QueryCache, useQuery } from '../..' +import { QueryClient, QueryClientProvider, QueryCache, useQuery } from '../..' import { dehydrate, useHydrate, Hydrate } from '../' import { waitForMs } from '../../react/tests/utils' @@ -12,36 +12,19 @@ describe('React hydration', () => { let stringifiedState: string beforeAll(async () => { - const serverQueryCache = new QueryCache() - await serverQueryCache.prefetchQuery('string', dataQuery) - const dehydrated = dehydrate(serverQueryCache) + const cache = new QueryCache() + const client = new QueryClient({ cache }) + await client.prefetchQuery('string', dataQuery) + const dehydrated = dehydrate(cache) stringifiedState = JSON.stringify(dehydrated) - serverQueryCache.clear({ notify: false }) + cache.clear() }) describe('useHydrate', () => { - test('should handle global cache case', async () => { - const dehydratedState = JSON.parse(stringifiedState) - function Page() { - useHydrate(dehydratedState) - const { data } = useQuery('string', dataQuery) - - return ( -
-

{data}

-
- ) - } - - const rendered = render() - - await waitForMs(10) - rendered.getByText('string') - }) - test('should hydrate queries to the cache on context', async () => { const dehydratedState = JSON.parse(stringifiedState) - const clientQueryCache = new QueryCache() + const cache = new QueryCache() + const client = new QueryClient({ cache }) function Page() { useHydrate(dehydratedState) @@ -54,21 +37,22 @@ describe('React hydration', () => { } const rendered = render( - + - + ) await waitForMs(10) rendered.getByText('string') - clientQueryCache.clear({ notify: false }) + cache.clear() }) }) describe('ReactQueryCacheProvider with hydration support', () => { test('should hydrate new queries if queries change', async () => { const dehydratedState = JSON.parse(stringifiedState) - const clientQueryCache = new QueryCache() + const cache = new QueryCache() + const client = new QueryClient({ cache }) function Page({ queryKey }: { queryKey: string }) { const { data } = useQuery(queryKey, dataQuery) @@ -80,31 +64,32 @@ describe('React hydration', () => { } const rendered = render( - + - + ) await waitForMs(10) rendered.getByText('string') const intermediateCache = new QueryCache() - await intermediateCache.prefetchQuery('string', () => + const intermediateClient = new QueryClient({ cache: intermediateCache }) + await intermediateClient.prefetchQuery('string', () => dataQuery('should change') ) - await intermediateCache.prefetchQuery('added string', dataQuery) + await intermediateClient.prefetchQuery('added string', dataQuery) const dehydrated = dehydrate(intermediateCache) - intermediateCache.clear({ notify: false }) + intermediateCache.clear() rendered.rerender( - + - + ) // Existing query data should be overwritten if older, @@ -114,12 +99,13 @@ describe('React hydration', () => { // New query data should be available immediately rendered.getByText('added string') - clientQueryCache.clear({ notify: false }) + cache.clear() }) test('should hydrate queries to new cache if cache changes', async () => { const dehydratedState = JSON.parse(stringifiedState) - const clientQueryCache = new QueryCache() + const cache = new QueryCache() + const client = new QueryClient({ cache }) function Page() { const { data } = useQuery('string', dataQuery) @@ -131,31 +117,34 @@ describe('React hydration', () => { } const rendered = render( - + - + ) await waitForMs(10) rendered.getByText('string') const newClientQueryCache = new QueryCache() + const newClientQueryClient = new QueryClient({ + cache: newClientQueryCache, + }) rendered.rerender( - + - + ) await waitForMs(10) rendered.getByText('string') - clientQueryCache.clear({ notify: false }) - newClientQueryCache.clear({ notify: false }) + cache.clear() + newClientQueryCache.clear() }) }) }) diff --git a/src/hydration/tests/ssr.test.tsx b/src/hydration/tests/ssr.test.tsx index 135de3f535..444b03c6ed 100644 --- a/src/hydration/tests/ssr.test.tsx +++ b/src/hydration/tests/ssr.test.tsx @@ -1,4 +1,4 @@ -import * as React from 'react' +import React from 'react' import ReactDOM from 'react-dom' import ReactDOMServer from 'react-dom/server' import { waitFor } from '@testing-library/react' @@ -6,10 +6,11 @@ import { waitFor } from '@testing-library/react' import { useQuery, setConsole, - ReactQueryCacheProvider, + QueryClient, + QueryClientProvider, QueryCache, } from '../..' -import { dehydrate, Hydrate } from '../' +import { dehydrate, hydrate } from '../' import * as utils from '../../core/utils' import { sleep } from '../../react/tests/utils' @@ -22,10 +23,7 @@ function setIsServer(isServer: boolean) { utils.isServer = isServer } -const fetchData: ( - value: TResult, - ms?: number -) => Promise = async (value, ms) => { +async function fetchData(value: TData, ms?: number): Promise { await sleep(ms || 1) return value } @@ -71,19 +69,21 @@ describe('Server side rendering with de/rehydration', () => { // -- Server part -- setIsServer(true) - const serverPrefetchCache = new QueryCache() - const prefetchPromise = serverPrefetchCache.prefetchQuery('success', () => + const prefetchCache = new QueryCache() + const prefetchClient = new QueryClient({ cache: prefetchCache }) + const prefetchPromise = prefetchClient.prefetchQuery('success', () => fetchDataSuccess('success') ) jest.runOnlyPendingTimers() await prefetchPromise - const dehydratedStateServer = dehydrate(serverPrefetchCache) + const dehydratedStateServer = dehydrate(prefetchCache) + const renderCache = new QueryCache() + hydrate(renderCache, dehydratedStateServer) + const renderClient = new QueryClient({ cache: renderCache }) const markup = ReactDOMServer.renderToString( - - - - - + + + ) const stringifiedState = JSON.stringify(dehydratedStateServer) setIsServer(false) @@ -93,13 +93,15 @@ describe('Server side rendering with de/rehydration', () => { // -- Client part -- const el = document.createElement('div') el.innerHTML = markup - const dehydratedStateClient = JSON.parse(stringifiedState) + + const cache = new QueryCache() + hydrate(cache, JSON.parse(stringifiedState)) + const client = new QueryClient({ cache }) + ReactDOM.hydrate( - - - - - , + + + , el ) @@ -141,19 +143,21 @@ describe('Server side rendering with de/rehydration', () => { // -- Server part -- setIsServer(true) - const serverQueryCache = new QueryCache() - const prefetchPromise = serverQueryCache.prefetchQuery('error', () => + const prefetchCache = new QueryCache() + const prefetchClient = new QueryClient({ cache: prefetchCache }) + const prefetchPromise = prefetchClient.prefetchQuery('error', () => fetchDataError() ) jest.runOnlyPendingTimers() await prefetchPromise - const dehydratedStateServer = dehydrate(serverQueryCache) + const dehydratedStateServer = dehydrate(prefetchCache) + const renderCache = new QueryCache() + hydrate(renderCache, dehydratedStateServer) + const renderClient = new QueryClient({ cache: renderCache }) const markup = ReactDOMServer.renderToString( - - - - - + + + ) const stringifiedState = JSON.stringify(dehydratedStateServer) setIsServer(false) @@ -163,13 +167,15 @@ describe('Server side rendering with de/rehydration', () => { // -- Client part -- const el = document.createElement('div') el.innerHTML = markup - const dehydratedStateClient = JSON.parse(stringifiedState) + + const cache = new QueryCache() + hydrate(cache, JSON.parse(stringifiedState)) + const client = new QueryClient({ cache }) + ReactDOM.hydrate( - - - - - , + + + , el ) @@ -215,14 +221,15 @@ describe('Server side rendering with de/rehydration', () => { // -- Server part -- setIsServer(true) - const serverPrefetchCache = new QueryCache() - const dehydratedStateServer = dehydrate(serverPrefetchCache) + const prefetchCache = new QueryCache() + const dehydratedStateServer = dehydrate(prefetchCache) + const renderCache = new QueryCache() + hydrate(renderCache, dehydratedStateServer) + const renderClient = new QueryClient({ cache: renderCache }) const markup = ReactDOMServer.renderToString( - - - - - + + + ) const stringifiedState = JSON.stringify(dehydratedStateServer) setIsServer(false) @@ -232,13 +239,15 @@ describe('Server side rendering with de/rehydration', () => { // -- Client part -- const el = document.createElement('div') el.innerHTML = markup - const dehydratedStateClient = JSON.parse(stringifiedState) + + const cache = new QueryCache() + hydrate(cache, JSON.parse(stringifiedState)) + const client = new QueryClient({ cache }) + ReactDOM.hydrate( - - - - - , + + + , el ) diff --git a/src/index.ts b/src/index.ts index adfaa66a12..5a7d84f690 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,2 @@ -import { setBatchedUpdates } from './core/index' -import { unstable_batchedUpdates } from './react/reactBatchedUpdates' -setBatchedUpdates(unstable_batchedUpdates) - -export * from './core/index' -export * from './react/index' +export * from './core' +export * from './react' diff --git a/src/react/Console.native.ts b/src/react/Console.native.ts new file mode 100644 index 0000000000..3be114b264 --- /dev/null +++ b/src/react/Console.native.ts @@ -0,0 +1,5 @@ +export const Console = { + log: console.log, + warn: console.warn, + error: console.warn, +} diff --git a/src/react/Console.ts b/src/react/Console.ts new file mode 100644 index 0000000000..a05dc0cbe7 --- /dev/null +++ b/src/react/Console.ts @@ -0,0 +1 @@ +export const Console = console diff --git a/src/react/QueryClientProvider.tsx b/src/react/QueryClientProvider.tsx new file mode 100644 index 0000000000..d16c04c8d2 --- /dev/null +++ b/src/react/QueryClientProvider.tsx @@ -0,0 +1,31 @@ +import React from 'react' + +import { QueryClient } from '../core' + +const queryClientContext = React.createContext( + undefined +) + +export const useQueryClient = () => React.useContext(queryClientContext)! + +export interface QueryClientProviderProps { + client: QueryClient +} + +export const QueryClientProvider: React.FC = ({ + client, + children, +}) => { + React.useEffect(() => { + client.mount() + return () => { + client.unmount() + } + }, [client]) + + return ( + + {children} + + ) +} diff --git a/src/react/ReactQueryErrorResetBoundary.tsx b/src/react/QueryErrorResetBoundary.tsx similarity index 63% rename from src/react/ReactQueryErrorResetBoundary.tsx rename to src/react/QueryErrorResetBoundary.tsx index af5edb39f5..e0c4d36d29 100644 --- a/src/react/ReactQueryErrorResetBoundary.tsx +++ b/src/react/QueryErrorResetBoundary.tsx @@ -2,13 +2,13 @@ import React from 'react' // CONTEXT -interface ReactQueryErrorResetBoundaryValue { +interface QueryErrorResetBoundaryValue { clearReset: () => void isReset: () => boolean reset: () => void } -function createValue(): ReactQueryErrorResetBoundaryValue { +function createValue(): QueryErrorResetBoundaryValue { let isReset = false return { clearReset: () => { @@ -27,17 +27,17 @@ const context = React.createContext(createValue()) // HOOK -export const useErrorResetBoundary = () => React.useContext(context) +export const useQueryErrorResetBoundary = () => React.useContext(context) // COMPONENT -export interface ReactQueryErrorResetBoundaryProps { +export interface QueryErrorResetBoundaryProps { children: - | ((value: ReactQueryErrorResetBoundaryValue) => React.ReactNode) + | ((value: QueryErrorResetBoundaryValue) => React.ReactNode) | React.ReactNode } -export const ReactQueryErrorResetBoundary: React.FC = ({ +export const QueryErrorResetBoundary: React.FC = ({ children, }) => { const value = React.useMemo(() => createValue(), []) diff --git a/src/react/ReactQueryCacheProvider.tsx b/src/react/ReactQueryCacheProvider.tsx deleted file mode 100644 index 90b6ea4988..0000000000 --- a/src/react/ReactQueryCacheProvider.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react' - -import { - QueryCache, - queryCache as defaultQueryCache, - queryCaches, -} from '../core' - -const queryCacheContext = React.createContext(defaultQueryCache) - -export const useQueryCache = () => React.useContext(queryCacheContext) - -export interface ReactQueryCacheProviderProps { - queryCache?: QueryCache -} - -export const ReactQueryCacheProvider: React.FC = ({ - queryCache, - children, -}) => { - const resolvedQueryCache = React.useMemo( - () => queryCache || new QueryCache(), - [queryCache] - ) - - React.useEffect(() => { - queryCaches.push(resolvedQueryCache) - - return () => { - // remove the cache from the active list - const i = queryCaches.indexOf(resolvedQueryCache) - if (i > -1) { - queryCaches.splice(i, 1) - } - // if the resolvedQueryCache was created by us, we need to tear it down - if (queryCache == null) { - resolvedQueryCache.clear({ notify: false }) - } - } - }, [resolvedQueryCache, queryCache]) - - return ( - - {children} - - ) -} diff --git a/src/react/ReactQueryConfigProvider.tsx b/src/react/ReactQueryConfigProvider.tsx deleted file mode 100644 index 6e6d76c909..0000000000 --- a/src/react/ReactQueryConfigProvider.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react' - -import { mergeReactQueryConfigs } from '../core/config' -import { ReactQueryConfig } from '../core/types' - -const configContext = React.createContext( - undefined -) - -export function useContextConfig() { - return React.useContext(configContext) -} - -export interface ReactQueryConfigProviderProps { - config: ReactQueryConfig -} - -export const ReactQueryConfigProvider: React.FC = ({ - config, - children, -}) => { - const parentConfig = useContextConfig() - - const mergedConfig = React.useMemo( - () => - parentConfig ? mergeReactQueryConfigs(parentConfig, config) : config, - [config, parentConfig] - ) - - return ( - - {children} - - ) -} diff --git a/src/react/index.ts b/src/react/index.ts index 93a54f7e44..c380177db2 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,22 +1,25 @@ +import { setBatchedUpdates, setConsole } from '../core' +import { Console } from './Console' +import { unstable_batchedUpdates } from './reactBatchedUpdates' + +setBatchedUpdates(unstable_batchedUpdates) + +if (Console) { + setConsole(Console) +} + +export { QueryClientProvider, useQueryClient } from './QueryClientProvider' export { - ReactQueryCacheProvider, - useQueryCache, -} from './ReactQueryCacheProvider' -export { ReactQueryConfigProvider } from './ReactQueryConfigProvider' -export { - ReactQueryErrorResetBoundary, - useErrorResetBoundary, -} from './ReactQueryErrorResetBoundary' + QueryErrorResetBoundary, + useQueryErrorResetBoundary, +} from './QueryErrorResetBoundary' export { useIsFetching } from './useIsFetching' export { useMutation } from './useMutation' export { useQuery } from './useQuery' -export { usePaginatedQuery } from './usePaginatedQuery' +export { useQueries } from './useQueries' export { useInfiniteQuery } from './useInfiniteQuery' // Types -export type { UseQueryObjectConfig } from './useQuery' -export type { UseInfiniteQueryObjectConfig } from './useInfiniteQuery' -export type { UsePaginatedQueryObjectConfig } from './usePaginatedQuery' -export type { ReactQueryCacheProviderProps } from './ReactQueryCacheProvider' -export type { ReactQueryConfigProviderProps } from './ReactQueryConfigProvider' -export type { ReactQueryErrorResetBoundaryProps } from './ReactQueryErrorResetBoundary' +export * from './types' +export type { QueryClientProviderProps } from './QueryClientProvider' +export type { QueryErrorResetBoundaryProps } from './QueryErrorResetBoundary' diff --git a/src/react/tests/QueryClientProvider.test.tsx b/src/react/tests/QueryClientProvider.test.tsx new file mode 100644 index 0000000000..1f36b0b777 --- /dev/null +++ b/src/react/tests/QueryClientProvider.test.tsx @@ -0,0 +1,130 @@ +import React from 'react' +import { render, waitFor } from '@testing-library/react' + +import { sleep, queryKey } from './utils' +import { QueryClient, QueryClientProvider, QueryCache, useQuery } from '../..' + +describe('QueryClientProvider', () => { + test('sets a specific cache for all queries to use', async () => { + const key = queryKey() + + const cache = new QueryCache() + const client = new QueryClient({ cache }) + + function Page() { + const { data } = useQuery(key, async () => { + await sleep(10) + return 'test' + }) + + return ( +
+

{data}

+
+ ) + } + + const rendered = render( + + + + ) + + await waitFor(() => rendered.getByText('test')) + + expect(client.getCache().find(key)).toBeDefined() + }) + + test('allows multiple caches to be partitioned', async () => { + const key1 = queryKey() + const key2 = queryKey() + + const cache1 = new QueryCache() + const cache2 = new QueryCache() + + const client1 = new QueryClient({ cache: cache1 }) + const client2 = new QueryClient({ cache: cache2 }) + + function Page1() { + const { data } = useQuery(key1, async () => { + await sleep(10) + return 'test1' + }) + + return ( +
+

{data}

+
+ ) + } + function Page2() { + const { data } = useQuery(key2, async () => { + await sleep(10) + return 'test2' + }) + + return ( +
+

{data}

+
+ ) + } + + const rendered = render( + <> + + + + + + + + ) + + await waitFor(() => rendered.getByText('test1')) + await waitFor(() => rendered.getByText('test2')) + + expect(cache1.find(key1)).toBeDefined() + expect(cache1.find(key2)).not.toBeDefined() + expect(cache2.find(key1)).not.toBeDefined() + expect(cache2.find(key2)).toBeDefined() + }) + + test("uses defaultOptions for queries when they don't provide their own config", async () => { + const key = queryKey() + + const cache = new QueryCache() + const client = new QueryClient({ + cache, + defaultOptions: { + queries: { + cacheTime: Infinity, + }, + }, + }) + + function Page() { + const { data } = useQuery(key, async () => { + await sleep(10) + return 'test' + }) + + return ( +
+

{data}

+
+ ) + } + + const rendered = render( + + + + ) + + await waitFor(() => rendered.getByText('test')) + + expect(cache.find(key)).toBeDefined() + expect(cache.find(key)?.options.cacheTime).toBe(Infinity) + }) +}) diff --git a/src/react/tests/ReactQueryResetErrorBoundary.test.tsx b/src/react/tests/QueryResetErrorBoundary.test.tsx similarity index 82% rename from src/react/tests/ReactQueryResetErrorBoundary.test.tsx rename to src/react/tests/QueryResetErrorBoundary.test.tsx index fb8f65ab89..2b6efa1112 100644 --- a/src/react/tests/ReactQueryResetErrorBoundary.test.tsx +++ b/src/react/tests/QueryResetErrorBoundary.test.tsx @@ -1,11 +1,19 @@ -import { render, waitFor, fireEvent } from '@testing-library/react' +import { waitFor, fireEvent } from '@testing-library/react' import { ErrorBoundary } from 'react-error-boundary' -import * as React from 'react' +import React from 'react' -import { sleep, queryKey, mockConsoleError } from './utils' -import { useQuery, ReactQueryErrorResetBoundary } from '../..' +import { sleep, queryKey, mockConsoleError, renderWithClient } from './utils' +import { + useQuery, + QueryClient, + QueryCache, + QueryErrorResetBoundary, +} from '../..' + +describe('QueryErrorResetBoundary', () => { + const cache = new QueryCache() + const client = new QueryClient({ cache }) -describe('ReactQueryResetErrorBoundary', () => { it('should retry fetch if the reset error boundary has been reset', async () => { const key = queryKey() @@ -31,8 +39,9 @@ describe('ReactQueryResetErrorBoundary', () => { return
{data}
} - const rendered = render( - + const rendered = renderWithClient( + client, + {({ reset }) => ( { )} - + ) await waitFor(() => rendered.getByText('error boundary')) @@ -85,8 +94,9 @@ describe('ReactQueryResetErrorBoundary', () => { return
{data}
} - const rendered = render( - + const rendered = renderWithClient( + client, + {({ reset }) => ( { )} - + ) await waitFor(() => rendered.getByText('error boundary')) diff --git a/src/react/tests/ReactQueryCacheProvider.test.tsx b/src/react/tests/ReactQueryCacheProvider.test.tsx deleted file mode 100644 index ae3296cf9d..0000000000 --- a/src/react/tests/ReactQueryCacheProvider.test.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import React, { useEffect } from 'react' -import { render, waitFor } from '@testing-library/react' - -import { sleep, queryKey } from './utils' -import { - ReactQueryCacheProvider, - useQuery, - useQueryCache, - queryCache, - QueryCache, -} from '../..' - -describe('ReactQueryCacheProvider', () => { - test('when not used, falls back to global cache', async () => { - const key = queryKey() - - function Page() { - const { data } = useQuery(key, async () => { - await sleep(10) - return 'test' - }) - - return ( -
-

{data}

-
- ) - } - - const rendered = render() - - await waitFor(() => rendered.getByText('test')) - - expect(queryCache.getQuery(key)).toBeDefined() - }) - - test('sets a specific cache for all queries to use', async () => { - const key = queryKey() - - const cache = new QueryCache() - - function Page() { - const { data } = useQuery(key, async () => { - await sleep(10) - return 'test' - }) - - return ( -
-

{data}

-
- ) - } - - const rendered = render( - - - - ) - - await waitFor(() => rendered.getByText('test')) - - expect(queryCache.getQuery(key)).not.toBeDefined() - expect(cache.getQuery(key)).toBeDefined() - }) - - test('implicitly creates a new cache for all queries to use', async () => { - const key = queryKey() - - function Page() { - const { data } = useQuery(key, async () => { - await sleep(10) - return 'test' - }) - - return ( -
-

{data}

-
- ) - } - - const rendered = render( - - - - ) - - await waitFor(() => rendered.getByText('test')) - - expect(queryCache.getQuery(key)).not.toBeDefined() - }) - - test('allows multiple caches to be partitioned', async () => { - const key1 = queryKey() - const key2 = queryKey() - - const cache1 = new QueryCache() - const cache2 = new QueryCache() - - function Page1() { - const { data } = useQuery(key1, async () => { - await sleep(10) - return 'test1' - }) - - return ( -
-

{data}

-
- ) - } - function Page2() { - const { data } = useQuery(key2, async () => { - await sleep(10) - return 'test2' - }) - - return ( -
-

{data}

-
- ) - } - - const rendered = render( - <> - - - - - - - - ) - - await waitFor(() => rendered.getByText('test1')) - await waitFor(() => rendered.getByText('test2')) - - expect(cache1.getQuery(key1)).toBeDefined() - expect(cache1.getQuery(key2)).not.toBeDefined() - expect(cache2.getQuery(key1)).not.toBeDefined() - expect(cache2.getQuery(key2)).toBeDefined() - - cache1.clear({ notify: false }) - cache2.clear({ notify: false }) - }) - - test('when cache changes, previous cache is cleaned', async () => { - const key = queryKey() - - const caches: QueryCache[] = [] - const customCache = new QueryCache() - - function Page() { - const contextCache = useQueryCache() - - useEffect(() => { - caches.push(contextCache) - }, [contextCache]) - - const { data } = useQuery(key, async () => { - await sleep(10) - return 'test' - }) - - return ( -
-

{data}

-
- ) - } - - function App({ cache }: { cache?: QueryCache }) { - return ( - - - - ) - } - - const rendered = render() - - await waitFor(() => rendered.getByText('test')) - - expect(caches).toHaveLength(1) - jest.spyOn(caches[0], 'clear') - - rendered.rerender() - - expect(caches).toHaveLength(2) - expect(caches[0].clear).toHaveBeenCalled() - - await waitFor(() => rendered.getByText('test')) - - customCache.clear({ notify: false }) - }) - - test("uses defaultConfig for queries when they don't provide their own config", async () => { - const key = queryKey() - - const cache = new QueryCache({ - defaultConfig: { - queries: { - staleTime: Infinity, - }, - }, - }) - - function Page() { - const { data } = useQuery(key, async () => { - await sleep(10) - return 'test' - }) - - return ( -
-

{data}

-
- ) - } - - const rendered = render( - - - - ) - - await waitFor(() => rendered.getByText('test')) - - expect(cache.getQuery(key)).toBeDefined() - expect(cache.getQuery(key)?.config.staleTime).toBe(Infinity) - cache.clear({ notify: false }) - }) -}) diff --git a/src/react/tests/ReactQueryConfigProvider.test.tsx b/src/react/tests/ReactQueryConfigProvider.test.tsx deleted file mode 100644 index 72c1a398bf..0000000000 --- a/src/react/tests/ReactQueryConfigProvider.test.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import React, { useState } from 'react' -import { act, fireEvent, render, waitFor } from '@testing-library/react' - -import { sleep, queryKey } from './utils' -import { ReactQueryConfigProvider, useQuery, queryCache } from '../..' - -describe('ReactQueryConfigProvider', () => { - // // See https://github.com/tannerlinsley/react-query/issues/105 - it('should allow overriding the config', async () => { - const key = queryKey() - - const onSuccess = jest.fn() - - const config = { - queries: { - onSuccess, - }, - } - - function Page() { - const { status } = useQuery(key, async () => { - await sleep(10) - return 'data' - }) - - return ( -
-

Status: {status}

-
- ) - } - - const rendered = render( - - - - ) - - await waitFor(() => rendered.getByText('Status: success')) - - expect(onSuccess).toHaveBeenCalledWith('data') - }) - - it('should allow overriding the default config from the outermost provider', async () => { - const key1 = queryKey() - const key2 = queryKey() - - const outerConfig = { - queries: { - queryFn: jest.fn(async () => { - await sleep(10) - return 'outer' - }), - }, - } - - const innerConfig = { - queries: { - queryFn: jest.fn(async () => { - await sleep(10) - return 'inner' - }), - }, - } - - function Container() { - return ( - - - - - - - ) - } - - function First() { - const { data } = useQuery(key1) - return First: {String(data)} - } - - function Second() { - const { data } = useQuery(key2) - return Second: {String(data)} - } - - const rendered = render() - - await waitFor(() => rendered.getByText('First: outer')) - await waitFor(() => rendered.getByText('Second: inner')) - }) - - it('should reset to defaults when unmounted', async () => { - const key = queryKey() - - const onSuccess = jest.fn() - - const config = { - queries: { - refetchOnWindowFocus: false, - refetchOnMount: false, - retry: false, - }, - } - - const queryFn = async () => { - await sleep(10) - return 'data' - } - - function Container() { - const [mounted, setMounted] = React.useState(true) - - return ( - <> - - {mounted ? ( - - - - ) : ( - - )} - - ) - } - - function Page() { - const { data } = useQuery(key, queryFn) - - return ( -
-

Data: {data || 'none'}

-
- ) - } - - const rendered = render() - - await waitFor(() => rendered.getByText('Data: none')) - - act(() => { - queryCache.prefetchQuery(key, queryFn) - }) - - await waitFor(() => rendered.getByText('Data: data')) - - // tear down and unmount - // so we are NOT passing the config above (refetchOnMount should be `true` by default) - fireEvent.click(rendered.getByText('unmount')) - - act(() => { - onSuccess.mockClear() - }) - - await waitFor(() => rendered.getByText('Data: data')) - }) - - it('should reset to previous config when nested provider is unmounted', async () => { - const key = queryKey() - - let counterRef = 0 - const parentOnSuccess = jest.fn() - - const parentConfig = { - queries: { - refetchOnMount: false, - onSuccess: parentOnSuccess, - }, - } - - const childConfig = { - queries: { - refetchOnMount: true, - - // Override onSuccess of parent, making it a no-op - onSuccess: undefined, - }, - } - - const queryFn = async () => { - await sleep(10) - counterRef += 1 - return String(counterRef) - } - - function Component() { - const { data, refetch } = useQuery(key, queryFn) - - return ( -
-

Data: {data}

- -
- ) - } - - function Page() { - const [childConfigEnabled, setChildConfigEnabled] = useState(true) - - return ( -
- {childConfigEnabled && ( - - - - )} - {!childConfigEnabled && } - -
- ) - } - - render( - - - - ) - - // await waitFor(() => rendered.getByText('Data: 1')) - - // expect(parentOnSuccess).not.toHaveBeenCalled() - - // fireEvent.click(rendered.getByText('refetch')) - - // await waitFor(() => rendered.getByText('Data: 2')) - - // expect(parentOnSuccess).not.toHaveBeenCalled() - - // parentOnSuccess.mockReset() - - // fireEvent.click(rendered.getByText('disableChildConfig')) - - // await waitFor(() => rendered.getByText('Data: 2')) - - // // it should not refetch on mount - // expect(parentOnSuccess).not.toHaveBeenCalled() - - // fireEvent.click(rendered.getByText('refetch')) - - // await waitFor(() => rendered.getByText('Data: 3')) - - // expect(parentOnSuccess).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/react/tests/ssr.test.tsx b/src/react/tests/ssr.test.tsx index fbd98261be..cc1e985d2f 100644 --- a/src/react/tests/ssr.test.tsx +++ b/src/react/tests/ssr.test.tsx @@ -7,252 +7,141 @@ import React from 'react' import { renderToString } from 'react-dom/server' import { sleep, queryKey } from './utils' -import { - usePaginatedQuery, - ReactQueryCacheProvider, - useQuery, - queryCache, - QueryCache, -} from '../..' +import { useQuery, QueryClient, QueryClientProvider, QueryCache } from '../..' describe('Server Side Rendering', () => { - // A frozen cache does not cache any data. This is the default - // for the global cache in a node-environment, since it's - // otherwise easy to cache data between separate requests, - // which is a security risk. - // - // See https://github.com/tannerlinsley/react-query/issues/70 - it('global cache should be frozen by default', async () => { + const cache = new QueryCache() + const client = new QueryClient({ cache }) + + it('should not trigger fetch', () => { const key = queryKey() + const queryFn = jest.fn() - const fetchFn = () => Promise.resolve('data') - const data = await queryCache.fetchQuery(key, fetchFn) + function Page() { + const query = useQuery(key, queryFn) - expect(data).toBe('data') - expect(queryCache.getQuery(key)).toBeFalsy() + const content = `status ${query.status}` + + return ( +
+
{content}
+
+ ) + } + + const markup = renderToString( + + + + ) + + expect(markup).toContain('status loading') + expect(queryFn).toHaveBeenCalledTimes(0) }) - // When consumers of the library create a cache explicitly by - // creating a QueryCache, they take on the responsibility of - // not using that cache to cache data between requests or do so - // in a safe way. - it('created caches should be unfrozen by default', async () => { + it('should add prefetched data to cache', async () => { const key = queryKey() - - const cache = new QueryCache() const fetchFn = () => Promise.resolve('data') - const data = await cache.fetchQuery(key, fetchFn) - + const data = await client.fetchQueryData(key, fetchFn) expect(data).toBe('data') - expect(cache.getQuery(key)).toBeTruthy() + expect(client.getCache().find(key)?.state.data).toBe('data') }) - describe('frozen cache', () => { - it('should not trigger fetch', () => { - const key = queryKey() - - const cache = new QueryCache({ frozen: true }) - const queryFn = jest.fn() - - function Page() { - const query = useQuery(key, queryFn) + it('should return existing data from the cache', async () => { + const key = queryKey() + const queryFn = jest.fn(() => sleep(10)) - const content = `status ${query.status}` + function Page() { + const query = useQuery(key, queryFn) - return ( -
-
{content}
-
- ) - } + const content = `status ${query.status}` - const markup = renderToString( - - - + return ( +
+
{content}
+
) + } - expect(markup).toContain('status loading') - expect(queryFn).toHaveBeenCalledTimes(0) - }) - - it('should not add initialData to the cache', () => { - const key = queryKey() - - const cache = new QueryCache({ frozen: true }) - - function Page() { - const [page, setPage] = React.useState(1) - const { resolvedData } = usePaginatedQuery( - [key, page], - async (_: string, pageArg: number) => { - return pageArg - }, - { initialData: 1 } - ) + await client.prefetchQuery(key, queryFn) - return ( - -

{resolvedData}

- -
- ) - } + const markup = renderToString( + + + + ) - renderToString() - - expect(cache.getQueries().length).toEqual(0) - }) - - it('should not add prefetched data to the cache', async () => { - const key = queryKey() - - const cache = new QueryCache({ frozen: true }) - const fetchFn = () => Promise.resolve('data') - const data = await cache.fetchQuery(key, fetchFn) - - expect(data).toBe('data') - expect(cache.getQuery(key)).toBeFalsy() - }) + expect(markup).toContain('status success') + expect(queryFn).toHaveBeenCalledTimes(1) }) - describe('unfrozen cache', () => { - it('should not trigger fetch', () => { - const key = queryKey() - - const cache = new QueryCache({ frozen: false }) - const queryFn = jest.fn() - - function Page() { - const query = useQuery(key, queryFn) - - const content = `status ${query.status}` - - return ( -
-
{content}
-
- ) - } + it('should add initialData to the cache', () => { + const key = queryKey() - const markup = renderToString( - - - + const customCache = new QueryCache() + const customClient = new QueryClient({ cache: customCache }) + + function Page() { + const [page, setPage] = React.useState(1) + const { data } = useQuery( + [key, page], + async (_: string, pageArg: number) => { + return pageArg + }, + { initialData: 1 } ) - expect(markup).toContain('status loading') - expect(queryFn).toHaveBeenCalledTimes(0) - }) - - it('should add prefetched data to cache', async () => { - const key = queryKey() - - const cache = new QueryCache({ frozen: false }) - const fetchFn = () => Promise.resolve('data') - const data = await cache.fetchQuery(key, fetchFn) - - expect(data).toBe('data') - expect(cache.getQuery(key)?.state.data).toBe('data') - }) - - it('should return existing data from the cache', async () => { - const key = queryKey() - - const cache = new QueryCache({ frozen: false }) - const queryFn = jest.fn(() => sleep(10)) - - function Page() { - const query = useQuery(key, queryFn) - - const content = `status ${query.status}` - - return ( -
-
{content}
-
- ) - } - - await cache.prefetchQuery(key, queryFn) - - const markup = renderToString( - - - + return ( +
+

{data}

+ +
) + } - expect(markup).toContain('status success') - expect(queryFn).toHaveBeenCalledTimes(1) - }) - - it('should add initialData to the cache', () => { - const key = queryKey() - - const cache = new QueryCache({ frozen: false }) - function Page() { - const [page, setPage] = React.useState(1) - const { resolvedData } = usePaginatedQuery( - [key, page], - async (_: string, pageArg: number) => { - return pageArg - }, - { initialData: 1 } - ) - - return ( -
-

{resolvedData}

- -
- ) - } - - renderToString( - - - - ) + renderToString( + + + + ) - const keys = cache.getQueries().map(query => query.queryHash) + const keys = customCache.getAll().map(query => query.queryHash) - expect(keys).toEqual([`["${key}",1]`]) - }) + expect(keys).toEqual([`["${key}",1]`]) + }) - it('should not call setTimeout', async () => { - const key = queryKey() + it('should not call setTimeout', async () => { + const key = queryKey() - // @ts-ignore - const setTimeoutMock = jest.spyOn(global, 'setTimeout') + // @ts-ignore + const setTimeoutMock = jest.spyOn(global, 'setTimeout') - const cache = new QueryCache({ frozen: false }) - const queryFn = jest.fn(() => Promise.resolve()) + const queryFn = jest.fn(() => Promise.resolve()) - function Page() { - const query = useQuery(key, queryFn) + function Page() { + const query = useQuery(key, queryFn) - const content = `status ${query.status}` + const content = `status ${query.status}` - return ( -
-
{content}
-
- ) - } + return ( +
+
{content}
+
+ ) + } - await cache.prefetchQuery(key, queryFn) + await client.prefetchQuery(key, queryFn) - const markup = renderToString( - - - - ) + const markup = renderToString( + + + + ) - expect(markup).toContain('status success') - expect(queryFn).toHaveBeenCalledTimes(1) - expect(setTimeoutMock).toHaveBeenCalledTimes(0) + expect(markup).toContain('status success') + expect(queryFn).toHaveBeenCalledTimes(1) + expect(setTimeoutMock).toHaveBeenCalledTimes(0) - setTimeoutMock.mockRestore() - }) + setTimeoutMock.mockRestore() }) }) diff --git a/src/react/tests/suspense.test.tsx b/src/react/tests/suspense.test.tsx index 551de97f1f..8641cece6f 100644 --- a/src/react/tests/suspense.test.tsx +++ b/src/react/tests/suspense.test.tsx @@ -1,16 +1,20 @@ -import { render, waitFor, fireEvent } from '@testing-library/react' +import { waitFor, fireEvent } from '@testing-library/react' import { ErrorBoundary } from 'react-error-boundary' -import * as React from 'react' +import React from 'react' -import { sleep, queryKey, mockConsoleError } from './utils' +import { sleep, queryKey, mockConsoleError, renderWithClient } from './utils' import { useQuery, - queryCache, - ReactQueryErrorResetBoundary, - useErrorResetBoundary, + QueryClient, + QueryCache, + QueryErrorResetBoundary, + useQueryErrorResetBoundary, } from '../..' describe("useQuery's in Suspense mode", () => { + const cache = new QueryCache() + const client = new QueryClient({ cache }) + it('should not call the queryFn twice when used in Suspense mode', async () => { const key = queryKey() @@ -23,7 +27,8 @@ describe("useQuery's in Suspense mode", () => { return <>rendered } - const rendered = render( + const rendered = renderWithClient( + client, @@ -38,7 +43,7 @@ describe("useQuery's in Suspense mode", () => { const key = queryKey() function Page() { - useQuery([key], () => sleep(10), { suspense: true }) + useQuery(key, () => sleep(10), { suspense: true }) return <>rendered } @@ -54,20 +59,20 @@ describe("useQuery's in Suspense mode", () => { ) } - const rendered = render() + const rendered = renderWithClient(client, ) expect(rendered.queryByText('rendered')).toBeNull() - expect(queryCache.getQuery(key)).toBeFalsy() + expect(cache.find(key)).toBeFalsy() fireEvent.click(rendered.getByLabelText('toggle')) await waitFor(() => rendered.getByText('rendered')) - expect(queryCache.getQuery(key)?.observers.length).toBe(1) + expect(cache.find(key)?.observers.length).toBe(1) fireEvent.click(rendered.getByLabelText('toggle')) expect(rendered.queryByText('rendered')).toBeNull() - expect(queryCache.getQuery(key)?.observers.length).toBe(0) + expect(cache.find(key)?.observers.length).toBe(0) }) it('should call onSuccess on the first successful call', async () => { @@ -84,7 +89,8 @@ describe("useQuery's in Suspense mode", () => { return <>rendered } - const rendered = render( + const rendered = renderWithClient( + client, @@ -119,7 +125,8 @@ describe("useQuery's in Suspense mode", () => { return second } - const rendered = render( + const rendered = renderWithClient( + client, @@ -160,27 +167,32 @@ describe("useQuery's in Suspense mode", () => { return
rendered
} - const rendered = render( - queryCache.resetErrorBoundaries()} - fallbackRender={({ resetErrorBoundary }) => ( -
-
error boundary
- -
+ const rendered = renderWithClient( + client, + + {({ reset }) => ( + ( +
+
error boundary
+ +
+ )} + > + + + +
)} - > - - - -
+ ) await waitFor(() => rendered.getByText('Loading...')) @@ -221,8 +233,9 @@ describe("useQuery's in Suspense mode", () => { return
rendered
} - const rendered = render( - + const rendered = renderWithClient( + client, + {({ reset }) => ( {
)} - + ) await waitFor(() => rendered.getByText('Loading...')) @@ -286,7 +299,7 @@ describe("useQuery's in Suspense mode", () => { } function App() { - const { reset } = useErrorResetBoundary() + const { reset } = useQueryErrorResetBoundary() return ( { ) } - const rendered = render() + const rendered = renderWithClient(client, ) await waitFor(() => rendered.getByText('Loading...')) await waitFor(() => rendered.getByText('error boundary')) @@ -338,7 +351,8 @@ describe("useQuery's in Suspense mode", () => { return - + ) } - const { getByTestId, getByText } = render() + const { getByTestId, getByText } = renderWithClient(client, ) expect(getByTestId('title').textContent).toBe('') @@ -58,12 +61,15 @@ describe('useMutation', () => {

{mutationResult.error.message}

)} - + ) } - const { getByTestId, getByText, queryByTestId } = render() + const { getByTestId, getByText, queryByTestId } = renderWithClient( + client, + + ) expect(queryByTestId('error')).toBeNull() @@ -104,7 +110,7 @@ describe('useMutation', () => { ) } - const { getByTestId, getByText } = render() + const { getByTestId, getByText } = renderWithClient(client, ) expect(getByTestId('title').textContent).toBe('0') @@ -158,7 +164,7 @@ describe('useMutation', () => { ) } - const { getByTestId, getByText } = render() + const { getByTestId, getByText } = renderWithClient(client, ) expect(getByTestId('title').textContent).toBe('0') diff --git a/src/react/tests/usePaginatedQuery.test.tsx b/src/react/tests/usePaginatedQuery.test.tsx deleted file mode 100644 index 2e3f9e9072..0000000000 --- a/src/react/tests/usePaginatedQuery.test.tsx +++ /dev/null @@ -1,298 +0,0 @@ -import { render, fireEvent, waitFor } from '@testing-library/react' -import * as React from 'react' - -import { sleep, queryKey } from './utils' -import { usePaginatedQuery, PaginatedQueryResult } from '../..' - -describe('usePaginatedQuery', () => { - it('should return the correct states for a successful query', async () => { - const key = queryKey() - const states: PaginatedQueryResult[] = [] - - function Page() { - const state = usePaginatedQuery([key, 1], async (_, page: number) => { - await sleep(10) - return page - }) - - states.push(state) - - return ( -
-

Status: {state.status}

-
- ) - } - - const rendered = render() - - await waitFor(() => rendered.getByText('Status: success')) - - expect(states[0]).toEqual({ - canFetchMore: undefined, - clear: expect.any(Function), - data: undefined, - error: null, - failureCount: 0, - fetchMore: expect.any(Function), - isError: false, - isFetched: false, - isFetchedAfterMount: false, - isFetching: true, - isFetchingMore: false, - isIdle: false, - isInitialData: true, - isLoading: true, - isPreviousData: false, - isStale: true, - isSuccess: false, - latestData: undefined, - resolvedData: undefined, - refetch: expect.any(Function), - remove: expect.any(Function), - status: 'loading', - updatedAt: expect.any(Number), - }) - - expect(states[1]).toEqual({ - canFetchMore: undefined, - clear: expect.any(Function), - data: 1, - error: null, - failureCount: 0, - fetchMore: expect.any(Function), - isError: false, - isFetched: true, - isFetchedAfterMount: true, - isFetching: false, - isFetchingMore: false, - isIdle: false, - isInitialData: false, - isLoading: false, - isPreviousData: false, - isStale: true, - isSuccess: true, - latestData: 1, - resolvedData: 1, - refetch: expect.any(Function), - remove: expect.any(Function), - status: 'success', - updatedAt: expect.any(Number), - }) - }) - - it('should use previous page data while fetching the next page', async () => { - const key = queryKey() - - function Page() { - const [page, setPage] = React.useState(1) - const { resolvedData = 'undefined' } = usePaginatedQuery( - [key, page], - async (_: string, pageArg: number) => { - await sleep(10) - return pageArg - } - ) - - return ( -
-

Data {resolvedData}

- -
- ) - } - - const rendered = render() - - rendered.getByText('Data undefined') - await waitFor(() => rendered.getByText('Data 1')) - - fireEvent.click(rendered.getByText('next')) - rendered.getByText('Data 1') - await waitFor(() => rendered.getByText('Data 2')) - - fireEvent.click(rendered.getByText('next')) - rendered.getByText('Data 2') - await waitFor(() => rendered.getByText('Data 3')) - }) - - it('should use initialData only on the first page, then use previous page data while fetching the next page', async () => { - const key = queryKey() - - function Page() { - const [page, setPage] = React.useState(1) - const params = { page } - - const { resolvedData } = usePaginatedQuery( - [key, params], - async (_: string, paramsArg: typeof params) => { - await sleep(10) - return paramsArg.page - }, - { initialData: 0 } - ) - - return ( -
-

Data {resolvedData}

- -
- ) - } - - const rendered = render() - - rendered.getByText('Data 0') - - fireEvent.click(rendered.getByText('next')) - rendered.getByText('Data 0') - await waitFor(() => rendered.getByText('Data 2')) - - fireEvent.click(rendered.getByText('next')) - rendered.getByText('Data 2') - await waitFor(() => rendered.getByText('Data 3')) - - fireEvent.click(rendered.getByText('next')) - rendered.getByText('Data 3') - await waitFor(() => rendered.getByText('Data 4')) - }) - - // See https://github.com/tannerlinsley/react-query/issues/169 - it('should not trigger unnecessary loading state', async () => { - const key = queryKey() - - function Page() { - const [page, setPage] = React.useState(1) - const params = { page } - - const { resolvedData, status } = usePaginatedQuery( - [key, params], - async (_: string, paramsArg: typeof params) => { - await sleep(10) - return paramsArg.page - }, - { initialData: 0 } - ) - - return ( -
-

Data {resolvedData}

-

{status}

- -
- ) - } - - const rendered = render() - - rendered.getByText('Data 0') - - fireEvent.click(rendered.getByText('next')) - fireEvent.click(rendered.getByText('next')) - fireEvent.click(rendered.getByText('next')) - - await waitFor(() => rendered.getByTestId('status')) - - rendered.getByText('success') - }) - - it('should clear resolvedData data when query is falsy', async () => { - const key = queryKey() - - function Page() { - const [searchTerm, setSearchTerm] = React.useState('') - const [page, setPage] = React.useState(1) - const { resolvedData = 'undefined' } = usePaginatedQuery( - [key, searchTerm, page], - async (_: string, searchTermArg: string, pageArg: number) => { - await sleep(10) - return `${searchTermArg} ${pageArg}` - }, - { - enabled: searchTerm, - keepPreviousData: page !== 1, - } - ) - - return ( -
-

Data {resolvedData}

- setSearchTerm(e.currentTarget.value)} - /> - - -
- ) - } - - const rendered = render() - - fireEvent.change(rendered.getByPlaceholderText('Enter a search term'), { - target: { value: 'first-search' }, - }) - rendered.getByText('Data undefined') - await waitFor(() => rendered.getByText('Data first-search 1')) - - fireEvent.click(rendered.getByText('next')) - rendered.getByText('Data first-search 1') - await waitFor(() => rendered.getByText('Data first-search 2')) - - fireEvent.click(rendered.getByText('clear')) - rendered.getByText('Data undefined') - - fireEvent.change(rendered.getByPlaceholderText('Enter a search term'), { - target: { value: 'second-search' }, - }) - rendered.getByText('Data undefined') - await waitFor(() => rendered.getByText('Data second-search 1')) - - fireEvent.click(rendered.getByText('next')) - rendered.getByText('Data second-search 1') - await waitFor(() => rendered.getByText('Data second-search 2')) - }) - - it('should not suspend while fetching the next page', async () => { - const key = queryKey() - - function Page() { - const [page, setPage] = React.useState(1) - const params = { page } - - const { resolvedData } = usePaginatedQuery( - [key, params], - async (_: string, paramsArg: typeof params) => { - await sleep(10) - return paramsArg.page - }, - { - initialData: 0, - suspense: true, - } - ) - - return ( -
-

Data {resolvedData}

- -
- ) - } - - // render will throw if Page is suspended - const rendered = render() - - fireEvent.click(rendered.getByText('next')) - await waitFor(() => rendered.getByText('Data 2')) - }) -}) diff --git a/src/react/tests/useQueries.test.tsx b/src/react/tests/useQueries.test.tsx new file mode 100644 index 0000000000..b2a2da9a93 --- /dev/null +++ b/src/react/tests/useQueries.test.tsx @@ -0,0 +1,33 @@ +import React from 'react' + +import { queryKey, renderWithClient, waitForMs } from './utils' +import { useQueries, QueryClient, UseQueryResult, QueryCache } from '../..' + +describe('useQueries', () => { + const cache = new QueryCache() + const client = new QueryClient({ cache }) + + it('should return the correct states', async () => { + const key1 = queryKey() + const key2 = queryKey() + const results: UseQueryResult[][] = [] + + function Page() { + const result = useQueries([ + { queryKey: key1, queryFn: () => 1 }, + { queryKey: key2, queryFn: () => 2 }, + ]) + results.push(result) + return null + } + + renderWithClient(client, ) + + await waitForMs(10) + + expect(results.length).toBe(3) + expect(results[0]).toMatchObject([{ data: undefined }, { data: undefined }]) + expect(results[1]).toMatchObject([{ data: 1 }, { data: undefined }]) + expect(results[2]).toMatchObject([{ data: 1 }, { data: 2 }]) + }) +}) diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index 2083bdf717..38d0629852 100644 --- a/src/react/tests/useQuery.test.tsx +++ b/src/react/tests/useQuery.test.tsx @@ -1,5 +1,5 @@ -import { render, act, waitFor, fireEvent } from '@testing-library/react' -import * as React from 'react' +import { act, waitFor, fireEvent } from '@testing-library/react' +import React from 'react' import { sleep, @@ -8,10 +8,14 @@ import { mockVisibilityState, mockConsoleError, waitForMs, + renderWithClient, } from './utils' -import { useQuery, queryCache, QueryResult } from '../..' +import { useQuery, QueryClient, UseQueryResult, QueryCache } from '../..' describe('useQuery', () => { + const cache = new QueryCache() + const client = new QueryClient({ cache }) + it('should return the correct types', () => { const key = queryKey() @@ -93,7 +97,7 @@ describe('useQuery', () => { ) } - const rendered = render() + const rendered = renderWithClient(client, ) rendered.getByText('default') @@ -102,7 +106,7 @@ describe('useQuery', () => { it('should return the correct states for a successful query', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] function Page() { const state = useQuery(key, () => 'test') @@ -116,13 +120,12 @@ describe('useQuery', () => { ) } - const rendered = render() + const rendered = renderWithClient(client, ) await waitFor(() => rendered.getByText('Status: success')) expect(states[0]).toEqual({ canFetchMore: undefined, - clear: expect.any(Function), data: undefined, error: null, failureCount: 0, @@ -133,7 +136,6 @@ describe('useQuery', () => { isFetching: true, isFetchingMore: false, isIdle: false, - isInitialData: true, isLoading: true, isPreviousData: false, isStale: true, @@ -146,7 +148,6 @@ describe('useQuery', () => { expect(states[1]).toEqual({ canFetchMore: undefined, - clear: expect.any(Function), data: 'test', error: null, failureCount: 0, @@ -157,7 +158,6 @@ describe('useQuery', () => { isFetching: false, isFetchingMore: false, isIdle: false, - isInitialData: false, isLoading: false, isPreviousData: false, isStale: true, @@ -173,7 +173,7 @@ describe('useQuery', () => { const key = queryKey() const consoleMock = mockConsoleError() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] function Page() { const state = useQuery( @@ -194,13 +194,12 @@ describe('useQuery', () => { ) } - const rendered = render() + const rendered = renderWithClient(client, ) await waitFor(() => rendered.getByText('Status: error')) expect(states[0]).toEqual({ canFetchMore: undefined, - clear: expect.any(Function), data: undefined, error: null, failureCount: 0, @@ -211,7 +210,6 @@ describe('useQuery', () => { isFetching: true, isFetchingMore: false, isIdle: false, - isInitialData: true, isLoading: true, isPreviousData: false, isStale: true, @@ -224,7 +222,6 @@ describe('useQuery', () => { expect(states[1]).toEqual({ canFetchMore: undefined, - clear: expect.any(Function), data: undefined, error: null, failureCount: 1, @@ -235,7 +232,6 @@ describe('useQuery', () => { isFetching: true, isFetchingMore: false, isIdle: false, - isInitialData: true, isLoading: true, isPreviousData: false, isStale: true, @@ -248,18 +244,16 @@ describe('useQuery', () => { expect(states[2]).toEqual({ canFetchMore: undefined, - clear: expect.any(Function), data: undefined, error: 'rejected', failureCount: 2, fetchMore: expect.any(Function), isError: true, - isFetched: true, - isFetchedAfterMount: true, + isFetched: false, + isFetchedAfterMount: false, isFetching: false, isFetchingMore: false, isIdle: false, - isInitialData: true, isLoading: false, isPreviousData: false, isStale: true, @@ -275,9 +269,9 @@ describe('useQuery', () => { it('should set isFetchedAfterMount to true after a query has been fetched', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] - await queryCache.prefetchQuery(key, () => 'prefetched') + await client.prefetchQuery(key, () => 'prefetched') function Page() { const state = useQuery(key, () => 'data') @@ -285,7 +279,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitFor(() => expect(states.length).toBe(3)) @@ -308,7 +302,7 @@ describe('useQuery', () => { it('should call onSuccess after a query has been fetched', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] const onSuccess = jest.fn() function Page() { @@ -317,7 +311,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitFor(() => expect(states.length).toBe(2)) expect(onSuccess).toHaveBeenCalledTimes(1) @@ -326,12 +320,12 @@ describe('useQuery', () => { it('should call onError after a query has been fetched with an error', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] const onError = jest.fn() const consoleMock = mockConsoleError() function Page() { - const state = useQuery(key, () => Promise.reject('error'), { + const state = useQuery(key, () => Promise.reject('error'), { retry: false, onError, }) @@ -339,7 +333,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitFor(() => expect(states.length).toBe(2)) expect(onError).toHaveBeenCalledTimes(1) @@ -349,7 +343,7 @@ describe('useQuery', () => { it('should call onSettled after a query has been fetched', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] const onSettled = jest.fn() function Page() { @@ -358,7 +352,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitFor(() => expect(states.length).toBe(2)) expect(onSettled).toHaveBeenCalledTimes(1) @@ -367,7 +361,7 @@ describe('useQuery', () => { it('should call onSettled after a query has been fetched with an error', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] const onSettled = jest.fn() const consoleMock = mockConsoleError() @@ -380,7 +374,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitFor(() => expect(states.length).toBe(2)) expect(onSettled).toHaveBeenCalledTimes(1) @@ -388,46 +382,144 @@ describe('useQuery', () => { consoleMock.mockRestore() }) - // https://github.com/tannerlinsley/react-query/issues/896 - it('should fetch data in Strict mode when refetchOnMount is false', async () => { + it('should be able to watch a query without providing a query function', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] + + client.setQueryDefaults(key, () => 'data') function Page() { - const state = useQuery( - key, - async () => { - await sleep(10) - return 'test' - }, - { - refetchOnMount: false, - } - ) + const state = useQuery(key) states.push(state) return null } - render( - - - - ) + renderWithClient(client, ) - await waitFor(() => expect(states.length).toBe(4)) + await waitForMs(10) - expect(states[0]).toMatchObject({ - data: undefined, - }) - expect(states[1]).toMatchObject({ - data: undefined, - }) - expect(states[2]).toMatchObject({ - data: 'test', - }) - expect(states[3]).toMatchObject({ - data: 'test', - }) + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'data' }) + }) + + it('should fetch when refetchOnMount is false and nothing has been fetched yet', async () => { + const key = queryKey() + const states: UseQueryResult[] = [] + + function Page() { + const state = useQuery(key, () => 'test', { + refetchOnMount: false, + }) + states.push(state) + return null + } + + renderWithClient(client, ) + + await waitForMs(10) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'test' }) + }) + + it('should fetch when refetchOnMount is false and data has been fetched already', async () => { + const key = queryKey() + const states: UseQueryResult[] = [] + + client.setQueryData(key, 'prefetched') + + function Page() { + const state = useQuery(key, () => 'test', { + refetchOnMount: false, + }) + states.push(state) + return null + } + + renderWithClient(client, ) + + await waitForMs(10) + + expect(states.length).toBe(1) + expect(states[0]).toMatchObject({ data: 'prefetched' }) + }) + + it('should be able to select a part of the data with select', async () => { + const key = queryKey() + const states: UseQueryResult[] = [] + + function Page() { + const state = useQuery(key, () => ({ name: 'test' }), { + select: data => data.name, + }) + states.push(state) + return null + } + + renderWithClient(client, ) + + await waitForMs(10) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'test' }) + }) + + it('should be able to select a part of the data with select in object syntax', async () => { + const key = queryKey() + const states: UseQueryResult[] = [] + + function Page() { + const state = useQuery({ + queryKey: key, + queryFn: () => ({ name: 'test' }), + select: data => data.name, + }) + states.push(state) + return null + } + + renderWithClient(client, ) + + await waitForMs(10) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'test' }) + }) + + it('should not re-render when notifyOnStatusChange is false and the selected data did not change', async () => { + const key = queryKey() + const states: UseQueryResult[] = [] + + function Page() { + const state = useQuery(key, () => ({ name: 'test' }), { + select: data => data.name, + notifyOnStatusChange: false, + }) + + states.push(state) + + const { refetch } = state + + React.useEffect(() => { + setTimeout(() => { + refetch() + }, 5) + }, [refetch]) + + return null + } + + renderWithClient(client, ) + + await waitForMs(10) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'test' }) }) it('should share equal data structures between query results', async () => { @@ -443,7 +535,7 @@ describe('useQuery', () => { { id: '2', done: true }, ] - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] let count = 0 @@ -465,7 +557,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitFor(() => expect(states.length).toBe(4)) @@ -486,9 +578,39 @@ describe('useQuery', () => { return null }) + it('should use query function from hook when the existing query does not have a query function', async () => { + const key = queryKey() + const results: UseQueryResult[] = [] + + client.setQueryData(key, 'set') + + function Page() { + const result = useQuery(key, () => 'fetched', { enabled: false }) + + results.push(result) + + React.useEffect(() => { + setTimeout(() => { + client.refetchQueries(key) + }, 10) + }, []) + + return null + } + + renderWithClient(client, ) + + await waitForMs(50) + + expect(results.length).toBe(3) + expect(results[0]).toMatchObject({ data: 'set', isFetching: false }) + expect(results[1]).toMatchObject({ data: 'set', isFetching: true }) + expect(results[2]).toMatchObject({ data: 'fetched', isFetching: false }) + }) + it('should update query stale state when invalidated with invalidateQueries', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] function Page() { const state = useQuery(key, () => 'data', { staleTime: Infinity }) @@ -497,21 +619,18 @@ describe('useQuery', () => { React.useEffect(() => { setTimeout(() => { - queryCache.invalidateQueries(key, { - refetchActive: false, - refetchInactive: false, - }) + client.invalidateQueries(key) }, 10) }, []) return null } - render() + renderWithClient(client, ) await waitForMs(100) - expect(states.length).toBe(3) + expect(states.length).toBe(4) expect(states[0]).toMatchObject({ data: undefined, isFetching: true, @@ -526,15 +645,21 @@ describe('useQuery', () => { }) expect(states[2]).toMatchObject({ data: 'data', - isFetching: false, + isFetching: true, isSuccess: true, isStale: true, }) + expect(states[3]).toMatchObject({ + data: 'data', + isFetching: false, + isSuccess: true, + isStale: false, + }) }) it('should update disabled query when updated with invalidateQueries', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] let count = 0 function Page() { @@ -552,14 +677,14 @@ describe('useQuery', () => { React.useEffect(() => { setTimeout(() => { - queryCache.invalidateQueries(key, { refetchInactive: true }) + client.refetchQueries({ queryKey: key }) }, 20) }, []) return null } - render() + renderWithClient(client, ) await waitForMs(100) @@ -586,7 +711,7 @@ describe('useQuery', () => { it('should not refetch disabled query when invalidated with invalidateQueries', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] let count = 0 function Page() { @@ -604,14 +729,14 @@ describe('useQuery', () => { React.useEffect(() => { setTimeout(() => { - queryCache.invalidateQueries(key) + client.invalidateQueries(key) }, 20) }, []) return null } - render() + renderWithClient(client, ) await waitForMs(100) @@ -626,7 +751,7 @@ describe('useQuery', () => { it('should keep the previous data when keepPreviousData is set', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] function Page() { const [count, setCount] = React.useState(0) @@ -651,29 +776,108 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) - await waitFor(() => expect(states.length).toBe(4)) + await waitFor(() => expect(states.length).toBe(5)) + // Initial expect(states[0]).toMatchObject({ data: undefined, isFetching: true, isSuccess: false, isPreviousData: false, }) + // Fetched expect(states[1]).toMatchObject({ data: 0, isFetching: false, isSuccess: true, isPreviousData: false, }) + // Set state expect(states[2]).toMatchObject({ + data: 0, + isFetching: false, + isSuccess: true, + isPreviousData: false, + }) + // Previous data + expect(states[3]).toMatchObject({ data: 0, isFetching: true, isSuccess: true, isPreviousData: true, }) + // New data + expect(states[4]).toMatchObject({ + data: 1, + isFetching: false, + isSuccess: true, + isPreviousData: false, + }) + }) + + it('should not show initial data from next query if keepPreviousData is set', async () => { + const key = queryKey() + const states: UseQueryResult[] = [] + + function Page() { + const [count, setCount] = React.useState(0) + + const state = useQuery( + [key, count], + async () => { + await sleep(10) + return count + }, + { initialData: 99, keepPreviousData: true } + ) + + states.push(state) + + React.useEffect(() => { + setTimeout(() => { + setCount(1) + }, 20) + }, []) + + return null + } + + renderWithClient(client, ) + + await waitFor(() => expect(states.length).toBe(5)) + + // Initial + expect(states[0]).toMatchObject({ + data: 99, + isFetching: true, + isSuccess: false, + isPreviousData: false, + }) + // Fetched + expect(states[1]).toMatchObject({ + data: 0, + isFetching: false, + isSuccess: true, + isPreviousData: false, + }) + // Set state + expect(states[2]).toMatchObject({ + data: 0, + isFetching: false, + isSuccess: true, + isPreviousData: false, + }) + // Previous data expect(states[3]).toMatchObject({ + data: 0, + isFetching: true, + isSuccess: true, + isPreviousData: true, + }) + // New data + expect(states[4]).toMatchObject({ data: 1, isFetching: false, isSuccess: true, @@ -683,7 +887,7 @@ describe('useQuery', () => { it('should keep the previous data on disabled query when keepPreviousData is set', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] function Page() { const [count, setCount] = React.useState(0) @@ -716,9 +920,9 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) - await waitFor(() => expect(states.length).toBe(6)) + await waitFor(() => expect(states.length).toBe(7)) // Disabled query expect(states[0]).toMatchObject({ @@ -741,22 +945,29 @@ describe('useQuery', () => { isSuccess: true, isPreviousData: false, }) - // Switched query key + // Set state expect(states[3]).toMatchObject({ + data: 0, + isFetching: false, + isSuccess: true, + isPreviousData: false, + }) + // Switched query key + expect(states[4]).toMatchObject({ data: 0, isFetching: false, isSuccess: true, isPreviousData: true, }) // Fetching new query - expect(states[4]).toMatchObject({ + expect(states[5]).toMatchObject({ data: 0, isFetching: true, isSuccess: true, isPreviousData: true, }) // Fetched new query - expect(states[5]).toMatchObject({ + expect(states[6]).toMatchObject({ data: 1, isFetching: false, isSuccess: true, @@ -766,9 +977,9 @@ describe('useQuery', () => { it('should keep the previous data on disabled query when keepPreviousData is set and switching query key multiple times', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] - queryCache.setQueryData([key, 10], 10) + client.setQueryData([key, 10], 10) await sleep(10) @@ -803,9 +1014,9 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) - await waitFor(() => expect(states.length).toBe(5)) + await waitFor(() => expect(states.length).toBe(7)) // Disabled query expect(states[0]).toMatchObject({ @@ -814,12 +1025,12 @@ describe('useQuery', () => { isSuccess: true, isPreviousData: false, }) - // Switched query key + // Set state expect(states[1]).toMatchObject({ data: 10, isFetching: false, isSuccess: true, - isPreviousData: true, + isPreviousData: false, }) // Switched query key expect(states[2]).toMatchObject({ @@ -828,15 +1039,29 @@ describe('useQuery', () => { isSuccess: true, isPreviousData: true, }) - // Refetch + // Set state expect(states[3]).toMatchObject({ + data: 10, + isFetching: false, + isSuccess: true, + isPreviousData: true, + }) + // Switched query key + expect(states[4]).toMatchObject({ + data: 10, + isFetching: false, + isSuccess: true, + isPreviousData: true, + }) + // Refetch + expect(states[5]).toMatchObject({ data: 10, isFetching: true, isSuccess: true, isPreviousData: true, }) // Refetch done - expect(states[4]).toMatchObject({ + expect(states[6]).toMatchObject({ data: 12, isFetching: false, isSuccess: true, @@ -846,7 +1071,7 @@ describe('useQuery', () => { it('should use the correct query function when components use different configurations', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] function FirstComponent() { const state = useQuery(key, () => 1) @@ -877,7 +1102,7 @@ describe('useQuery', () => { ) } - render() + renderWithClient(client, ) await waitFor(() => expect(states.length).toBe(4)) @@ -898,10 +1123,10 @@ describe('useQuery', () => { it('should be able to set different stale times for a query', async () => { const key = queryKey() - const states1: QueryResult[] = [] - const states2: QueryResult[] = [] + const states1: UseQueryResult[] = [] + const states2: UseQueryResult[] = [] - await queryCache.prefetchQuery(key, () => 'prefetch') + await client.prefetchQuery(key, () => 'prefetch') await sleep(20) @@ -930,7 +1155,7 @@ describe('useQuery', () => { ) } - render() + renderWithClient(client, ) await waitFor(() => expect(states1).toMatchObject([ @@ -985,7 +1210,7 @@ describe('useQuery', () => { it('should re-render when a query becomes stale', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] function Page() { const state = useQuery(key, () => 'test', { @@ -995,10 +1220,11 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitForMs(100) + expect(states.length).toBe(3) expect(states[0]).toMatchObject({ isStale: true }) expect(states[1]).toMatchObject({ isStale: false }) expect(states[2]).toMatchObject({ isStale: true }) @@ -1006,10 +1232,10 @@ describe('useQuery', () => { it('should notify query cache when a query becomes stale', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] const fn = jest.fn() - const unsubscribe = queryCache.subscribe(fn) + const unsubscribe = cache.subscribe(fn) function Page() { const state = useQuery(key, () => 'test', { @@ -1019,16 +1245,21 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitForMs(20) unsubscribe() - expect(fn).toHaveBeenCalledTimes(3) + + // 1. Subscribe observer + // 2. Query init + // 3. Query stale + // 4. Unsubscribe observer + expect(fn).toHaveBeenCalledTimes(4) }) it('should not re-render when a query status changes and notifyOnStatusChange is false', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] function Page() { const state = useQuery( @@ -1052,7 +1283,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitForMs(30) @@ -1095,12 +1326,12 @@ describe('useQuery', () => { ) } - const rendered = render() + const rendered = renderWithClient(client, ) rendered.getByText('First Data: init') rendered.getByText('Second Data: init') - rendered.getByText('First Status: success') - rendered.getByText('Second Status: success') + rendered.getByText('First Status: idle') + rendered.getByText('Second Status: idle') }) it('should not override query configuration on render', async () => { @@ -1122,9 +1353,9 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) - expect(queryCache.getQuery(key)!.config.queryFn).toBe(queryFn1) + expect(cache.find(key)!.options.queryFn).toBe(queryFn1) }) it('should batch re-renders', async () => { @@ -1144,7 +1375,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitForMs(20) @@ -1180,7 +1411,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitForMs(20) @@ -1210,7 +1441,7 @@ describe('useQuery', () => { ) } - const rendered = render() + const rendered = renderWithClient(client, ) // use "act" to wait for state update and prevent console warning @@ -1232,7 +1463,7 @@ describe('useQuery', () => { return
status: {status}
} - const rendered = render() + const rendered = renderWithClient(client, ) rendered.getByText('status: loading') }) @@ -1251,7 +1482,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) expect(queryFn).toHaveBeenCalledWith(key, variables) }) @@ -1272,7 +1503,7 @@ describe('useQuery', () => { ) } - const rendered = render() + const rendered = renderWithClient(client, ) await waitFor(() => rendered.getByText('default')) @@ -1285,7 +1516,7 @@ describe('useQuery', () => { it('should not refetch stale query on focus when `refetchOnWindowFocus` is set to `false`', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] let count = 0 function Page() { @@ -1297,7 +1528,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitForMs(10) @@ -1314,7 +1545,7 @@ describe('useQuery', () => { it('should not refetch fresh query on focus when `refetchOnWindowFocus` is set to `true`', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] let count = 0 function Page() { @@ -1326,7 +1557,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitForMs(10) @@ -1343,7 +1574,7 @@ describe('useQuery', () => { it('should refetch fresh query on focus when `refetchOnWindowFocus` is set to `always`', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] let count = 0 function Page() { @@ -1355,7 +1586,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitForMs(10) @@ -1374,9 +1605,9 @@ describe('useQuery', () => { it('should refetch fresh query when refetchOnMount is set to always', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] - await queryCache.prefetchQuery(key, () => 'prefetched') + await client.prefetchQuery(key, () => 'prefetched') function Page() { const state = useQuery(key, () => 'data', { @@ -1387,7 +1618,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitForMs(10) @@ -1411,9 +1642,9 @@ describe('useQuery', () => { it('should refetch stale query when refetchOnMount is set to true', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] - await queryCache.prefetchQuery(key, () => 'prefetched') + await client.prefetchQuery(key, () => 'prefetched') await sleep(10) @@ -1426,7 +1657,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitForMs(10) @@ -1469,7 +1700,7 @@ describe('useQuery', () => { ) } - const rendered = render() + const rendered = renderWithClient(client, ) await waitFor(() => rendered.getByText('error')) await waitFor(() => rendered.getByText('Error test jaylen')) @@ -1477,22 +1708,22 @@ describe('useQuery', () => { consoleMock.mockRestore() }) - it('should always fetch if forceFetchOnMount is set', async () => { + it('should always fetch if refetchOnMount is set to always', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] - await queryCache.prefetchQuery(key, () => 'prefetched') + await client.prefetchQuery(key, () => 'prefetched') function Page() { const state = useQuery(key, () => 'data', { - forceFetchOnMount: true, + refetchOnMount: 'always', staleTime: 100, }) states.push(state) return null } - render() + renderWithClient(client, ) await waitFor(() => expect(states.length).toBe(3)) @@ -1503,9 +1734,9 @@ describe('useQuery', () => { ]) }) - it('should not fetch if initial data is set', async () => { + it('should fetch if initial data is set', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] function Page() { const state = useQuery(key, () => 'data', { @@ -1515,88 +1746,62 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) - await waitForMs(10) + await waitForMs(50) expect(states.length).toBe(2) - expect(states[0]).toMatchObject({ data: 'initial', isStale: false }) - expect(states[1]).toMatchObject({ data: 'initial', isStale: true }) - }) - it('should fetch if initial data is set and initial stale is set to true', async () => { - const key = queryKey() - const states: QueryResult[] = [] - - function Page() { - const state = useQuery(key, () => 'data', { - initialData: 'initial', - initialStale: true, - }) - states.push(state) - return null - } - - render() - - await waitFor(() => expect(states.length).toBe(3)) - - expect(states).toMatchObject([ - { data: 'initial', isStale: true, isFetching: false }, - { data: 'initial', isStale: true, isFetching: true }, - { data: 'data', isStale: true, isFetching: false }, - ]) + expect(states[0]).toMatchObject({ + data: 'initial', + isStale: true, + isFetching: true, + }) + expect(states[1]).toMatchObject({ + data: 'data', + isStale: true, + isFetching: false, + }) }) - it('should fetch if initial data is set and initial stale is set to true with stale time', async () => { + it('should not fetch if initial data is set with a stale time', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] function Page() { const state = useQuery(key, () => 'data', { staleTime: 50, initialData: 'initial', - initialStale: true, }) states.push(state) return null } - render() + renderWithClient(client, ) await waitForMs(100) - expect(states.length).toBe(4) + expect(states.length).toBe(2) expect(states[0]).toMatchObject({ data: 'initial', - isStale: true, + isStale: false, isFetching: false, }) expect(states[1]).toMatchObject({ data: 'initial', isStale: true, - isFetching: true, - }) - expect(states[2]).toMatchObject({ - data: 'data', - isStale: false, - isFetching: false, - }) - expect(states[3]).toMatchObject({ - data: 'data', - isStale: true, isFetching: false, }) }) - it('should keep initial stale and initial data when the query key changes', async () => { + it('should keep initial data when the query key changes', async () => { const key = queryKey() - const states: QueryResult<{ count: number }>[] = [] + const states: UseQueryResult<{ count: number }>[] = [] function Page() { const [count, setCount] = React.useState(0) const state = useQuery([key, count], () => ({ count: 10 }), { - initialStale: () => false, + staleTime: Infinity, initialData: () => ({ count }), }) states.push(state) @@ -1608,15 +1813,17 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitForMs(100) - expect(states.length).toBe(4) + expect(states.length).toBe(3) + // Initial expect(states[0]).toMatchObject({ data: { count: 0 } }) + // Set state expect(states[1]).toMatchObject({ data: { count: 0 } }) + // Update expect(states[2]).toMatchObject({ data: { count: 1 } }) - expect(states[3]).toMatchObject({ data: { count: 1 } }) }) it('should retry specified number of times', async () => { @@ -1642,7 +1849,7 @@ describe('useQuery', () => { ) } - const rendered = render() + const rendered = renderWithClient(client, ) await waitFor(() => rendered.getByText('loading')) await waitFor(() => rendered.getByText('error')) @@ -1688,7 +1895,7 @@ describe('useQuery', () => { ) } - const rendered = render() + const rendered = renderWithClient(client, ) await waitFor(() => rendered.getByText('loading')) await waitFor(() => rendered.getByText('error')) @@ -1735,7 +1942,7 @@ describe('useQuery', () => { ) } - const rendered = render() + const rendered = renderWithClient(client, ) // The query should display the first error result await waitFor(() => rendered.getByText('failureCount 1')) @@ -1769,9 +1976,9 @@ describe('useQuery', () => { it('should fetch on mount when a query was already created with setQueryData', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] - queryCache.setQueryData(key, 'prefetched') + client.setQueryData(key, 'prefetched') function Page() { const state = useQuery(key, () => 'data') @@ -1779,7 +1986,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitFor(() => expect(states).toMatchObject([ @@ -1804,7 +2011,7 @@ describe('useQuery', () => { it('should refetch after focus regain', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] const consoleMock = mockConsoleError() // make page unfocused @@ -1812,7 +2019,7 @@ describe('useQuery', () => { mockVisibilityState('hidden') // set data in cache to check if the hook query fn is actually called - queryCache.setQueryData(key, 'prefetched') + client.setQueryData(key, 'prefetched') function Page() { const state = useQuery(key, () => 'data') @@ -1820,7 +2027,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitFor(() => expect(states.length).toBe(3)) @@ -1866,7 +2073,7 @@ describe('useQuery', () => { // See https://github.com/tannerlinsley/react-query/issues/195 it('should refetch if stale after a prefetch', async () => { const key = queryKey() - const states: QueryResult[] = [] + const states: UseQueryResult[] = [] const queryFn = jest.fn() queryFn.mockImplementation(() => 'data') @@ -1874,7 +2081,7 @@ describe('useQuery', () => { const prefetchQueryFn = jest.fn() prefetchQueryFn.mockImplementation(() => 'not yet...') - await queryCache.prefetchQuery(key, prefetchQueryFn, { + await client.prefetchQuery(key, prefetchQueryFn, { staleTime: 10, }) @@ -1886,7 +2093,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await waitFor(() => expect(states.length).toBe(3)) @@ -1906,7 +2113,7 @@ describe('useQuery', () => { return 'not yet...' }) - await queryCache.prefetchQuery(key, prefetchQueryFn, { + await client.prefetchQuery(key, prefetchQueryFn, { staleTime: 1000, }) @@ -1919,7 +2126,7 @@ describe('useQuery', () => { return null } - render() + renderWithClient(client, ) await sleep(0) @@ -1956,7 +2163,7 @@ describe('useQuery', () => { ) } - const rendered = render() + const rendered = renderWithClient(client, ) await waitFor(() => rendered.getByText('failureCount 2')) await waitFor(() => rendered.getByText('failureCount 0')) @@ -1978,7 +2185,7 @@ describe('useQuery', () => { React.useEffect(() => { async function prefetch() { - await queryCache.prefetchQuery(key, () => + await client.prefetchQuery(key, () => Promise.resolve('prefetched data') ) setPrefetched(true) @@ -1995,7 +2202,7 @@ describe('useQuery', () => { ) } - const rendered = render() + const rendered = renderWithClient(client, ) await waitFor(() => rendered.getByText('isPrefetched')) fireEvent.click(rendered.getByText('setKey')) @@ -2023,7 +2230,7 @@ describe('useQuery', () => { ) } - const rendered = render() + const rendered = renderWithClient(client, ) rendered.getByText('Status: idle') rendered.getByText('Data: no data') @@ -2037,84 +2244,75 @@ describe('useQuery', () => { ]) }) - it('should not mark query as fetching, when using initialData', async () => { + it('should mark query as fetching, when using initialData', async () => { const key = queryKey() + const results: UseQueryResult[] = [] function Page() { - const query = useQuery(key, () => 'serverData', { - initialData: 'data', - }) - - return ( -
-
{query.data}
-
{`${query.isFetching}`}
-
- ) + const result = useQuery(key, () => 'serverData', { initialData: 'data' }) + results.push(result) + return null } - const rendered = render() + renderWithClient(client, ) - rendered.getByText('data') - expect(rendered.getByTestId('isFetching').textContent).toBe('false') + await waitForMs(10) + + expect(results.length).toBe(2) + expect(results[0]).toMatchObject({ data: 'data', isFetching: true }) + expect(results[1]).toMatchObject({ data: 'serverData', isFetching: false }) }) it('should initialize state properly, when initialData is falsy', async () => { const key = queryKey() + const results: UseQueryResult[] = [] function Page() { - const query = useQuery(key, () => 1, { initialData: 0 }) - - return ( -
-
{query.data}
-
{`${query.isFetching}`}
-
{query.status}
-
- ) + const result = useQuery(key, () => 1, { initialData: 0 }) + results.push(result) + return null } - const rendered = render() + renderWithClient(client, ) - rendered.getByText('0') - expect(rendered.getByTestId('isFetching').textContent).toBe('false') - expect(rendered.getByTestId('status').textContent).toBe('success') + await waitForMs(10) + + expect(results.length).toBe(2) + expect(results[0]).toMatchObject({ data: 0, isFetching: true }) + expect(results[1]).toMatchObject({ data: 1, isFetching: false }) }) // // See https://github.com/tannerlinsley/react-query/issues/214 it('data should persist when enabled is changed to false', async () => { const key = queryKey() - const callback = jest.fn() + const results: UseQueryResult[] = [] function Page() { const [shouldFetch, setShouldFetch] = React.useState(true) - const query = useQuery(key, () => 'fetched data', { + + const result = useQuery(key, () => 'fetched data', { enabled: shouldFetch, initialData: shouldFetch ? 'initial' : 'initial falsy', }) - const { data } = query + results.push(result) React.useEffect(() => { - callback() - }, [query]) + setTimeout(() => { + setShouldFetch(false) + }, 5) + }, []) - return ( -
-
{data}
- -
- ) + return null } - const rendered = render() + renderWithClient(client, ) - await waitFor(() => rendered.getByText('initial')) - fireEvent.click(rendered.getByText('setShouldFetch(false)')) - rendered.getByText('initial') - expect(callback.mock.calls.length).toBeLessThan(5) + await waitForMs(50) + expect(results.length).toBe(3) + expect(results[0]).toMatchObject({ data: 'initial', isStale: true }) + expect(results[1]).toMatchObject({ data: 'fetched data', isStale: true }) + expect(results[2]).toMatchObject({ data: 'fetched data', isStale: true }) }) it('it should support enabled:false in query object syntax', async () => { @@ -2126,17 +2324,15 @@ describe('useQuery', () => { const { status } = useQuery({ queryKey: key, queryFn, - config: { - enabled: false, - }, + enabled: false, }) return
status: {status}
} - const rendered = render() + const rendered = renderWithClient(client, ) expect(queryFn).not.toHaveBeenCalled() - expect(queryCache.getQuery(key)).not.toBeUndefined() + expect(cache.find(key)).not.toBeUndefined() rendered.getByText('status: idle') }) @@ -2156,7 +2352,7 @@ describe('useQuery', () => { ) } - const rendered = render() + const rendered = renderWithClient(client, ) await waitFor(() => rendered.getByText('status: idle')) }) @@ -2171,13 +2367,13 @@ describe('useQuery', () => { return
{query.data}
} - const rendered = render() + const rendered = renderWithClient(client, ) await waitFor(() => rendered.getByText('fetched data')) rendered.unmount() - const query = queryCache.getQuery(key) + const query = cache.find(key) // @ts-expect-error expect(query!.cacheTimeout).toBe(undefined) }) @@ -2213,7 +2409,7 @@ describe('useQuery', () => { ) } - const rendered = render() + const rendered = renderWithClient(client, ) await waitFor(() => rendered.getByText('status loading')) await waitFor(() => rendered.getByText('status success')) @@ -2243,7 +2439,7 @@ describe('useQuery', () => { return
count: {data}
} - const rendered = render() + const rendered = renderWithClient(client, ) // mount await waitFor(() => rendered.getByText('count: 0')) @@ -2251,73 +2447,13 @@ describe('useQuery', () => { await waitFor(() => rendered.getByText('count: 2')) }) - it('should error when using functions as query keys', () => { - const consoleMock = mockConsoleError() - - function Page() { - useQuery( - () => undefined, - () => 'data' - ) - return null - } - - expect(() => render()).toThrowError(/query key/) - - consoleMock.mockRestore() - }) - - it('should accept undefined as query key', async () => { - function Page() { - const result = useQuery(undefined, (key: undefined) => key) - return <>{JSON.stringify(result.data)} - } - - const rendered = render() - - await waitFor(() => rendered.getByText('null')) - }) - - it('should accept a boolean as query key', async () => { - function Page() { - const result = useQuery(false, (key: boolean) => key) - return <>{JSON.stringify(result.data)} - } - - const rendered = render() - - await waitFor(() => rendered.getByText('false')) - }) - - it('should accept null as query key', async () => { - function Page() { - const result = useQuery(null, key => key) - return <>{JSON.stringify(result.data)} - } - - const rendered = render() - - await waitFor(() => rendered.getByText('null')) - }) - - it('should accept a number as query key', async () => { - function Page() { - const result = useQuery(1, (key: number) => key) - return <>{JSON.stringify(result.data)} - } - - const rendered = render() - - await waitFor(() => rendered.getByText('1')) - }) - it('should accept an empty string as query key', async () => { function Page() { const result = useQuery('', (key: string) => key) return <>{JSON.stringify(result.data)} } - const rendered = render() + const rendered = renderWithClient(client, ) await waitFor(() => rendered.getByText('')) }) @@ -2328,7 +2464,7 @@ describe('useQuery', () => { return <>{JSON.stringify(result.data)} } - const rendered = render() + const rendered = renderWithClient(client, ) await waitFor(() => rendered.getByText('{"a":"a"}')) }) @@ -2355,7 +2491,7 @@ describe('useQuery', () => { ) } - const rendered = render() + const rendered = renderWithClient(client, ) expect(queryFn).toHaveBeenCalledTimes(0) fireEvent.click(rendered.getByText('enable')) await waitFor(() => rendered.getByText('data')) diff --git a/src/react/tests/utils.tsx b/src/react/tests/utils.tsx index d1b273cf9d..208adddf79 100644 --- a/src/react/tests/utils.tsx +++ b/src/react/tests/utils.tsx @@ -1,7 +1,14 @@ -import { waitFor } from '@testing-library/react' +import { render, waitFor } from '@testing-library/react' +import React from 'react' + +import { QueryClient, QueryClientProvider } from '../..' let queryKeyCount = 0 +export function renderWithClient(client: QueryClient, ui: React.ReactElement) { + return render({ui}) +} + export function mockVisibilityState(value: string) { Object.defineProperty(document, 'visibilityState', { value, diff --git a/src/react/types.ts b/src/react/types.ts new file mode 100644 index 0000000000..fc0aa0f4ac --- /dev/null +++ b/src/react/types.ts @@ -0,0 +1,57 @@ +import { + MutateOptions, + QueryObserverOptions, + QueryObserverResult, +} from '../core/types' + +export interface UseQueryOptions< + TData = unknown, + TError = unknown, + TQueryFnData = TData, + TQueryData = TQueryFnData +> extends QueryObserverOptions {} + +export interface UseQueryResult + extends QueryObserverResult {} + +export interface UseInfiniteQueryOptions< + TData = unknown, + TError = unknown, + TQueryFnData = TData, + TQueryData = TQueryFnData[] +> extends QueryObserverOptions {} + +export interface UseInfiniteQueryResult + extends QueryObserverResult {} + +export type MutationStatus = 'idle' | 'loading' | 'error' | 'success' + +export type MutationFunction = ( + variables: TVariables +) => Promise + +export type MutateFunction< + TData, + TError = unknown, + TVariables = unknown, + TSnapshot = unknown +> = ( + variables: TVariables, + options?: MutateOptions +) => Promise + +export type UseMutationResultPair = [ + MutateFunction, + UseMutationResult +] + +export interface UseMutationResult { + status: MutationStatus + data: TData | undefined + error: TError | null + isIdle: boolean + isLoading: boolean + isSuccess: boolean + isError: boolean + reset: () => void +} diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts index fe217ca8f4..104cc87121 100644 --- a/src/react/useBaseQuery.ts +++ b/src/react/useBaseQuery.ts @@ -1,70 +1,62 @@ import React from 'react' import { useIsMounted } from './utils' -import { getResolvedQueryConfig } from '../core/config' import { QueryObserver } from '../core/queryObserver' -import { QueryResultBase, QueryKey, QueryConfig } from '../core/types' -import { useErrorResetBoundary } from './ReactQueryErrorResetBoundary' -import { useQueryCache } from './ReactQueryCacheProvider' -import { useContextConfig } from './ReactQueryConfigProvider' +import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary' +import { useQueryClient } from './QueryClientProvider' +import { UseQueryOptions, UseQueryResult } from './types' -export function useBaseQuery( - queryKey: QueryKey, - config?: QueryConfig -): QueryResultBase { - const [, rerender] = React.useReducer(c => c + 1, 0) +export function useBaseQuery( + options: UseQueryOptions +): UseQueryResult { + const client = useQueryClient() const isMounted = useIsMounted() - const cache = useQueryCache() - const contextConfig = useContextConfig() - const errorResetBoundary = useErrorResetBoundary() + const errorResetBoundary = useQueryErrorResetBoundary() + const defaultedOptions = client.defaultQueryObserverOptions(options) - // Get resolved config - const resolvedConfig = getResolvedQueryConfig( - cache, - queryKey, - contextConfig, - config - ) + // Always set stale time when using suspense + if (defaultedOptions.suspense && defaultedOptions.staleTime === 0) { + options.staleTime = 5000 + } // Create query observer - const observerRef = React.useRef>() + const observerRef = React.useRef< + QueryObserver + >() const firstRender = !observerRef.current - const observer = observerRef.current || new QueryObserver(resolvedConfig) + const observer = observerRef.current || client.watchQuery(options) observerRef.current = observer + // Update options + if (!firstRender) { + observer.setOptions(options) + } + + const [currentResult, setCurrentResult] = React.useState(() => + observer.getCurrentResult() + ) + const currentOptions = observer.options + // Subscribe to the observer React.useEffect(() => { errorResetBoundary.clearReset() - return observer.subscribe(() => { + return observer.subscribe(result => { if (isMounted()) { - rerender() + setCurrentResult(result) } }) - }, [isMounted, observer, rerender, errorResetBoundary]) - - // Update config - if (!firstRender) { - observer.updateConfig(resolvedConfig) - } - - const result = observer.getCurrentResult() + }, [isMounted, observer, setCurrentResult, errorResetBoundary]) // Handle suspense - if (resolvedConfig.suspense || resolvedConfig.useErrorBoundary) { - const query = observer.getCurrentQuery() - - if ( - result.isError && - !errorResetBoundary.isReset() && - query.state.throwInErrorBoundary - ) { - throw result.error + if (currentOptions.suspense || currentOptions.useErrorBoundary) { + if (currentResult.isError && !errorResetBoundary.isReset()) { + throw currentResult.error } if ( - resolvedConfig.enabled && - resolvedConfig.suspense && - !result.isSuccess + currentOptions.enabled && + currentOptions.suspense && + !currentResult.isSuccess ) { errorResetBoundary.clearReset() const unsubscribe = observer.subscribe() @@ -72,5 +64,5 @@ export function useBaseQuery( } } - return result + return currentResult } diff --git a/src/react/useInfiniteQuery.ts b/src/react/useInfiniteQuery.ts index cc79eba5e4..aec19a5d55 100644 --- a/src/react/useInfiniteQuery.ts +++ b/src/react/useInfiniteQuery.ts @@ -1,58 +1,52 @@ -import { - InfiniteQueryConfig, - InfiniteQueryResult, - QueryFunction, - QueryKey, - TypedQueryFunction, - TypedQueryFunctionArgs, -} from '../core/types' -import { getQueryArgs } from '../core/utils' +import { QueryFunction, QueryKey } from '../core/types' +import { parseQueryArgs } from '../core/utils' +import { UseInfiniteQueryOptions, UseInfiniteQueryResult } from './types' import { useBaseQuery } from './useBaseQuery' -// TYPES - -export interface UseInfiniteQueryObjectConfig { - queryKey: QueryKey - queryFn?: QueryFunction - config?: InfiniteQueryConfig -} - // HOOK -// Parameter syntax with optional config -export function useInfiniteQuery( - queryKey: QueryKey, - queryConfig?: InfiniteQueryConfig -): InfiniteQueryResult - -// Parameter syntax with query function and optional config export function useInfiniteQuery< - TResult, - TError, - TArgs extends TypedQueryFunctionArgs + TData = unknown, + TError = unknown, + TQueryFnData = TData, + TQueryData = TQueryFnData[] +>( + options: UseInfiniteQueryOptions +): UseInfiniteQueryResult +export function useInfiniteQuery< + TData = unknown, + TError = unknown, + TQueryFnData = TData, + TQueryData = TQueryFnData[] >( queryKey: QueryKey, - queryFn: TypedQueryFunction, - queryConfig?: InfiniteQueryConfig -): InfiniteQueryResult - -export function useInfiniteQuery( + options?: UseInfiniteQueryOptions +): UseInfiniteQueryResult +export function useInfiniteQuery< + TData = unknown, + TError = unknown, + TQueryFnData = TData, + TQueryData = TQueryFnData[] +>( queryKey: QueryKey, - queryFn: QueryFunction, - queryConfig?: InfiniteQueryConfig -): InfiniteQueryResult - -// Object syntax -export function useInfiniteQuery( - config: UseInfiniteQueryObjectConfig -): InfiniteQueryResult - -// Implementation -export function useInfiniteQuery( - arg1: any, - arg2?: any, - arg3?: any -): InfiniteQueryResult { - const [queryKey, config] = getQueryArgs(arg1, arg2, arg3) - return useBaseQuery(queryKey, { ...config, infinite: true }) + queryFn: QueryFunction, + options?: UseInfiniteQueryOptions +): UseInfiniteQueryResult +export function useInfiniteQuery< + TData, + TError, + TQueryFnData = TData, + TQueryData = TQueryFnData[] +>( + arg1: + | QueryKey + | UseInfiniteQueryOptions, + arg2?: + | QueryFunction + | UseInfiniteQueryOptions, + arg3?: UseInfiniteQueryOptions +): UseInfiniteQueryResult { + const parsedOptions = parseQueryArgs(arg1, arg2, arg3) + parsedOptions.infinite = true + return useBaseQuery(parsedOptions) as UseInfiniteQueryResult } diff --git a/src/react/useIsFetching.ts b/src/react/useIsFetching.ts index 2f3658f0b0..2a40d8cdcd 100644 --- a/src/react/useIsFetching.ts +++ b/src/react/useIsFetching.ts @@ -1,21 +1,21 @@ import React from 'react' -import { useQueryCache } from './ReactQueryCacheProvider' +import { useQueryClient } from './QueryClientProvider' import { useIsMounted } from './utils' export function useIsFetching(): number { + const client = useQueryClient() const isMounted = useIsMounted() - const queryCache = useQueryCache() - const [isFetching, setIsFetching] = React.useState(queryCache.isFetching) + const [isFetching, setIsFetching] = React.useState(client.isFetching()) React.useEffect( () => - queryCache.subscribe(() => { + client.getCache().subscribe(() => { if (isMounted()) { - setIsFetching(queryCache.isFetching) + setIsFetching(client.isFetching()) } }), - [queryCache, setIsFetching, isMounted] + [client, setIsFetching, isMounted] ) return isFetching diff --git a/src/react/useMutation.ts b/src/react/useMutation.ts index 2d0a313101..a86b924926 100644 --- a/src/react/useMutation.ts +++ b/src/react/useMutation.ts @@ -1,26 +1,23 @@ import React from 'react' import { useMountedCallback } from './utils' -import { getResolvedMutationConfig } from '../core/config' -import { Console, uid, getStatusProps } from '../core/utils' +import { getStatusProps } from '../core/utils' +import { getConsole } from '../core/setConsole' +import { MutateOptions, MutationOptions } from '../core/types' +import { useQueryClient } from './QueryClientProvider' import { - QueryStatus, - MutationResultPair, MutationFunction, - MutationConfig, - MutateConfig, - MutationResult, -} from '../core/types' -import { useQueryCache } from './ReactQueryCacheProvider' -import { useContextConfig } from './ReactQueryConfigProvider' + MutationStatus, + UseMutationResultPair, +} from './types' // TYPES type Reducer = (prevState: S, action: A) => S -interface State { - status: QueryStatus - data: TResult | undefined +interface State { + status: MutationStatus + data: TData | undefined error: TError | null isIdle: boolean isLoading: boolean @@ -28,69 +25,68 @@ interface State { isError: boolean } -const enum ActionType { - Reset, - Loading, - Resolve, - Reject, -} - interface ResetAction { - type: ActionType.Reset + type: 'reset' } interface LoadingAction { - type: ActionType.Loading + type: 'loading' } -interface ResolveAction { - type: ActionType.Resolve - data: TResult +interface ResolveAction { + type: 'resolve' + data: TData } interface RejectAction { - type: ActionType.Reject + type: 'reject' error: TError } -type Action = +type Action = | ResetAction | LoadingAction - | ResolveAction + | ResolveAction | RejectAction // HOOK -function getDefaultState(): State { +let _uid = 0 + +function uid(): number { + return _uid++ +} + +function getDefaultState(): State { return { - ...getStatusProps(QueryStatus.Idle), + ...getStatusProps('idle'), data: undefined, error: null, } } -function mutationReducer( - state: State, - action: Action -): State { +function mutationReducer( + state: State, + action: Action +): State { switch (action.type) { - case ActionType.Reset: + case 'reset': return getDefaultState() - case ActionType.Loading: + case 'loading': return { - ...getStatusProps(QueryStatus.Loading), + ...getStatusProps('loading'), data: undefined, error: null, } - case ActionType.Resolve: + case 'resolve': return { - ...getStatusProps(QueryStatus.Success), + ...getStatusProps('success'), data: action.data, error: null, } - case ActionType.Reject: + case 'reject': return { - ...getStatusProps(QueryStatus.Error), + ...getStatusProps('error'), data: undefined, error: action.error, } @@ -100,39 +96,39 @@ function mutationReducer( } export function useMutation< - TResult, + TData, TError = unknown, TVariables = undefined, TSnapshot = unknown >( - mutationFn: MutationFunction, - config: MutationConfig = {} -): MutationResultPair { - const cache = useQueryCache() - const contextConfig = useContextConfig() + mutationFn: MutationFunction, + options: MutationOptions = {} +): UseMutationResultPair { + const client = useQueryClient() - // Get resolved config - const resolvedConfig = getResolvedMutationConfig(cache, contextConfig, config) + // Get defaulted options + const defaultedOptions = client.defaultMutationOptions(options) const [state, unsafeDispatch] = React.useReducer( - mutationReducer as Reducer, Action>, + mutationReducer as Reducer, Action>, null, getDefaultState ) + const dispatch = useMountedCallback(unsafeDispatch) const latestMutationRef = React.useRef() const latestMutationFnRef = React.useRef(mutationFn) latestMutationFnRef.current = mutationFn - const latestConfigRef = React.useRef(resolvedConfig) - latestConfigRef.current = resolvedConfig + const latestOptionsRef = React.useRef(defaultedOptions) + latestOptionsRef.current = defaultedOptions const mutate = React.useCallback( async ( - variables?: TVariables, - mutateConfig: MutateConfig = {} - ): Promise => { - const latestConfig = latestConfigRef.current + variables: TVariables, + mutateOptions: MutateOptions = {} + ): Promise => { + const latestOptions = latestOptionsRef.current const mutationId = uid() latestMutationRef.current = mutationId @@ -142,44 +138,44 @@ export function useMutation< let snapshotValue: TSnapshot | undefined try { - dispatch({ type: ActionType.Loading }) - snapshotValue = (await latestConfig.onMutate?.(variables!)) as TSnapshot + dispatch({ type: 'loading' }) + snapshotValue = await latestOptions.onMutate?.(variables) const latestMutationFn = latestMutationFnRef.current - const data = await latestMutationFn(variables!) + const data = await latestMutationFn(variables) if (isLatest()) { - dispatch({ type: ActionType.Resolve, data }) + dispatch({ type: 'resolve', data }) } - await latestConfig.onSuccess?.(data, variables!) - await mutateConfig.onSuccess?.(data, variables!) - await latestConfig.onSettled?.(data, null, variables!) - await mutateConfig.onSettled?.(data, null, variables!) + await latestOptions.onSuccess?.(data, variables) + await mutateOptions.onSuccess?.(data, variables) + await latestOptions.onSettled?.(data, null, variables) + await mutateOptions.onSettled?.(data, null, variables) return data } catch (error) { - Console.error(error) - await latestConfig.onError?.(error, variables!, snapshotValue!) - await mutateConfig.onError?.(error, variables!, snapshotValue!) - await latestConfig.onSettled?.( + getConsole().error(error) + await latestOptions.onError?.(error, variables, snapshotValue) + await mutateOptions.onError?.(error, variables, snapshotValue) + await latestOptions.onSettled?.( undefined, error, - variables!, - snapshotValue as TSnapshot + variables, + snapshotValue ) - await mutateConfig.onSettled?.( + await mutateOptions.onSettled?.( undefined, error, - variables!, + variables, snapshotValue ) if (isLatest()) { - dispatch({ type: ActionType.Reject, error }) + dispatch({ type: 'reject', error }) } - if (mutateConfig.throwOnError || latestConfig.throwOnError) { + if (mutateOptions.throwOnError || latestOptions.throwOnError) { throw error } } @@ -188,21 +184,20 @@ export function useMutation< ) React.useEffect(() => { - const latestConfig = latestConfigRef.current - const { suspense, useErrorBoundary } = latestConfig + const latestOptions = latestOptionsRef.current + const { suspense, useErrorBoundary } = latestOptions if ((useErrorBoundary || suspense) && state.error) { throw state.error } }, [state.error]) const reset = React.useCallback(() => { - dispatch({ type: ActionType.Reset }) + dispatch({ type: 'reset' }) }, [dispatch]) - const result: MutationResult = { - ...state, + return React.useMemo(() => [mutate, { ...state, reset }], [ + mutate, + state, reset, - } - - return [mutate, result] + ]) } diff --git a/src/react/usePaginatedQuery.ts b/src/react/usePaginatedQuery.ts deleted file mode 100644 index 9418f84ace..0000000000 --- a/src/react/usePaginatedQuery.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - PaginatedQueryConfig, - PaginatedQueryResult, - QueryFunction, - QueryKey, - TypedQueryFunction, - TypedQueryFunctionArgs, -} from '../core/types' -import { getQueryArgs } from '../core/utils' -import { useBaseQuery } from './useBaseQuery' - -// A paginated query is more like a "lag" query, which means -// as the query key changes, we keep the results from the -// last query and use them as placeholder data in the next one -// We DON'T use it as initial data though. That's important - -// TYPES - -export interface UsePaginatedQueryObjectConfig { - queryKey: QueryKey - queryFn?: QueryFunction - config?: PaginatedQueryConfig -} - -// HOOK - -// Parameter syntax with optional config -export function usePaginatedQuery( - queryKey: QueryKey, - queryConfig?: PaginatedQueryConfig -): PaginatedQueryResult - -// Parameter syntax with query function and optional config -export function usePaginatedQuery< - TResult, - TError, - TArgs extends TypedQueryFunctionArgs ->( - queryKey: QueryKey, - queryFn: TypedQueryFunction, - queryConfig?: PaginatedQueryConfig -): PaginatedQueryResult - -export function usePaginatedQuery( - queryKey: QueryKey, - queryFn: QueryFunction, - queryConfig?: PaginatedQueryConfig -): PaginatedQueryResult - -// Object syntax -export function usePaginatedQuery( - config: UsePaginatedQueryObjectConfig -): PaginatedQueryResult - -// Implementation -export function usePaginatedQuery( - arg1: any, - arg2?: any, - arg3?: any -): PaginatedQueryResult { - const [queryKey, config] = getQueryArgs(arg1, arg2, arg3) - const result = useBaseQuery(queryKey, { - keepPreviousData: true, - ...config, - }) - return { - ...result, - resolvedData: result.data, - latestData: result.isPreviousData ? undefined : result.data, - } -} diff --git a/src/react/useQueries.ts b/src/react/useQueries.ts new file mode 100644 index 0000000000..149d917d25 --- /dev/null +++ b/src/react/useQueries.ts @@ -0,0 +1,39 @@ +import React from 'react' + +import { useIsMounted } from './utils' +import { QueriesObserver } from '../core/queriesObserver' +import { useQueryClient } from './QueryClientProvider' +import { UseQueryOptions, UseQueryResult } from './types' + +export function useQueries(queries: UseQueryOptions[]): UseQueryResult[] { + const client = useQueryClient() + const isMounted = useIsMounted() + + // Create queries observer + const observerRef = React.useRef() + const firstRender = !observerRef.current + const observer = observerRef.current || client.watchQueries(queries) + observerRef.current = observer + + // Update queries + if (!firstRender) { + observer.setQueries(queries) + } + + const [currentResult, setCurrentResult] = React.useState(() => + observer.getCurrentResult() + ) + + // Subscribe to the observer + React.useEffect( + () => + observer.subscribe(result => { + if (isMounted()) { + setCurrentResult(result) + } + }), + [isMounted, observer, setCurrentResult] + ) + + return currentResult +} diff --git a/src/react/useQuery.ts b/src/react/useQuery.ts index d3f36bf4bb..9ac689b89f 100644 --- a/src/react/useQuery.ts +++ b/src/react/useQuery.ts @@ -1,54 +1,41 @@ -import { - QueryConfig, - QueryFunction, - QueryKey, - QueryResult, - TypedQueryFunction, - TypedQueryFunctionArgs, -} from '../core/types' -import { getQueryArgs } from '../core/utils' +import { QueryFunction, QueryKey } from '../core/types' +import { parseQueryArgs } from '../core/utils' +import { UseQueryOptions, UseQueryResult } from './types' import { useBaseQuery } from './useBaseQuery' -// TYPES - -export interface UseQueryObjectConfig { - queryKey: QueryKey - queryFn?: QueryFunction - config?: QueryConfig -} - // HOOK -// Parameter syntax with optional config -export function useQuery( - queryKey: QueryKey, - queryConfig?: QueryConfig -): QueryResult - -// Parameter syntax with query function and optional config -export function useQuery( +export function useQuery< + TData = unknown, + TError = unknown, + TQueryFnData = TData +>( + options: UseQueryOptions +): UseQueryResult +export function useQuery< + TData = unknown, + TError = unknown, + TQueryFnData = TData +>( queryKey: QueryKey, - queryFn: TypedQueryFunction, - queryConfig?: QueryConfig -): QueryResult - -export function useQuery( + options?: UseQueryOptions +): UseQueryResult +export function useQuery< + TData = unknown, + TError = unknown, + TQueryFnData = TData +>( queryKey: QueryKey, - queryFn: QueryFunction, - queryConfig?: QueryConfig -): QueryResult - -// Object syntax -export function useQuery( - config: UseQueryObjectConfig -): QueryResult - -// Implementation -export function useQuery( - arg1: any, - arg2?: any, - arg3?: any -): QueryResult { - const [queryKey, config] = getQueryArgs(arg1, arg2, arg3) - return useBaseQuery(queryKey, config) + queryFn: QueryFunction, + options?: UseQueryOptions +): UseQueryResult +export function useQuery( + arg1: QueryKey | UseQueryOptions, + arg2?: + | QueryFunction + | UseQueryOptions, + arg3?: UseQueryOptions +): UseQueryResult { + const parsedOptions = parseQueryArgs(arg1, arg2, arg3) + return useBaseQuery(parsedOptions) } diff --git a/src/react/utils.ts b/src/react/utils.ts index ff279c30d5..bce3591437 100644 --- a/src/react/utils.ts +++ b/src/react/utils.ts @@ -5,8 +5,9 @@ import { isServer } from '../core/utils' export function useIsMounted(): () => boolean { const mountedRef = React.useRef(false) const isMounted = React.useCallback(() => mountedRef.current, []) + const useEffect = isServer ? React.useEffect : React.useLayoutEffect - React[isServer ? 'useEffect' : 'useLayoutEffect'](() => { + useEffect(() => { mountedRef.current = true return () => { mountedRef.current = false diff --git a/tsconfig.json b/tsconfig.json index eda3826e7b..da4415a412 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,11 +13,7 @@ "noUnusedParameters": true, "skipLibCheck": true, "strict": true, - "types": ["jest"], - "baseUrl": "./", - "paths": { - "react-query": ["src/index.ts"] - } + "types": ["jest"] }, "include": ["./src"] }