From 31b9ab49af052cf12419fcdc4db8b9c035177eb3 Mon Sep 17 00:00:00 2001 From: John Date: Tue, 25 Jun 2024 16:10:16 +0200 Subject: [PATCH] feat(core): Add possibility to pass a callback to enabled. (#7566) * Add possibility to pass a callback to enabled. * Refactor into using the same pattern as resolving staletime, with the use of a resolveEnabled util function * Update tests for enabled option as a callback * update docs * remove typo * Update enabled type in docs * remove duplicated test case * Fix eslint errors --------- Co-authored-by: Dominik Dorfmeister Co-authored-by: John Pettersson --- .../react/guides/disabling-queries.md | 2 +- docs/framework/react/react-native.md | 31 +++ docs/framework/react/reference/useQuery.md | 2 +- .../src/__tests__/queryObserver.test.tsx | 181 ++++++++++++++++++ packages/query-core/src/query.ts | 12 +- packages/query-core/src/queryObserver.ts | 27 ++- packages/query-core/src/types.ts | 14 +- packages/query-core/src/utils.ts | 13 ++ 8 files changed, 267 insertions(+), 15 deletions(-) diff --git a/docs/framework/react/guides/disabling-queries.md b/docs/framework/react/guides/disabling-queries.md index 6893039809..da173f688b 100644 --- a/docs/framework/react/guides/disabling-queries.md +++ b/docs/framework/react/guides/disabling-queries.md @@ -3,7 +3,7 @@ id: disabling-queries title: Disabling/Pausing Queries --- -If you ever want to disable a query from automatically running, you can use the `enabled = false` option. +If you ever want to disable a query from automatically running, you can use the `enabled = false` option. The enabled option also accepts a callback that returns a boolean. When `enabled` is `false`: diff --git a/docs/framework/react/react-native.md b/docs/framework/react/react-native.md index c14f4c30fd..ad8d230c31 100644 --- a/docs/framework/react/react-native.md +++ b/docs/framework/react/react-native.md @@ -143,3 +143,34 @@ function MyComponent() { return DataUpdatedAt: {dataUpdatedAt} } ``` + +## Disable queries on out of focus screens + +Enabled can also be set to a callback to support disabling queries on out of focus screens without state and re-rendering on navigation, similar to how notifyOnChangeProps works but in addition it wont trigger refetching when invalidating queries with refetchType active. + +```tsx +import React from 'react' +import { useFocusEffect } from '@react-navigation/native' + +export function useQueryFocusAware(notifyOnChangeProps?: NotifyOnChangeProps) { + const focusedRef = React.useRef(true) + + useFocusEffect( + React.useCallback(() => { + focusedRef.current = true + + return () => { + focusedRef.current = false + } + }, []), + ) + + return () => focusRef.current + + useQuery({ + queryKey: ['key'], + queryFn: () => fetch(...), + enabled: () => focusedRef.current, + }) +} +``` diff --git a/docs/framework/react/reference/useQuery.md b/docs/framework/react/reference/useQuery.md index 02137a3cdc..c71ba342ae 100644 --- a/docs/framework/react/reference/useQuery.md +++ b/docs/framework/react/reference/useQuery.md @@ -70,7 +70,7 @@ const { - The function that the query will use to request data. - Receives a [QueryFunctionContext](../../guides/query-functions#queryfunctioncontext) - Must return a promise that will either resolve data or throw an error. The data cannot be `undefined`. -- `enabled: boolean` +- `enabled: boolean | (query: Query) => boolean` - Set this to `false` to disable this query from automatically running. - Can be used for [Dependent Queries](../../guides/dependent-queries). - `networkMode: 'online' | 'always' | 'offlineFirst` diff --git a/packages/query-core/src/__tests__/queryObserver.test.tsx b/packages/query-core/src/__tests__/queryObserver.test.tsx index bc6d4e8d13..c3a1ebb037 100644 --- a/packages/query-core/src/__tests__/queryObserver.test.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test.tsx @@ -52,6 +52,173 @@ describe('queryObserver', () => { unsubscribe() }) + describe('enabled is a callback that initially returns false', () => { + let observer: QueryObserver> + let enabled: boolean + let count: number + let key: Array + + beforeEach(() => { + key = queryKey() + count = 0 + enabled = false + + observer = new QueryObserver(queryClient, { + queryKey: key, + staleTime: Infinity, + enabled: () => enabled, + queryFn: async () => { + await sleep(10) + count++ + return 'data' + }, + }) + }) + + test('should not fetch on mount', () => { + const unsubscribe = observer.subscribe(vi.fn()) + + // Has not fetched and is not fetching since its disabled + expect(count).toBe(0) + expect(observer.getCurrentResult()).toMatchObject({ + status: 'pending', + fetchStatus: 'idle', + data: undefined, + }) + + unsubscribe() + }) + + test('should not be re-fetched when invalidated with refetchType: all', async () => { + const unsubscribe = observer.subscribe(vi.fn()) + + queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' }) + + //So we still expect it to not have fetched and not be fetching + expect(count).toBe(0) + expect(observer.getCurrentResult()).toMatchObject({ + status: 'pending', + fetchStatus: 'idle', + data: undefined, + }) + await waitFor(() => expect(count).toBe(0)) + + unsubscribe() + }) + + test('should still trigger a fetch when refetch is called', async () => { + const unsubscribe = observer.subscribe(vi.fn()) + + expect(enabled).toBe(false) + + //Not the same with explicit refetch, this will override enabled and trigger a fetch anyway + observer.refetch() + + expect(observer.getCurrentResult()).toMatchObject({ + status: 'pending', + fetchStatus: 'fetching', + data: undefined, + }) + + await waitFor(() => expect(count).toBe(1)) + expect(observer.getCurrentResult()).toMatchObject({ + status: 'success', + fetchStatus: 'idle', + data: 'data', + }) + + unsubscribe() + }) + + test('should fetch if unsubscribed, then enabled returns true, and then re-subscribed', async () => { + let unsubscribe = observer.subscribe(vi.fn()) + expect(observer.getCurrentResult()).toMatchObject({ + status: 'pending', + fetchStatus: 'idle', + data: undefined, + }) + + unsubscribe() + + enabled = true + + unsubscribe = observer.subscribe(vi.fn()) + + expect(observer.getCurrentResult()).toMatchObject({ + status: 'pending', + fetchStatus: 'fetching', + data: undefined, + }) + + await waitFor(() => expect(count).toBe(1)) + + unsubscribe() + }) + + test('should not be re-fetched if not subscribed to after enabled was toggled to true', async () => { + const unsubscribe = observer.subscribe(vi.fn()) + + // Toggle enabled + enabled = true + + unsubscribe() + + queryClient.invalidateQueries({ queryKey: key, refetchType: 'active' }) + + expect(observer.getCurrentResult()).toMatchObject({ + status: 'pending', + fetchStatus: 'idle', + data: undefined, + }) + expect(count).toBe(0) + }) + + test('should not be re-fetched if not subscribed to after enabled was toggled to true', async () => { + const unsubscribe = observer.subscribe(vi.fn()) + + // Toggle enabled + enabled = true + + queryClient.invalidateQueries({ queryKey: key, refetchType: 'active' }) + + expect(observer.getCurrentResult()).toMatchObject({ + status: 'pending', + fetchStatus: 'fetching', + data: undefined, + }) + await waitFor(() => expect(count).toBe(1)) + + unsubscribe() + }) + + test('should handle that the enabled callback updates the return value', async () => { + const unsubscribe = observer.subscribe(vi.fn()) + + // Toggle enabled + enabled = true + + queryClient.invalidateQueries({ queryKey: key, refetchType: 'inactive' }) + + //should not refetch since it was active and we only refetch inactive + await waitFor(() => expect(count).toBe(0)) + + queryClient.invalidateQueries({ queryKey: key, refetchType: 'active' }) + + //should refetch since it was active and we refetch active + await waitFor(() => expect(count).toBe(1)) + + // Toggle enabled + enabled = false + + //should not refetch since it is not active and we only refetch active + queryClient.invalidateQueries({ queryKey: key, refetchType: 'active' }) + + await waitFor(() => expect(count).toBe(1)) + + unsubscribe() + }) + }) + test('should be able to read latest data when re-subscribing (but not re-fetching)', async () => { const key = queryKey() let count = 0 @@ -429,6 +596,20 @@ describe('queryObserver', () => { expect(queryFn).toHaveBeenCalledTimes(0) }) + test('should not trigger a fetch when subscribed and disabled by callback', async () => { + const key = queryKey() + const queryFn = vi.fn, string>().mockReturnValue('data') + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + enabled: () => false, + }) + const unsubscribe = observer.subscribe(() => undefined) + await sleep(1) + unsubscribe() + expect(queryFn).toHaveBeenCalledTimes(0) + }) + test('should not trigger a fetch when not subscribed', async () => { const key = queryKey() const queryFn = vi.fn, string>().mockReturnValue('data') diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 2ccb91e201..fa2900d098 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -1,4 +1,10 @@ -import { ensureQueryFn, noop, replaceData, timeUntilStale } from './utils' +import { + ensureQueryFn, + noop, + replaceData, + resolveEnabled, + timeUntilStale, +} from './utils' import { notifyManager } from './notifyManager' import { canFetch, createRetryer, isCancelledError } from './retryer' import { Removable } from './removable' @@ -244,7 +250,9 @@ export class Query< } isActive(): boolean { - return this.observers.some((observer) => observer.options.enabled !== false) + return this.observers.some( + (observer) => resolveEnabled(observer.options.enabled, this) !== false, + ) } isDisabled(): boolean { diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index ef25d32e8d..0a73184241 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -3,6 +3,7 @@ import { isValidTimeout, noop, replaceData, + resolveEnabled, resolveStaleTime, shallowEqualObjects, timeUntilStale, @@ -149,9 +150,14 @@ export class QueryObserver< if ( this.options.enabled !== undefined && - typeof this.options.enabled !== 'boolean' + typeof this.options.enabled !== 'boolean' && + typeof this.options.enabled !== 'function' && + typeof resolveEnabled(this.options.enabled, this.#currentQuery) !== + 'boolean' ) { - throw new Error('Expected enabled to be a boolean') + throw new Error( + 'Expected enabled to be a boolean or a callback that returns a boolean', + ) } this.#updateQuery() @@ -190,7 +196,8 @@ export class QueryObserver< if ( mounted && (this.#currentQuery !== prevQuery || - this.options.enabled !== prevOptions.enabled || + resolveEnabled(this.options.enabled, this.#currentQuery) !== + resolveEnabled(prevOptions.enabled, this.#currentQuery) || resolveStaleTime(this.options.staleTime, this.#currentQuery) !== resolveStaleTime(prevOptions.staleTime, this.#currentQuery)) ) { @@ -203,7 +210,8 @@ export class QueryObserver< if ( mounted && (this.#currentQuery !== prevQuery || - this.options.enabled !== prevOptions.enabled || + resolveEnabled(this.options.enabled, this.#currentQuery) !== + resolveEnabled(prevOptions.enabled, this.#currentQuery) || nextRefetchInterval !== this.#currentRefetchInterval) ) { this.#updateRefetchInterval(nextRefetchInterval) @@ -377,7 +385,7 @@ export class QueryObserver< if ( isServer || - this.options.enabled === false || + resolveEnabled(this.options.enabled, this.#currentQuery) === false || !isValidTimeout(this.#currentRefetchInterval) || this.#currentRefetchInterval === 0 ) { @@ -692,7 +700,7 @@ function shouldLoadOnMount( options: QueryObserverOptions, ): boolean { return ( - options.enabled !== false && + resolveEnabled(options.enabled, query) !== false && query.state.data === undefined && !(query.state.status === 'error' && options.retryOnMount === false) ) @@ -716,7 +724,7 @@ function shouldFetchOn( (typeof options)['refetchOnWindowFocus'] & (typeof options)['refetchOnReconnect'], ) { - if (options.enabled !== false) { + if (resolveEnabled(options.enabled, query) !== false) { const value = typeof field === 'function' ? field(query) : field return value === 'always' || (value !== false && isStale(query, options)) @@ -731,7 +739,8 @@ function shouldFetchOptionally( prevOptions: QueryObserverOptions, ): boolean { return ( - (query !== prevQuery || prevOptions.enabled === false) && + (query !== prevQuery || + resolveEnabled(prevOptions.enabled, query) === false) && (!options.suspense || query.state.status !== 'error') && isStale(query, options) ) @@ -742,7 +751,7 @@ function isStale( options: QueryObserverOptions, ): boolean { return ( - options.enabled !== false && + resolveEnabled(options.enabled, query) !== false && query.isStaleByTime(resolveStaleTime(options.staleTime, query)) ) } diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 7b812a061f..75a130fd7e 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -54,6 +54,15 @@ export type StaleTime< TQueryKey extends QueryKey = QueryKey, > = number | ((query: Query) => number) +export type Enabled< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = + | boolean + | ((query: Query) => boolean) + export type QueryPersister< T = unknown, TQueryKey extends QueryKey = QueryKey, @@ -253,11 +262,12 @@ export interface QueryObserverOptions< 'queryKey' > { /** - * Set this to `false` to disable automatic refetching when the query mounts or changes query keys. + * Set this to `false` or a function that returns `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. + * Accepts a boolean or function that returns a boolean. * Defaults to `true`. */ - enabled?: boolean + enabled?: Enabled /** * The time in milliseconds after data is considered stale. * If set to `Infinity`, the data will never be considered stale. diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index 3cb9374e58..8b498ae1e4 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -1,5 +1,6 @@ import type { DefaultError, + Enabled, FetchStatus, MutationKey, MutationStatus, @@ -100,6 +101,18 @@ export function resolveStaleTime< return typeof staleTime === 'function' ? staleTime(query) : staleTime } +export function resolveEnabled< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + enabled: undefined | Enabled, + query: Query, +): boolean | undefined { + return typeof enabled === 'function' ? enabled(query) : enabled +} + export function matchQuery( filters: QueryFilters, query: Query,