Skip to content

Commit

Permalink
feat: add placeholderData to queryObserver (#1161)
Browse files Browse the repository at this point in the history
* feat: add placeholderData to queryObserver

* Add docs and a few tests (one failing)

* Add isPlaceholderData

* Update api.md
  • Loading branch information
tannerlinsley committed Oct 21, 2020
1 parent 6e30aaa commit 3fce2ec
Show file tree
Hide file tree
Showing 7 changed files with 130 additions and 18 deletions.
9 changes: 9 additions & 0 deletions docs/src/pages/docs/api.md
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
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
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
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
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
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
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',
},
])
})
})

1 comment on commit 3fce2ec

@vercel
Copy link

@vercel vercel bot commented on 3fce2ec Oct 21, 2020

Choose a reason for hiding this comment

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

Please sign in to comment.