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
9 changes: 9 additions & 0 deletions docs/src/pages/docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const {
enabled,
initialData,
initialStale,
placeholderData,
isDataEqual,
keepPreviousData,
notifyOnStatusChange,
Expand Down Expand Up @@ -137,6 +138,12 @@ const queryInfo = useQuery({
- Optional
- If set, this will mark any `initialData` provided as stale and will likely cause it to be refetched on mount
- If a function is passed, it will be called only when appropriate to resolve the `initialStale` value. This can be useful if your `initialStale` value is costly to calculate.
- `initialData` **is persisted** to the cache
- `placeholderData: any | Function() => any`
- Optional
- If set, this value will be used as the placeholder data for this particular query instance while the query is still in the `loading` data and no initialData has been provided.
- If set to a function, the function will be called **once** during the shared/root query initialization, and be expected to synchronously return the initialData
- `placeholderData` is **not persisted** to the cache
- `keepPreviousData: Boolean`
- Optional
- Defaults to `false`
Expand Down Expand Up @@ -176,6 +183,8 @@ const queryInfo = useQuery({
- Will be `true` if the cache data is stale.
- `isPreviousData: Boolean`
- Will be `true` when `keepPreviousData` is set and data from the previous query is returned.
- `isPlaceholderData: Boolean`
- Will be `true` if and when the query's `data` is equal to the result of the `placeholderData` option.
- `isFetchedAfterMount: Boolean`
- Will be `true` if the query has been fetched after the component mounted.
- This property can be used to not show any previously cached data.
Expand Down
23 changes: 22 additions & 1 deletion src/core/queryObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import {
noop,
} from './utils'
import { notifyManager } from './notifyManager'
import type { QueryConfig, QueryResult, ResolvedQueryConfig } from './types'
import type {
QueryConfig,
QueryResult,
ResolvedQueryConfig,
PlaceholderDataFunction,
} from './types'
import { QueryStatus } from './types'
import type { Query, Action, FetchMoreOptions, RefetchOptions } from './query'
import { DEFAULT_CONFIG, isResolvedQueryConfig } from './config'

Expand Down Expand Up @@ -243,6 +249,7 @@ export class QueryObserver<TResult, TError> {
const { state } = this.currentQuery
let { data, status, updatedAt } = state
let isPreviousData = false
let isPlaceholderData = false

// Keep previous data if needed
if (
Expand All @@ -256,6 +263,19 @@ export class QueryObserver<TResult, TError> {
isPreviousData = true
}

if (status === 'loading' && this.config.placeholderData) {
const placeholderData =
typeof this.config.placeholderData === 'function'
? (this.config.placeholderData as PlaceholderDataFunction<TResult>)()
: this.config.placeholderData

if (typeof placeholderData !== 'undefined') {
status = QueryStatus.Success
data = placeholderData
isPlaceholderData = true
}
}

this.currentResult = {
...getStatusProps(status),
canFetchMore: state.canFetchMore,
Expand All @@ -270,6 +290,7 @@ export class QueryObserver<TResult, TError> {
isFetchingMore: state.isFetchingMore,
isInitialData: state.isInitialData,
isPreviousData,
isPlaceholderData,
isStale: this.isStale,
refetch: this.refetch,
remove: this.remove,
Expand Down
29 changes: 29 additions & 0 deletions src/core/tests/queryCache.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -847,4 +847,33 @@ describe('queryCache', () => {
consoleMock.mockRestore()
})
})

describe('QueryObserver', () => {
test('uses placeholderData as non-cache data when loading a query with no data', async () => {
const key = queryKey()
const cache = new QueryCache()
const observer = cache.watchQuery(key, { placeholderData: 'placeholder' })

expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
data: 'placeholder',
})

const results: QueryResult<unknown>[] = []

observer.subscribe(x => {
results.push(x)
})

await cache.fetchQuery(key, async () => {
await sleep(100)
return 'data'
})

expect(results[0].data).toBe('data')

observer.unsubscribe()
cache.clear()
})
})
})
3 changes: 3 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type TypedQueryFunction<
export type TypedQueryFunctionArgs = readonly [unknown, ...unknown[]]

export type InitialDataFunction<TResult> = () => TResult | undefined
export type PlaceholderDataFunction<TResult> = () => TResult | undefined

export type InitialStaleFunction = () => boolean

Expand All @@ -49,6 +50,7 @@ export interface BaseQueryConfig<TResult, TError = unknown, TData = TResult> {
queryKeySerializerFn?: QueryKeySerializerFunction
queryFnParamsFilter?: (args: ArrayQueryKey) => ArrayQueryKey
initialData?: TResult | InitialDataFunction<TResult>
placeholderData?: TResult | InitialDataFunction<TResult>
infinite?: true
/**
* Set this to `false` to disable structural sharing between query results.
Expand Down Expand Up @@ -204,6 +206,7 @@ export interface QueryResultBase<TResult, TError = unknown> {
isInitialData: boolean
isLoading: boolean
isPreviousData: boolean
isPlaceholderData: boolean
isStale: boolean
isSuccess: boolean
refetch: (options?: RefetchOptions) => Promise<TResult | undefined>
Expand Down
40 changes: 23 additions & 17 deletions src/react/tests/useInfiniteQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ const initialItems = (page: number): Result => {
}
}

const fetchItems = async (page: number, ts: number, nextId?: any): Promise<Result> => {
const fetchItems = async (
page: number,
ts: number,
nextId?: any
): Promise<Result> => {
await sleep(10)
return {
items: [...new Array(10)].fill(null).map((_, d) => page * pageSize + d),
Expand Down Expand Up @@ -74,6 +78,7 @@ describe('useInfiniteQuery', () => {
isInitialData: true,
isLoading: true,
isPreviousData: false,
isPlaceholderData: false,
isStale: true,
isSuccess: false,
refetch: expect.any(Function),
Expand Down Expand Up @@ -104,6 +109,7 @@ describe('useInfiniteQuery', () => {
isInitialData: false,
isLoading: false,
isPreviousData: false,
isPlaceholderData: false,
isStale: true,
isSuccess: true,
refetch: expect.any(Function),
Expand Down Expand Up @@ -1067,7 +1073,7 @@ describe('useInfiniteQuery', () => {
it('should compute canFetchMore correctly for falsy getFetchMore return value on refetching', async () => {
const key = queryKey()
const MAX = 2

function Page() {
const fetchCountRef = React.useRef(0)
const [isRemovedLastPage, setIsRemovedLastPage] = React.useState<boolean>(
Expand Down Expand Up @@ -1096,7 +1102,7 @@ describe('useInfiniteQuery', () => {
getFetchMore: (lastGroup, _allGroups) => lastGroup.nextId,
}
)

return (
<div>
<h1>Pagination</h1>
Expand Down Expand Up @@ -1145,55 +1151,55 @@ describe('useInfiniteQuery', () => {
</div>
)
}

const rendered = render(<Page />)

rendered.getByText('Loading...')

await waitFor(() => {
rendered.getByText('Item: 9')
rendered.getByText('Page 0: 0')
})

fireEvent.click(rendered.getByText('Load More'))

await waitFor(() => rendered.getByText('Loading more...'))

await waitFor(() => {
rendered.getByText('Item: 19')
rendered.getByText('Page 0: 0')
rendered.getByText('Page 1: 1')
})

fireEvent.click(rendered.getByText('Load More'))

await waitFor(() => rendered.getByText('Loading more...'))

await waitFor(() => {
rendered.getByText('Item: 29')
rendered.getByText('Page 0: 0')
rendered.getByText('Page 1: 1')
rendered.getByText('Page 2: 2')
})

rendered.getByText('Nothing more to load')

fireEvent.click(rendered.getByText('Remove Last Page'))

await waitForMs(10)

fireEvent.click(rendered.getByText('Refetch'))

await waitFor(() => rendered.getByText('Background Updating...'))

await waitFor(() => {
rendered.getByText('Page 0: 3')
rendered.getByText('Page 1: 4')
})

expect(rendered.queryByText('Item: 29')).toBeNull()
expect(rendered.queryByText('Page 2: 5')).toBeNull()

rendered.getByText('Nothing more to load')
})
})
2 changes: 2 additions & 0 deletions src/react/tests/usePaginatedQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ describe('usePaginatedQuery', () => {
isInitialData: true,
isLoading: true,
isPreviousData: false,
isPlaceholderData: false,
isStale: true,
isSuccess: false,
latestData: undefined,
Expand All @@ -70,6 +71,7 @@ describe('usePaginatedQuery', () => {
isInitialData: false,
isLoading: false,
isPreviousData: false,
isPlaceholderData: false,
isStale: true,
isSuccess: true,
latestData: 1,
Expand Down
42 changes: 42 additions & 0 deletions src/react/tests/useQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ describe('useQuery', () => {
isInitialData: true,
isLoading: true,
isPreviousData: false,
isPlaceholderData: false,
isStale: true,
isSuccess: false,
refetch: expect.any(Function),
Expand All @@ -160,6 +161,7 @@ describe('useQuery', () => {
isInitialData: false,
isLoading: false,
isPreviousData: false,
isPlaceholderData: false,
isStale: true,
isSuccess: true,
refetch: expect.any(Function),
Expand Down Expand Up @@ -214,6 +216,7 @@ describe('useQuery', () => {
isInitialData: true,
isLoading: true,
isPreviousData: false,
isPlaceholderData: false,
isStale: true,
isSuccess: false,
refetch: expect.any(Function),
Expand All @@ -238,6 +241,7 @@ describe('useQuery', () => {
isInitialData: true,
isLoading: true,
isPreviousData: false,
isPlaceholderData: false,
isStale: true,
isSuccess: false,
refetch: expect.any(Function),
Expand All @@ -262,6 +266,7 @@ describe('useQuery', () => {
isInitialData: true,
isLoading: false,
isPreviousData: false,
isPlaceholderData: false,
isStale: true,
isSuccess: false,
refetch: expect.any(Function),
Expand Down Expand Up @@ -2361,4 +2366,41 @@ describe('useQuery', () => {
await waitFor(() => rendered.getByText('data'))
expect(queryFn).toHaveBeenCalledTimes(1)
})

it('should use placeholder data while the query loads', async () => {
const key1 = queryKey()

const states: QueryResult<string>[] = []

function Page() {
const state = useQuery(key1, () => 'data', {
placeholderData: 'placeholder',
})

states.push(state)

return (
<div>
<h2>Data: {state.data}</h2>
<div>Status: {state.status}</div>
</div>
)
}

const rendered = render(<Page />)
await waitFor(() => rendered.getByText('Data: data'))

expect(states).toMatchObject([
{
isSuccess: true,
isPlaceholderData: true,
data: 'placeholder',
},
{
isSuccess: true,
isPlaceholderData: false,
data: 'data',
},
])
})
})