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

Bail out if query data undefined #3271

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/src/pages/guides/migrating-to-react-query-4.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,21 @@ Types now require using TypeScript v4.1 or greater
Starting with v4, react-query will no longer log errors (e.g. failed fetches) to the console in production mode, as this was confusing to many.
Errors will still show up in development mode.

### Undefined is an illegale cache value for successful queries

In order to make bailing out of updates possible by returning `undefined`, we had to make `undefined` an illegal cache value. This is in-line with other concepts of react-query, for example, returning `undefined` from the [initialData function](guides/initial-query-data#initial-data-function) will also _not_ set data.

Further, it is an easy bug to produce `Promise<void>` by adding logging in the queryFn:

```js
useQuery(
['key'],
() => axios.get(url).then(result => console.log(result.data))
)
```

This is now disallowed on type level; at runtime, `undefined` will be transformed to a _failed Promise_, which means you will get an `error`, which will also be logged to the console in development mode.

## New Features 🚀

### Proper offline support
Expand All @@ -265,3 +280,14 @@ Mutations can now also be garbage collected automatically, just like queries. Th
### Tracked Queries per default

React Query defaults to "tracking" query properties, which should give you a nice boost in render optimization. The feature has existed since [v3.6.0](https://github.com/tannerlinsley/react-query/releases/tag/v3.6.0) and has now become the default behavior with v4.

### Bailing out of updates with setQueryData

When using the [functional updater form of setQueryData](../reference/QueryClient#queryclientsetquerydata), you can now bail out of the update by returning `undefined`. This is helpful if `undefined` is given to you as `previousValue`, which means that currently, no cached entry exists and you don't want to / cannot create one, like in the example of toggling a todo:

```js
queryClient.setQueryData(
['todo', id],
(previousTodo) => previousTodo ? { ...previousTodo, done: true } : undefined
)
```
6 changes: 5 additions & 1 deletion docs/src/pages/reference/QueryClient.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ queryClient.setQueryData(queryKey, updater)
**Options**

- `queryKey: QueryKey`: [Query Keys](../guides/query-keys)
- `updater: TData | (oldData: TData | undefined) => TData`
- `updater: TData | (oldData: TData | undefined) => TData | undefined`
- 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.

Expand All @@ -224,6 +224,8 @@ queryClient.setQueryData(queryKey, updater)
setQueryData(queryKey, newData)
```

If the value is `undefined`, the query data is not updated.

**Using an updater function**

For convenience in syntax, you can also pass an updater function which receives the current data value and returns the new one:
Expand All @@ -232,6 +234,8 @@ For convenience in syntax, you can also pass an updater function which receives
setQueryData(queryKey, oldData => newData)
```

If the updater function returns `undefined`, the query data will not be updated. If the updater function receives `undefined` as input, you can return `undefined` to bail out of the update and thus _not_ create a new cache entry.

## `queryClient.getQueryState`

`getQueryState` is a synchronous function that can be used to get an existing query's state. If the query does not exist, `undefined` will be returned.
Expand Down
4 changes: 2 additions & 2 deletions docs/src/pages/reference/useQuery.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const result = useQuery({

**Options**

- `queryKey: unknown[]`
- `queryKey: unknown[]`
- **Required**
- The query key to use for this query.
- The query key will be hashed into a stable hash. See [Query Keys](../guides/query-keys) for more information.
Expand All @@ -77,7 +77,7 @@ const result = useQuery({
- **Required, but only if no default query function has been defined** See [Default Query Function](../guides/default-query-function) for more information.
- The function that the query will use to request data.
- Receives a [QueryFunctionContext](../guides/query-functions#queryfunctioncontext)
- Must return a promise that will either resolve data or throw an error.
- Must return a promise that will either resolve data or throw an error. The data cannot be `undefined`.
- `enabled: boolean`
- Set this to `false` to disable this query from automatically running.
- Can be used for [Dependent Queries](../guides/dependent-queries).
Expand Down
62 changes: 32 additions & 30 deletions src/core/query.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import {
getAbortController,
Updater,
functionalUpdate,
noop,
replaceEqualDeep,
timeUntilStale,
Expand Down Expand Up @@ -195,14 +193,11 @@ export class Query<
}

setData(
updater: Updater<TData | undefined, TData>,
data: TData,
options?: SetDataOptions & { notifySuccess: boolean }
): TData {
const prevData = this.state.data

// Get the new data
let data = functionalUpdate(updater, prevData)

// Use prev data if an isDataEqual function is defined and returns `true`
if (this.options.isDataEqual?.(prevData, data)) {
data = prevData as TData
Expand Down Expand Up @@ -438,11 +433,41 @@ export class Query<
this.dispatch({ type: 'fetch', meta: context.fetchOptions?.meta })
}

const onError = (error: TError | { silent?: boolean }) => {
// Optimistically update state if needed
if (!(isCancelledError(error) && error.silent)) {
this.dispatch({
type: 'error',
error: error as TError,
})
}

if (!isCancelledError(error)) {
// Notify cache callback
this.cache.config.onError?.(error, this as Query<any, any, any, any>)

if (process.env.NODE_ENV !== 'production') {
getLogger().error(error)
}
}

if (!this.isFetchingOptimistic) {
// Schedule query gc after fetching
this.scheduleGc()
}
this.isFetchingOptimistic = false
}

// Try to fetch the data
this.retryer = createRetryer({
fn: context.fetchFn as () => TData,
abort: abortController?.abort?.bind(abortController),
onSuccess: data => {
if (typeof data === 'undefined') {
onError(new Error('Query data cannot be undefined') as any)
return
}

this.setData(data as TData)

// Notify cache callback
Expand All @@ -454,30 +479,7 @@ export class Query<
}
this.isFetchingOptimistic = false
},
onError: (error: TError | { silent?: boolean }) => {
// Optimistically update state if needed
if (!(isCancelledError(error) && error.silent)) {
this.dispatch({
type: 'error',
error: error as TError,
})
}

if (!isCancelledError(error)) {
// Notify cache callback
this.cache.config.onError?.(error, this as Query<any, any, any, any>)

if (process.env.NODE_ENV !== 'production') {
getLogger().error(error)
}
}

if (!this.isFetchingOptimistic) {
// Schedule query gc after fetching
this.scheduleGc()
}
this.isFetchingOptimistic = false
},
onError,
onFail: () => {
this.dispatch({ type: 'failed' })
},
Expand Down
17 changes: 13 additions & 4 deletions src/core/queryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
partialMatchKey,
hashQueryKeyByOptions,
MutationFilters,
functionalUpdate,
} from './utils'
import type {
QueryClientConfig,
Expand Down Expand Up @@ -125,14 +126,22 @@ export class QueryClient {

setQueryData<TData>(
queryKey: QueryKey,
updater: Updater<TData | undefined, TData>,
updater: Updater<TData | undefined, TData> | undefined,
options?: SetDataOptions
): TData {
): TData | undefined {
const query = this.queryCache.find<TData>(queryKey)
const prevData = query?.state.data
const data = functionalUpdate(updater, prevData)

if (typeof data === 'undefined') {
return undefined
}

const parsedOptions = parseQueryArgs(queryKey)
const defaultedOptions = this.defaultQueryOptions(parsedOptions)
return this.queryCache
.build(this, defaultedOptions)
.setData(updater, { ...options, notifySuccess: false })
.setData(data, { ...options, notifySuccess: false })
}

setQueriesData<TData>(
Expand All @@ -151,7 +160,7 @@ export class QueryClient {
queryKeyOrFilters: QueryKey | QueryFilters,
updater: Updater<TData | undefined, TData>,
options?: SetDataOptions
): [QueryKey, TData][] {
): [QueryKey, TData | undefined][] {
return notifyManager.batch(() =>
this.getQueryCache()
.findAll(queryKeyOrFilters)
Expand Down
27 changes: 27 additions & 0 deletions src/core/tests/query.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
isError,
onlineManager,
QueryFunctionContext,
QueryObserverResult,
} from '../..'
import { waitFor } from '@testing-library/react'

Expand Down Expand Up @@ -787,6 +788,7 @@ describe('query', () => {
let signalTest: any
await queryClient.prefetchQuery(key, ({ signal }) => {
signalTest = signal
return 'data'
})

expect(signalTest).toBeUndefined()
Expand Down Expand Up @@ -814,6 +816,31 @@ describe('query', () => {
consoleMock.mockRestore()
})

test('fetch should dispatch an error if the queryFn returns undefined', async () => {
const key = queryKey()

const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: (() => undefined) as any,
retry: false,
})

let observerResult: QueryObserverResult<unknown, unknown> | undefined

const unsubscribe = observer.subscribe(result => {
observerResult = result
})

await sleep(10)

expect(observerResult).toMatchObject({
isError: true,
error: new Error('Query data cannot be undefined'),
})

unsubscribe()
})

test('fetch should dispatch fetch if is fetching and current promise is undefined', async () => {
const key = queryKey()

Expand Down
Loading