Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add possibility to pass a callback to enabled. #7566

Merged
merged 12 commits into from
Jun 25, 2024
2 changes: 1 addition & 1 deletion docs/framework/react/guides/disabling-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
31 changes: 31 additions & 0 deletions docs/framework/react/react-native.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,34 @@ function MyComponent() {
return <Text>DataUpdatedAt: {dataUpdatedAt}</Text>
}
```

## 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,
})
}
```
195 changes: 195 additions & 0 deletions packages/query-core/src/__tests__/queryObserver.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,173 @@ describe('queryObserver', () => {
unsubscribe()
})

describe('enabled is a callback that initially returns false and', () => {
TkDodo marked this conversation as resolved.
Show resolved Hide resolved
let observer: QueryObserver<string, Error, string, string, string[]>
let enabled: boolean
let count: number
let key: string[]

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', () => {
let 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 refetched when invalidated with refetchType: all', async () => {
let 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 () => {
let 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 unsubcribed, then enabled returns true, and then re-suscribed', 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 refetched if not subscribed to after enabled was toggled to true', async () => {
let 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 refetched if not subscribed to after enabled was toggled to true', async () => {
let 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 () => {
let 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
Expand Down Expand Up @@ -429,6 +596,34 @@ 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<Array<unknown>, 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 subscribed and disabled by callback', async () => {
const key = queryKey()
const queryFn = vi.fn<Array<unknown>, 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<Array<unknown>, string>().mockReturnValue('data')
Expand Down
12 changes: 10 additions & 2 deletions packages/query-core/src/query.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 18 additions & 9 deletions packages/query-core/src/queryObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
isValidTimeout,
noop,
replaceData,
resolveEnabled,
resolveStaleTime,
shallowEqualObjects,
timeUntilStale,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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))
) {
Expand All @@ -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)
Expand Down Expand Up @@ -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
) {
Expand Down Expand Up @@ -692,7 +700,7 @@ function shouldLoadOnMount(
options: QueryObserverOptions<any, any, any, any>,
): boolean {
return (
options.enabled !== false &&
resolveEnabled(options.enabled, query) !== false &&
query.state.data === undefined &&
!(query.state.status === 'error' && options.retryOnMount === false)
)
Expand All @@ -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))
Expand All @@ -731,7 +739,8 @@ function shouldFetchOptionally(
prevOptions: QueryObserverOptions<any, any, any, any, any>,
): boolean {
return (
(query !== prevQuery || prevOptions.enabled === false) &&
(query !== prevQuery ||
resolveEnabled(prevOptions.enabled, query) === false) &&
(!options.suspense || query.state.status !== 'error') &&
isStale(query, options)
)
Expand All @@ -742,7 +751,7 @@ function isStale(
options: QueryObserverOptions<any, any, any, any, any>,
): boolean {
return (
options.enabled !== false &&
resolveEnabled(options.enabled, query) !== false &&
query.isStaleByTime(resolveStaleTime(options.staleTime, query))
)
}
Expand Down
12 changes: 11 additions & 1 deletion packages/query-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ export type StaleTime<
TQueryKey extends QueryKey = QueryKey,
> = number | ((query: Query<TQueryFnData, TError, TData, TQueryKey>) => number)

export type Enabled<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> =
| boolean
| ((query: Query<TQueryFnData, TError, TData, TQueryKey>) => boolean)

export type QueryPersister<
T = unknown,
TQueryKey extends QueryKey = QueryKey,
Expand Down Expand Up @@ -255,9 +264,10 @@ export interface QueryObserverOptions<
/**
* 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.
* You can also pass a callback that returns a boolean to check this condition dynamically.
* Defaults to `true`.
*/
enabled?: boolean
enabled?: Enabled<TQueryFnData, TError, TQueryData, TQueryKey>
/**
* The time in milliseconds after data is considered stale.
* If set to `Infinity`, the data will never be considered stale.
Expand Down
Loading
Loading