Skip to content
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
5 changes: 5 additions & 0 deletions docs/src/pages/docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const {
} = useQuery(queryKey, queryFn?, {
cacheTime,
enabled,
forceFetchOnMount,
initialData,
initialStale,
isDataEqual,
Expand Down Expand Up @@ -128,6 +129,10 @@ const queryInfo = useQuery({
- Optional
- Defaults to `false`
- If set, any previous `data` will be kept when fetching new data because the query key changed.
- `forceFetchOnMount: Boolean`
- Optional
- Defaults to `false`
- Set this to `true` to always fetch when the component mounts (regardless of staleness).
- `refetchOnMount: Boolean`
- Optional
- Defaults to `true`
Expand Down
2 changes: 1 addition & 1 deletion src/core/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export class Query<TResult, TError> {
this.state = queryReducer(this.state, action)

this.observers.forEach(observer => {
observer.onQueryUpdate(this.state, action)
observer.onQueryUpdate(action)
})

this.notifyGlobalListeners(this)
Expand Down
84 changes: 41 additions & 43 deletions src/core/queryObserver.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import { getStatusProps, isServer, isDocumentVisible } from './utils'
import type { QueryResult, QueryObserverConfig } from './types'
import type {
Query,
QueryState,
Action,
FetchMoreOptions,
RefetchOptions,
} from './query'
import type { Query, Action, FetchMoreOptions, RefetchOptions } from './query'
import type { QueryCache } from './queryCache'

export type UpdateListener<TResult, TError> = (
Expand All @@ -19,7 +13,7 @@ export class QueryObserver<TResult, TError> {
private queryCache: QueryCache
private currentQuery!: Query<TResult, TError>
private currentResult!: QueryResult<TResult, TError>
private previousResult?: QueryResult<TResult, TError>
private previousQueryResult?: QueryResult<TResult, TError>
private updateListener?: UpdateListener<TResult, TError>
private staleTimeoutId?: number
private refetchIntervalId?: number
Expand All @@ -42,7 +36,13 @@ export class QueryObserver<TResult, TError> {
this.started = true
this.updateListener = listener
this.currentQuery.subscribeObserver(this)
this.optionalFetch()

if (this.config.enabled && this.config.forceFetchOnMount) {
this.fetch()
} else {
this.optionalFetch()
}

this.updateTimers()
return this.unsubscribe.bind(this)
}
Expand Down Expand Up @@ -141,11 +141,15 @@ export class QueryObserver<TResult, TError> {
private updateIsStale(): void {
const isStale = this.currentQuery.isStaleByTime(this.config.staleTime)
if (isStale !== this.currentResult.isStale) {
this.currentResult = this.createResult()
this.updateListener?.(this.currentResult)
this.updateResult()
this.notify()
}
}

private notify(): void {
this.updateListener?.(this.currentResult)
}

private updateStaleTimeout(): void {
if (isServer) {
return
Expand Down Expand Up @@ -216,39 +220,39 @@ export class QueryObserver<TResult, TError> {
}
}

private createResult(): QueryResult<TResult, TError> {
const { currentQuery, currentResult, previousResult, config } = this
private updateResult(): void {
const { currentQuery, currentResult, previousQueryResult, config } = this
const { state } = currentQuery
let { data, status, updatedAt } = state

// Keep previous data if needed
if (
config.keepPreviousData &&
state.isLoading &&
previousResult?.isSuccess
previousQueryResult?.isSuccess
) {
data = previousResult.data
updatedAt = previousResult.updatedAt
status = previousResult.status
data = previousQueryResult.data
updatedAt = previousQueryResult.updatedAt
status = previousQueryResult.status
}

let isStale = false

// When the query has not been fetched yet and this is the initial render,
// determine the staleness based on the initialStale or existence of initial data.
if (!currentResult && !currentQuery.state.isFetched) {
if (!currentResult && !state.isFetched) {
if (typeof config.initialStale === 'function') {
isStale = config.initialStale()
} else if (typeof config.initialStale === 'boolean') {
isStale = config.initialStale
} else {
isStale = typeof currentQuery.state.data === 'undefined'
isStale = typeof state.data === 'undefined'
}
} else {
isStale = currentQuery.isStaleByTime(config.staleTime)
}

return {
this.currentResult = {
...getStatusProps(status),
canFetchMore: state.canFetchMore,
clear: this.clear,
Expand Down Expand Up @@ -282,9 +286,9 @@ export class QueryObserver<TResult, TError> {
return false
}

this.previousResult = this.currentResult
this.previousQueryResult = this.currentResult
this.currentQuery = newQuery
this.currentResult = this.createResult()
this.updateResult()

if (this.started) {
prevQuery?.unsubscribeObserver(this)
Expand All @@ -294,39 +298,33 @@ export class QueryObserver<TResult, TError> {
return true
}

onQueryUpdate(
_state: QueryState<TResult, TError>,
action: Action<TResult, TError>
): void {
const { config } = this

onQueryUpdate(action: Action<TResult, TError>): void {
// Store current result and get new result
const prevResult = this.currentResult
this.currentResult = this.createResult()
const result = this.currentResult
this.updateResult()

const { currentResult, config } = this

// We need to check the action because the state could have
// transitioned from success to success in case of `setQueryData`.
if (action.type === 'Success' && result.isSuccess) {
config.onSuccess?.(result.data!)
config.onSettled?.(result.data!, null)
if (action.type === 'Success' && currentResult.isSuccess) {
config.onSuccess?.(currentResult.data!)
config.onSettled?.(currentResult.data!, null)
this.updateTimers()
} else if (action.type === 'Error' && result.isError) {
config.onError?.(result.error!)
config.onSettled?.(undefined, result.error!)
} else if (action.type === 'Error' && currentResult.isError) {
config.onError?.(currentResult.error!)
config.onSettled?.(undefined, currentResult.error!)
this.updateTimers()
}

// Decide if we need to notify the listener
const notify =
if (
// Always notify on data or error change
result.data !== prevResult.data ||
result.error !== prevResult.error ||
currentResult.data !== prevResult.data ||
currentResult.error !== prevResult.error ||
// Maybe notify on other changes
config.notifyOnStatusChange

if (notify) {
this.updateListener?.(result)
) {
this.notify()
}
}
}
5 changes: 5 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ export interface QueryObserverConfig<
* Defaults to `true`.
*/
refetchOnMount?: boolean
/**
* Set this to `true` to always fetch when the component mounts (regardless of staleness).
* Defaults to `false`.
*/
forceFetchOnMount?: boolean
/**
* Whether a change to the query status should re-render a component.
* If set to `false`, the component will only re-render when the actual `data` or `error` changes.
Expand Down
74 changes: 73 additions & 1 deletion src/react/tests/useQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ describe('useQuery', () => {
return null
})

it.only('should update disabled query when updated with invalidateQueries', async () => {
it('should update disabled query when updated with invalidateQueries', async () => {
const key = queryKey()
const states: QueryResult<number>[] = []
let count = 0
Expand Down Expand Up @@ -843,6 +843,78 @@ describe('useQuery', () => {
consoleMock.mockRestore()
})

it('should always fetch if forceFetchOnMount is set', async () => {
const key = queryKey()
const states: QueryResult<string>[] = []

await queryCache.prefetchQuery(key, () => 'prefetched')

function Page() {
const state = useQuery(key, () => 'data', {
forceFetchOnMount: true,
staleTime: 100,
})
states.push(state)
return null
}

render(<Page />)

await waitFor(() => expect(states.length).toBe(3))

expect(states).toMatchObject([
{ data: 'prefetched', isStale: false, isFetching: false },
{ data: 'prefetched', isStale: false, isFetching: true },
{ data: 'data', isStale: false, isFetching: false },
])
})

it('should not fetch if initial data is set', async () => {
const key = queryKey()
const states: QueryResult<string>[] = []

function Page() {
const state = useQuery(key, () => 'data', {
initialData: 'initial',
})
states.push(state)
return null
}

render(<Page />)

await waitFor(() => expect(states.length).toBe(2))

expect(states).toMatchObject([
{ data: 'initial', isStale: false },
{ data: 'initial', isStale: true },
])
})

it('should fetch if initial data is set and initial stale is set to true', async () => {
const key = queryKey()
const states: QueryResult<string>[] = []

function Page() {
const state = useQuery(key, () => 'data', {
initialData: 'initial',
initialStale: true,
})
states.push(state)
return null
}

render(<Page />)

await waitFor(() => expect(states.length).toBe(3))

expect(states).toMatchObject([
{ data: 'initial', isStale: true, isFetching: false },
{ data: 'initial', isStale: true, isFetching: true },
{ data: 'data', isStale: true, isFetching: false },
])
})

it('should keep initial stale and initial data when the query key changes', async () => {
const key = queryKey()
const states: QueryResult<{ count: number }>[] = []
Expand Down