Skip to content

Commit

Permalink
feat(core): Add possibility to pass a callback to enabled. (#7566)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
Co-authored-by: John Pettersson <[email protected]>
  • Loading branch information
3 people committed Jun 25, 2024
1 parent 461f3af commit 31b9ab4
Show file tree
Hide file tree
Showing 8 changed files with 267 additions and 15 deletions.
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,
})
}
```
2 changes: 1 addition & 1 deletion docs/framework/react/reference/useQuery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
181 changes: 181 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', () => {
let observer: QueryObserver<string, Error, string, string, Array<string>>
let enabled: boolean
let count: number
let key: Array<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', () => {
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
Expand Down Expand Up @@ -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<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
14 changes: 12 additions & 2 deletions 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 @@ -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<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

0 comments on commit 31b9ab4

Please sign in to comment.