Skip to content
Closed
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
4 changes: 2 additions & 2 deletions docs/src/pages/reference/QueryCache.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ Its available methods are:

- `onError?: (error: unknown, query: Query) => void`
- Optional
- This function will be called if some query encounters an error.
- This function will fire if some query encounters an error and will be passed the error.
- `onSuccess?: (data: unknown, query: Query) => void`
- Optional
- This function will be called if some query is successful.
- This function will fire any time the query successfully fetches new data or the cache is updated via `setQueryData`.

## Global callbacks

Expand Down
62 changes: 45 additions & 17 deletions src/core/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ interface SuccessAction<TData> {
dataUpdatedAt?: number
}

interface IgnoreAction {
type: 'ignore'
}

interface ErrorAction<TError> {
type: 'error'
error: TError
Expand Down Expand Up @@ -134,6 +138,7 @@ export type Action<TData, TError> =
| PauseAction
| SetStateAction<TData, TError>
| SuccessAction<TData>
| IgnoreAction

export interface SetStateOptions {
meta?: any
Expand Down Expand Up @@ -220,28 +225,37 @@ export class Query<
}

setData(
updater: Updater<TData | undefined, TData>,
updater: Updater<TData | undefined, TData | undefined>,
options?: SetDataOptions
): TData {
): TData | undefined {
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
} else if (this.options.structuralSharing !== false) {
// Structurally share data between prev and new data if needed
data = replaceEqualDeep(prevData, data)
if (typeof data !== 'undefined') {
// Use prev data if an isDataEqual function is defined and returns `true`
if (this.options.isDataEqual?.(prevData, data)) {
data = prevData
} else if (this.options.structuralSharing !== false) {
// Structurally share data between prev and new data if needed
data = replaceEqualDeep(prevData, data)
}
}

// Set data and mark it as cached
this.dispatch({
data,
type: 'success',
dataUpdatedAt: options?.updatedAt,
})
if (typeof data === 'undefined' || Object.is(data, prevData)) {
// Bail out
this.dispatch({
type: 'ignore',
})
} else {
// Set data and mark it as cached
this.dispatch({
data,
type: 'success',
dataUpdatedAt: options?.updatedAt,
})
}

return data
}
Expand Down Expand Up @@ -366,6 +380,8 @@ export class Query<
options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
fetchOptions?: FetchOptions
): Promise<TData> {
const prevData = this.state.data

if (this.state.isFetching) {
if (this.state.dataUpdatedAt && fetchOptions?.cancelRefetch) {
// Silently cancel current fetch if the user wants to cancel refetches
Expand Down Expand Up @@ -450,10 +466,12 @@ export class Query<
fn: context.fetchFn as () => TData,
abort: abortController?.abort?.bind(abortController),
onSuccess: data => {
this.setData(data as TData)
const updatedData = this.setData(data as TData)

// Notify cache callback
this.cache.config.onSuccess?.(data, this as Query<any, any, any, any>)
if (typeof updatedData !== 'undefined' && !Object.is(prevData, updatedData)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignore this if we go the route suggested in a later comment: #2905 (comment)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will add polyfill.

// Notify cache callback
this.cache.config.onSuccess?.(updatedData, this as Query<any, any, any, any>)
}

// Remove query after fetching if cache time is 0
if (this.cacheTime === 0) {
Expand Down Expand Up @@ -588,6 +606,16 @@ export class Query<
isPaused: false,
status: 'success',
}
case 'ignore':
return {
...state,
error: null,
fetchFailureCount: 0,
isFetching: false,
isInvalidated: false,
isPaused: false,
status: typeof state.data === 'undefined' ? 'idle' : 'success',
}
Comment on lines +609 to +618
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this please? Can we just not dispatch anything in case of bail-out and let the query be in that state as if setQueryData was never called?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a query function returns undefined or the same data, we need to reset state after bailing out. Otherwise the query will stay in fetching state.

case 'error':
const error = action.error as unknown

Expand Down
38 changes: 30 additions & 8 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 {
DefaultOptions,
Expand All @@ -29,7 +30,7 @@ import type {
ResetQueryFilters,
SetDataOptions,
} from './types'
import type { QueryState } from './query'
import type { Query, QueryState } from './query'
import { QueryCache } from './queryCache'
import { MutationCache } from './mutationCache'
import { focusManager } from './focusManager'
Expand Down Expand Up @@ -129,14 +130,35 @@ export class QueryClient {

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

if (query) {
const prevData = query.state.data
const updatedData = query.setData(updater, options)

if (typeof updatedData !== 'undefined' && !Object.is(prevData, updatedData)) {
// Notify cache callback
this.queryCache.config.onSuccess?.(updatedData, query as Query<any, any, any, any>)
}

return updatedData
}

const data = functionalUpdate(updater, undefined)

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

const parsedOptions = parseQueryArgs(queryKey)
const defaultedOptions = this.defaultQueryOptions(parsedOptions)
return this.queryCache
.build(this, defaultedOptions)
.setData(updater, options)
query = this.queryCache.build(this, defaultedOptions)
const updatedData = query.setData(data, options)
this.queryCache.config.onSuccess?.(updatedData, query as Query<any, any, any, any>)
return updatedData
}

setQueriesData<TData>(
Expand All @@ -153,9 +175,9 @@ export class QueryClient {

setQueriesData<TData>(
queryKeyOrFilters: QueryKey | QueryFilters,
updater: Updater<TData | undefined, TData>,
updater: Updater<TData | undefined, TData | undefined>,
options?: SetDataOptions
): [QueryKey, TData][] {
): [QueryKey, TData | undefined][] {
return notifyManager.batch(() =>
this.getQueryCache()
.findAll(queryKeyOrFilters)
Expand Down
10 changes: 5 additions & 5 deletions src/core/queryObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export class QueryObserver<
TQueryKey
>
private previousQueryResult?: QueryObserverResult<TData, TError>
private previousSelectError: Error | null
private previousSelectError: TError | null
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this change seems unrelated. Why is it necessary please?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are type errors in the latest version of Typescript. I can fix in separate PR if you prefer.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes lets please do this in a different branch, where we also update our TS version to the latest (4.5 as of a couple of minutes ago)

private staleTimeoutId?: number
private refetchIntervalId?: number
private currentRefetchInterval?: number | false
Expand Down Expand Up @@ -495,8 +495,8 @@ export class QueryObserver<
this.previousSelectError = null
} catch (selectError) {
getLogger().error(selectError)
error = selectError
this.previousSelectError = selectError
error = selectError as TError
this.previousSelectError = selectError as TError
errorUpdatedAt = Date.now()
status = 'error'
}
Expand Down Expand Up @@ -538,8 +538,8 @@ export class QueryObserver<
this.previousSelectError = null
} catch (selectError) {
getLogger().error(selectError)
error = selectError
this.previousSelectError = selectError
error = selectError as TError
this.previousSelectError = selectError as TError
errorUpdatedAt = Date.now()
status = 'error'
}
Expand Down
18 changes: 18 additions & 0 deletions src/core/tests/query.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -646,4 +646,22 @@ describe('query', () => {
})
)
})

test('does not update data if data is undefined', async () => {
const key = queryKey()
const value = {value: 'data'}
queryClient.setQueryData(key, value)
const query = queryCache.find(key)!
query.setData(undefined)
expect(query.state.data).toBe(value)
})

test('does not update data if data is the same as previous data', async () => {
const key = queryKey()
const value = {value: 'data'}
queryClient.setQueryData(key, value)
const query = queryCache.find(key)!
query.setData({value: 'data'})
expect(query.state.data).toBe(value)
})
})
35 changes: 35 additions & 0 deletions src/core/tests/queryCache.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,5 +157,40 @@ describe('queryCache', () => {
const query = testCache.find(key)
expect(onSuccess).toHaveBeenCalledWith({ data: 5 }, query)
})

test('should not be called when a query bails out', async () => {
const consoleMock = mockConsoleError()
const key = queryKey()
const onSuccess = jest.fn()
const testCache = new QueryCache({ onSuccess })
const testClient = new QueryClient({ queryCache: testCache })
await testClient.prefetchQuery(key, () => Promise.resolve(undefined))
consoleMock.mockRestore()
expect(onSuccess).not.toHaveBeenCalled()
})

test('should be called when data has changed', async () => {
const consoleMock = mockConsoleError()
const key = queryKey()
const onSuccess = jest.fn()
const testCache = new QueryCache({ onSuccess })
const testClient = new QueryClient({ queryCache: testCache })
await testClient.prefetchQuery(key, () => Promise.resolve({ data: 5 }))
testClient.setQueryData(key, { data: 10 })
consoleMock.mockRestore()
expect(onSuccess).toHaveBeenCalledTimes(2)
})

test('should not be called when data has not changed', async () => {
const consoleMock = mockConsoleError()
const key = queryKey()
const onSuccess = jest.fn()
const testCache = new QueryCache({ onSuccess })
const testClient = new QueryClient({ queryCache: testCache })
await testClient.prefetchQuery(key, () => Promise.resolve({ data: 5 }))
testClient.setQueryData(key, { data: 5 })
consoleMock.mockRestore()
expect(onSuccess).toHaveBeenCalledTimes(1)
})
})
})
Loading