From 7c826d0fd1a0c0052858f166c14e0d74c45c9f24 Mon Sep 17 00:00:00 2001 From: Niek Date: Sun, 4 Oct 2020 20:59:04 +0200 Subject: [PATCH] feat: add bi-directional infinite query support --- docs/src/pages/guides/infinite-queries.md | 68 +- .../guides/migrating-to-react-query-3.md | 92 ++ docs/src/pages/reference/useInfiniteQuery.md | 42 +- examples/basic-graphql-request/src/index.js | 2 +- examples/basic/src/index.js | 2 +- examples/default-query-function/src/index.js | 2 +- .../load-more-infinite-scroll/pages/index.js | 28 +- src/core/query.ts | 241 +++-- src/core/queryObserver.ts | 31 +- src/core/types.ts | 40 +- src/react/tests/useInfiniteQuery.test.tsx | 911 +++++++++--------- src/react/tests/useQuery.test.tsx | 45 +- 12 files changed, 859 insertions(+), 645 deletions(-) diff --git a/docs/src/pages/guides/infinite-queries.md b/docs/src/pages/guides/infinite-queries.md index 15002bea4b..a341341c81 100644 --- a/docs/src/pages/guides/infinite-queries.md +++ b/docs/src/pages/guides/infinite-queries.md @@ -8,10 +8,11 @@ Rendering lists that can additively "load more" data onto an existing set of dat When using `useInfiniteQuery`, you'll notice a few things are different: - `data` is now an array of arrays that contain query group results, instead of the query results themselves -- A `fetchMore` function is now available -- A `getFetchMore` option is available for both determining if there is more data to load and the information to fetch it. This information is supplied as an additional parameter in the query function (which can optionally be overridden when calling the `fetchMore` function) -- A `canFetchMore` boolean is now available and is `true` if `getFetchMore` returns a truthy value -- An `isFetchingMore` boolean is now available to distinguish between a background refresh state and a loading more state +- The `fetchNextPage` and `fetchPreviousPage` functions are now available +- The `getNextPageParam` and `getPreviousPageParam` options are available for both determining if there is more data to load and the information to fetch it. This information is supplied as an additional parameter in the query function (which can optionally be overridden when calling the `fetchNextPage` or `fetchPreviousPage` functions) +- A `hasNextPage` boolean is now available and is `true` if `getNextPageParam` returns a value other than `undefined`. +- A `hasPreviousPage` boolean is now available and is `true` if `getPreviousPageParam` returns a value other than `undefined`. +- The `isFetchingNextPage` and `isFetchingPreviousPage` booleans are now available to distinguish between a background refresh state and a loading more state ## Example @@ -31,10 +32,10 @@ fetch('/api/projects?cursor=9') With this information, we can create a "Load More" UI by: - Waiting for `useInfiniteQuery` to request the first group of data by default -- Returning the information for the next query in `getFetchMore` -- Calling `fetchMore` function +- Returning the information for the next query in `getNextPageParam` +- Calling `fetchNextPage` function -> Note: It's very important you do not call `fetchMore` with arguments unless you want them to override the `fetchMoreInfo` data returned from the `getFetchMore` function. eg. Do not do this: ` -
{isFetching && !isFetchingMore ? 'Fetching...' : null}
+
{isFetching && !isFetchingNextPage ? 'Fetching...' : null}
) } @@ -91,7 +92,7 @@ When an infinite query becomes `stale` and needs to be refetched, each group is ## What if I need to pass custom information to my query function? -By default, the info returned from `getFetchMore` will be supplied to the query function, but in some cases, you may want to override this. You can pass custom variables to the `fetchMore` function which will override the default info like so: +By default, the variable returned from `getNextPageParam` will be supplied to the query function, but in some cases, you may want to override this. You can pass custom variables to the `fetchNextPage` function which will override the default variable like so: ```js function Projects() { @@ -102,24 +103,35 @@ function Projects() { status, data, isFetching, - isFetchingMore, - fetchMore, - canFetchMore, + isFetchingNextPage, + fetchNextPage, + hasNextPage, } = useInfiniteQuery('projects', fetchProjects, { - getFetchMore: (lastGroup, allGroups) => lastGroup.nextCursor, + getNextPageParam: (lastPage, pages) => lastPage.nextCursor, }) - // Pass your own custom fetchMoreInfo - const skipToCursor50 = () => fetchMore(50) + // Pass your own page param + const skipToCursor50 = () => fetchNextPage({ pageParam: 50 }) } ``` -## What if I want to infinitely load more data in reverse? +## What if I want to implement a bi-directional infinite list? -Sometimes you may not want to **append** infinitely loaded data, but instead **prepend** it. If this is case, you can use `fetchMore`'s `previous` option, eg. +Bi-directional lists can be implemented by using the `getPreviousPageParam`, `fetchPreviousPage`, `hasPreviousPage` and `isFetchingPreviousPage` properties and functions. ```js -fetchMore(previousPageVariables, { previous: true }) +useInfiniteQuery('projects', fetchProjects, { + getNextPageParam: (lastPage, pages) => lastPage.nextCursor, + getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor, +}) ``` -This will ensure the new data is prepended to the data array instead of appended. +## What if I want to show the pages in reversed order? + +Sometimes you may want to show the pages in reversed order. If this is case, you can use the `select` option: + +```js +useInfiniteQuery('projects', fetchProjects, { + select: pages => [...pages].reverse(), +}) +``` diff --git a/docs/src/pages/guides/migrating-to-react-query-3.md b/docs/src/pages/guides/migrating-to-react-query-3.md index 4890b6d899..69d4a0c005 100644 --- a/docs/src/pages/guides/migrating-to-react-query-3.md +++ b/docs/src/pages/guides/migrating-to-react-query-3.md @@ -81,6 +81,98 @@ function Page({ page }) { } ``` +### useInfiniteQuery() + +The `useInfiniteQuery()` interface has changed to fully support bi-directional infinite lists. + +One direction: + +```js +const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, +} = useInfiniteQuery('projects', fetchProjects, { + getNextPageParam: (lastPage, pages) => lastPage.nextCursor, +}) +``` + +Both directions: + +```js +const { + data, + fetchNextPage, + fetchPreviousPage, + hasNextPage, + hasPreviousPage, + isFetchingNextPage, + isFetchingPreviousPage, +} = useInfiniteQuery('projects', fetchProjects, { + getNextPageParam: (lastPage, pages) => lastPage.nextCursor, + getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor, +}) +``` + +One direction reversed: + +```js +const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, +} = useInfiniteQuery('projects', fetchProjects, { + select: pages => [...pages].reverse(), + getNextPageParam: (lastPage, pages) => lastPage.nextCursor, +}) +``` + +### useMutation() + +The `useMutation()` hook now returns an object instead of an array: + +```js +// Old: +const [mutate, { status, reset }] = useMutation() + +// New: +const { mutate, status, reset } = useMutation() +``` + +Previously the `mutate` function returned a promise which resolved to `undefined` if a mutation failed instead of throwing. +We got a lot of questions regarding this behavior as users expected the promise to behave like a regular promise. +Because of this the `mutate` function is now split into a `mutate` and `mutateAsync` function. + +The `mutate` function can be used when using callbacks: + +```js +const { mutate } = useMutation(addTodo) + +mutate('todo', { + onSuccess: data => { + console.log(data) + }, + onError: error => { + console.error(error) + }, +}) +``` + +The `mutateAsync` function can be used when using async/await: + +```js +const { mutateAsync } = useMutation(addTodo) + +try { + const data = await mutateAsync('todo') + console.log(data) +} catch (error) { + console.error(error) +} +``` + ### Query object syntax The object syntax has been collapsed: diff --git a/docs/src/pages/reference/useInfiniteQuery.md b/docs/src/pages/reference/useInfiniteQuery.md index 5d0941bf12..f257c0cb04 100644 --- a/docs/src/pages/reference/useInfiniteQuery.md +++ b/docs/src/pages/reference/useInfiniteQuery.md @@ -5,16 +5,20 @@ title: useInfiniteQuery ```js -const queryFn = (...queryKey, fetchMoreVariable) // => Promise +const queryFn = (...queryKey, pageParam) // => Promise const { - isFetchingMore, - fetchMore, - canFetchMore, + fetchNextPage, + fetchPreviousPage, + hasNextPage, + hasPreviousPage, + isFetchingNextPage, + isFetchingPreviousPage, ...result } = useInfiniteQuery(queryKey, queryFn, { ...options, - getFetchMore: (lastPage, allPages) => fetchMoreVariable + getNextPageParam: (lastPage, allPages) => lastPage.nextCursor, + getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor }) ``` @@ -22,18 +26,30 @@ const { The options for `useInfiniteQuery` are identical to the [`useQuery` hook](#usequery) with the addition of the following: -- `getFetchMore: (lastPage, allPages) => fetchMoreVariable | boolean` +- `getNextPageParam: (lastPage, allPages) => unknown | undefined` - When new data is received for this query, this function receives both the last page of the infinite list of data and the full array of all pages. - - It should return a **single variable** that will be passed as the last optional parameter to your query function + - It should return a **single variable** that will be passed as the last optional parameter to your query function. + - Return `undefined` to indicate there is no next page available. +- `getPreviousPageParam: (firstPage, allPages) => unknown | undefined` + - When new data is received for this query, this function receives both the first page of the infinite list of data and the full array of all pages. + - It should return a **single variable** that will be passed as the last optional parameter to your query function. + - Return `undefined` to indicate there is no previous page available. **Returns** The returned properties for `useInfiniteQuery` are identical to the [`useQuery` hook](#usequery), with the addition of the following: -- `isFetchingMore: false | 'next' | 'previous'` - - If using `paginated` mode, this will be `true` when fetching more results using the `fetchMore` function. -- `fetchMore: (fetchMoreVariableOverride) => Promise` +- `isFetchingNextPage: boolean` + - Will be `true` while fetching the next page with `fetchNextPage`. +- `isFetchingPreviousPage: boolean` + - Will be `true` while fetching the previous page with `fetchPreviousPage`. +- `fetchNextPage: (options?: FetchNextPageOptions) => Promise` - This function allows you to fetch the next "page" of results. - - `fetchMoreVariableOverride` allows you to optionally override the fetch more variable returned from your `getFetchMore` option to your query function to retrieve the next page of results. -- `canFetchMore: boolean` - - If using `paginated` mode, this will be `true` if there is more data to be fetched (known via the required `getFetchMore` option function). + - `options.pageParam: unknown` allows you to manually specify a page param instead of using `getNextPageParam`. +- `fetchPreviousPage: (options?: FetchPreviousPageOptions) => Promise` + - This function allows you to fetch the previous "page" of results. + - `options.pageParam: unknown` allows you to manually specify a page param instead of using `getPreviousPageParam`. +- `hasNextPage: boolean` + - This will be `true` if there is a next page to be fetched (known via the `getNextPageParam` option). +- `hasPreviousPage: boolean` + - This will be `true` if there is a previous page to be fetched (known via the `getPreviousPageParam` option). diff --git a/examples/basic-graphql-request/src/index.js b/examples/basic-graphql-request/src/index.js index fc7d37ffde..42311d85cd 100644 --- a/examples/basic-graphql-request/src/index.js +++ b/examples/basic-graphql-request/src/index.js @@ -126,7 +126,7 @@ function usePost(postId) { return post; }, { - enabled: postId, + enabled: !!postId, } ); } diff --git a/examples/basic/src/index.js b/examples/basic/src/index.js index acffc7bebf..a496d14268 100644 --- a/examples/basic/src/index.js +++ b/examples/basic/src/index.js @@ -101,7 +101,7 @@ const getPostById = async (key, id) => { function usePost(postId) { return useQuery(["post", postId], getPostById, { - enabled: postId, + enabled: !!postId, }); } diff --git a/examples/default-query-function/src/index.js b/examples/default-query-function/src/index.js index a5dc96576c..8139a3935f 100644 --- a/examples/default-query-function/src/index.js +++ b/examples/default-query-function/src/index.js @@ -105,7 +105,7 @@ function Posts({ setPostId }) { function Post({ postId, setPostId }) { // You can even leave out the queryFn and just go straight into options const { status, data, error, isFetching } = useQuery(`/posts/${postId}`, { - enabled: postId, + enabled: !!postId, }); return ( diff --git a/examples/load-more-infinite-scroll/pages/index.js b/examples/load-more-infinite-scroll/pages/index.js index 85919ac4b2..aa3e92d798 100755 --- a/examples/load-more-infinite-scroll/pages/index.js +++ b/examples/load-more-infinite-scroll/pages/index.js @@ -31,14 +31,14 @@ function Example() { data, error, isFetching, - isFetchingMore, - fetchMore, - canFetchMore, + isFetchingNextPage, + fetchNextPage, + hasNextPage, } = useInfiniteQuery( 'projects', - async (key, nextId = 0) => { - const { data } = await axios.get('/api/projects?cursor=' + nextId) - return data + async (_key, nextId = 0) => { + const res = await axios.get('/api/projects?cursor=' + nextId) + return res.data }, { getFetchMore: lastGroup => lastGroup.nextId, @@ -49,8 +49,8 @@ function Example() { useIntersectionObserver({ target: loadMoreButtonRef, - onIntersect: fetchMore, - enabled: canFetchMore, + onIntersect: fetchNextPage, + enabled: hasNextPage, }) return ( @@ -81,18 +81,20 @@ function Example() {
- {isFetching && !isFetchingMore ? 'Background Updating...' : null} + {isFetching && !isFetchingNextPage + ? 'Background Updating...' + : null}
)} diff --git a/src/core/query.ts b/src/core/query.ts index 99376eda22..3c8d3212fb 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -17,7 +17,6 @@ import { } from './utils' import type { InitialDataFunction, - IsFetchingMoreValue, QueryFunction, QueryKey, QueryOptions, @@ -38,15 +37,18 @@ export interface QueryConfig { } export interface QueryState { - canFetchMore?: boolean - data?: TData + data: TData | undefined dataUpdateCount: number error: TError | null errorUpdateCount: number failureCount: number + hasNextPage: boolean | undefined + hasPreviousPage: boolean | undefined isFetching: boolean - isFetchingMore: IsFetchingMoreValue + isFetchingNextPage: boolean + isFetchingPreviousPage: boolean isInvalidated: boolean + pageParams: unknown[] | undefined status: QueryStatus updatedAt: number } @@ -57,11 +59,12 @@ export interface FetchOptions { } interface FetchMoreOptions { - fetchMoreVariable?: unknown - previous?: boolean + pageParam?: unknown + direction: 'forward' | 'backward' } interface SetDataOptions { + pageParams?: unknown[] updatedAt?: number } @@ -71,13 +74,16 @@ interface FailedAction { interface FetchAction { type: 'fetch' - isFetchingMore?: IsFetchingMoreValue + isFetchingNextPage?: boolean + isFetchingPreviousPage?: boolean } interface SuccessAction { - type: 'success' data: TData | undefined - canFetchMore?: boolean + hasNextPage?: boolean + hasPreviousPage?: boolean + pageParams?: unknown[] + type: 'success' updatedAt?: number } @@ -205,14 +211,13 @@ export class Query { data = prevData as TData } - // Try to determine if more data can be fetched - const canFetchMore = hasMorePages(this.options, data) - // Set data and mark it as cached this.dispatch({ - type: 'success', data, - canFetchMore, + hasNextPage: hasNextPage(this.options, data), + hasPreviousPage: hasPreviousPage(this.options, data), + pageParams: options?.pageParams, + type: 'success', updatedAt: options?.updatedAt, }) @@ -363,31 +368,23 @@ export class Query { ? this.startInfiniteFetch(this.options, params, fetchOptions) : this.startFetch(this.options, params) - this.promise = promise - .then(data => { - // Set success state - this.setData(data) - - // Return data - return data - }) - .catch(error => { - // Set error state if needed - if (!(isCancelledError(error) && error.silent)) { - this.dispatch({ - type: 'error', - error, - }) - } + this.promise = promise.catch(error => { + // Set error state if needed + if (!(isCancelledError(error) && error.silent)) { + this.dispatch({ + type: 'error', + error, + }) + } - // Log error - if (!isCancelledError(error)) { - getLogger().error(error) - } + // Log error + if (!isCancelledError(error)) { + getLogger().error(error) + } - // Propagate error - throw error - }) + // Propagate error + throw error + }) return this.promise } @@ -406,7 +403,9 @@ export class Query { } // Try to fetch the data - return this.tryFetchData(options, fetchData) + return this.tryFetchData(options, fetchData).then(data => + this.setData(data) + ) } private startInfiniteFetch( @@ -414,63 +413,100 @@ export class Query { params: unknown[], fetchOptions?: FetchOptions ): Promise { + const queryFn = options.queryFn || defaultQueryFn const fetchMore = fetchOptions?.fetchMore - const { previous, fetchMoreVariable } = fetchMore || {} - const isFetchingMore = fetchMore ? (previous ? 'previous' : 'next') : false - const prevPages: TQueryFnData[] = (this.state.data as any) || [] + const pageParam = fetchMore?.pageParam + const isFetchingNextPage = fetchMore?.direction === 'forward' + const isFetchingPreviousPage = fetchMore?.direction === 'backward' + const oldPages = (this.state.data || []) as TQueryFnData[] + const oldPageParams = this.state.pageParams || [] + let newPageParams = oldPageParams // Create function to fetch a page const fetchPage = ( pages: TQueryFnData[], - prepend?: boolean, - cursor?: unknown + manual?: boolean, + param?: unknown, + previous?: boolean ): Promise => { - const lastPage = getLastPage(pages, prepend) - - if ( - typeof cursor === 'undefined' && - typeof lastPage !== 'undefined' && - options.getFetchMore - ) { - cursor = options.getFetchMore(lastPage, pages) - } - - if (!cursor && typeof lastPage !== 'undefined') { + if (typeof param === 'undefined' && !manual && pages.length) { return Promise.resolve(pages) } - const queryFn = options.queryFn || defaultQueryFn - return Promise.resolve() - .then(() => queryFn(...params, cursor)) - .then(page => (prepend ? [page, ...pages] : [...pages, page])) + .then(() => queryFn(...params, param)) + .then(page => { + newPageParams = previous + ? [param, ...newPageParams] + : [...newPageParams, param] + return previous ? [page, ...pages] : [...pages, page] + }) } // Create function to fetch the data const fetchData = (): Promise => { - if (isFetchingMore) { - return fetchPage(prevPages, previous, fetchMoreVariable) - } else if (!prevPages.length) { + // Reset new page params + newPageParams = oldPageParams + + // Fetch first page? + if (!oldPages.length) { return fetchPage([]) - } else { - let promise = fetchPage([]) - for (let i = 1; i < prevPages.length; i++) { - promise = promise.then(fetchPage) - } - return promise } + + // Fetch next page? + if (isFetchingNextPage) { + const manual = typeof pageParam !== 'undefined' + const param = manual ? pageParam : getNextPageParam(options, oldPages) + return fetchPage(oldPages, manual, param) + } + + // Fetch previous page? + if (isFetchingPreviousPage) { + const manual = typeof pageParam !== 'undefined' + const param = manual + ? pageParam + : getPreviousPageParam(options, oldPages) + return fetchPage(oldPages, manual, param, true) + } + + // Refetch pages + newPageParams = [] + + const manual = typeof options.getNextPageParam === 'undefined' + + // Fetch first page + let promise = fetchPage([], manual, oldPageParams[0]) + + // Fetch remaining pages + for (let i = 1; i < oldPages.length; i++) { + promise = promise.then(pages => { + const param = manual + ? oldPageParams[i] + : getNextPageParam(options, pages) + return fetchPage(pages, manual, param) + }) + } + + return promise } // Set to fetching state if not already in it if ( !this.state.isFetching || - this.state.isFetchingMore !== isFetchingMore + this.state.isFetchingNextPage !== isFetchingNextPage || + this.state.isFetchingPreviousPage !== isFetchingPreviousPage ) { - this.dispatch({ type: 'fetch', isFetchingMore }) + this.dispatch({ + type: 'fetch', + isFetchingNextPage, + isFetchingPreviousPage, + }) } // Try to get the data - return this.tryFetchData(options, fetchData) + return this.tryFetchData(options, fetchData).then(data => + this.setData(data, { pageParams: newPageParams }) + ) } private tryFetchData( @@ -596,21 +632,44 @@ function defaultQueryFn() { return Promise.reject() } -function getLastPage( - pages: TQueryFnData[], - previous?: boolean -): TQueryFnData { - return previous ? pages[0] : pages[pages.length - 1] +function getNextPageParam( + options: QueryOptions, + pages: TQueryFnData[] +): unknown | undefined { + return options.getNextPageParam?.(pages[pages.length - 1], pages) +} + +function getPreviousPageParam( + options: QueryOptions, + pages: TQueryFnData[] +): unknown | undefined { + return options.getPreviousPageParam?.(pages[0], pages) +} + +/** + * Checks if there is a next page. + * Returns `undefined` if it cannot be determined. + */ +function hasNextPage( + options: QueryOptions, + pages: unknown +): boolean | undefined { + return options.getNextPageParam && Array.isArray(pages) + ? typeof getNextPageParam(options, pages) !== 'undefined' + : undefined } -function hasMorePages( +/** + * Checks if there is a previous page. + * Returns `undefined` if it cannot be determined. + */ +function hasPreviousPage( options: QueryOptions, - pages: unknown, - previous?: boolean + pages: unknown ): boolean | undefined { - if (options.infinite && options.getFetchMore && Array.isArray(pages)) { - return Boolean(options.getFetchMore(getLastPage(pages, previous), pages)) - } + return options.getPreviousPageParam && Array.isArray(pages) + ? typeof getPreviousPageParam(options, pages) !== 'undefined' + : undefined } function getDefaultState( @@ -624,15 +683,18 @@ function getDefaultState( const hasData = typeof data !== 'undefined' return { - canFetchMore: hasMorePages(options, data), data, dataUpdateCount: 0, error: null, errorUpdateCount: 0, failureCount: 0, + hasNextPage: hasNextPage(options, data), + hasPreviousPage: hasPreviousPage(options, data), isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, isInvalidated: false, + pageParams: undefined, status: hasData ? 'success' : 'idle', updatedAt: hasData ? Date.now() : 0, } @@ -653,19 +715,23 @@ export function queryReducer( ...state, failureCount: 0, isFetching: true, - isFetchingMore: action.isFetchingMore || false, + isFetchingNextPage: action.isFetchingNextPage || false, + isFetchingPreviousPage: action.isFetchingPreviousPage || false, status: state.updatedAt ? 'success' : 'loading', } case 'success': return { ...state, - canFetchMore: action.canFetchMore, data: action.data, dataUpdateCount: state.dataUpdateCount + 1, error: null, failureCount: 0, + hasNextPage: action.hasNextPage, + hasPreviousPage: action.hasPreviousPage, + pageParams: action.pageParams, isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, isInvalidated: false, status: 'success', updatedAt: action.updatedAt ?? Date.now(), @@ -677,7 +743,8 @@ export function queryReducer( errorUpdateCount: state.errorUpdateCount + 1, failureCount: state.failureCount + 1, isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, status: 'error', } case 'invalidate': diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index eb517f6623..61c9715076 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -10,7 +10,8 @@ import { } from './utils' import { notifyManager } from './notifyManager' import type { - FetchMoreOptions, + FetchNextPageOptions, + FetchPreviousPageOptions, QueryObserverOptions, QueryObserverResult, QueryOptions, @@ -70,7 +71,8 @@ export class QueryObserver< // Bind exposed methods this.remove = this.remove.bind(this) this.refetch = this.refetch.bind(this) - this.fetchMore = this.fetchMore.bind(this) + this.fetchNextPage = this.fetchNextPage.bind(this) + this.fetchPreviousPage = this.fetchPreviousPage.bind(this) // Subscribe to the query this.updateQuery() @@ -222,13 +224,21 @@ export class QueryObserver< return this.fetch(options) } - fetchMore( - fetchMoreVariable?: unknown, - options?: FetchMoreOptions + fetchNextPage( + options?: FetchNextPageOptions ): Promise> { return this.fetch({ throwOnError: options?.throwOnError, - fetchMore: { previous: options?.previous, fetchMoreVariable }, + fetchMore: { direction: 'forward', pageParam: options?.pageParam }, + }) + } + + fetchPreviousPage( + options?: FetchPreviousPageOptions + ): Promise> { + return this.fetch({ + throwOnError: options?.throwOnError, + fetchMore: { direction: 'backward', pageParam: options?.pageParam }, }) } @@ -371,15 +381,18 @@ export class QueryObserver< const result: QueryObserverResult = { ...getStatusProps(status), - canFetchMore: state.canFetchMore, data, error: state.error, failureCount: state.failureCount, - fetchMore: this.fetchMore, + fetchNextPage: this.fetchNextPage, + fetchPreviousPage: this.fetchPreviousPage, + hasNextPage: state.hasNextPage, + hasPreviousPage: state.hasPreviousPage, isFetched: state.dataUpdateCount > 0, isFetchedAfterMount: state.dataUpdateCount > this.initialDataUpdateCount, isFetching, - isFetchingMore: state.isFetchingMore, + isFetchingNextPage: state.isFetchingNextPage, + isFetchingPreviousPage: state.isFetchingPreviousPage, isPreviousData, isStale: this.isStale(), refetch: this.refetch, diff --git a/src/core/types.ts b/src/core/types.ts index 8f085aa0ff..1d8e0e79be 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -15,10 +15,15 @@ export type ShouldRetryFunction = ( error: TError ) => boolean -export type GetFetchMoreVariableFunction = ( +export type GetPreviousPageParamFunction = ( + firstPage: TQueryFnData, + allPages: TQueryFnData[] +) => unknown | undefined + +export type GetNextPageParamFunction = ( lastPage: TQueryFnData, allPages: TQueryFnData[] -) => unknown +) => unknown | undefined export type RetryDelayFunction = (attempt: number) => number @@ -49,11 +54,16 @@ export interface QueryOptions< * Defaults to `true`. */ structuralSharing?: boolean + /** + * This function can be set to automatically get the previous cursor for infinite queries. + * The result will also be used to determine the value of `hasPreviousPage`. + */ + getPreviousPageParam?: GetPreviousPageParamFunction /** * This function can be set to automatically get the next cursor for infinite queries. - * The result will also be used to determine the value of `canFetchMore`. + * The result will also be used to determine the value of `hasNextPage`. */ - getFetchMore?: GetFetchMoreVariableFunction + getNextPageParam?: GetNextPageParamFunction } export interface FetchQueryOptions< @@ -166,28 +176,34 @@ export interface InvalidateOptions { throwOnError?: boolean } -export interface FetchMoreOptions extends ResultOptions { - previous?: boolean +export interface FetchNextPageOptions extends ResultOptions { + pageParam?: unknown } -export type IsFetchingMoreValue = 'previous' | 'next' | false +export interface FetchPreviousPageOptions extends ResultOptions { + pageParam?: unknown +} export type QueryStatus = 'idle' | 'loading' | 'error' | 'success' export interface QueryObserverResult { - canFetchMore: boolean | undefined data: TData | undefined error: TError | null failureCount: number - fetchMore: ( - fetchMoreVariable?: unknown, - options?: FetchMoreOptions + fetchNextPage: ( + options?: FetchNextPageOptions + ) => Promise> + fetchPreviousPage: ( + options?: FetchPreviousPageOptions ) => Promise> + hasNextPage?: boolean + hasPreviousPage?: boolean isError: boolean isFetched: boolean isFetchedAfterMount: boolean isFetching: boolean - isFetchingMore?: IsFetchingMoreValue + isFetchingNextPage?: boolean + isFetchingPreviousPage?: boolean isIdle: boolean isLoading: boolean isPreviousData: boolean diff --git a/src/react/tests/useInfiniteQuery.test.tsx b/src/react/tests/useInfiniteQuery.test.tsx index 102726d9d7..3eafb5b1f5 100644 --- a/src/react/tests/useInfiniteQuery.test.tsx +++ b/src/react/tests/useInfiniteQuery.test.tsx @@ -17,29 +17,24 @@ import { interface Result { items: number[] - nextId: number + nextId?: number + prevId?: number ts: number } const pageSize = 10 -const initialItems = (page: number): Result => { - return { - items: [...new Array(10)].fill(null).map((_, d) => page * pageSize + d), - nextId: page + 1, - ts: page, - } -} - const fetchItems = async ( page: number, ts: number, - nextId?: any + noNext?: boolean, + noPrev?: boolean ): Promise => { await sleep(10) return { items: [...new Array(10)].fill(null).map((_, d) => page * pageSize + d), - nextId: nextId ?? page + 1, + nextId: noNext ? undefined : page + 1, + prevId: noPrev ? undefined : page - 1, ts, } } @@ -50,43 +45,39 @@ describe('useInfiniteQuery', () => { it('should return the correct states for a successful query', async () => { const key = queryKey() - - let count = 0 - const states: UseInfiniteQueryResult[] = [] + const states: UseInfiniteQueryResult[] = [] function Page() { const state = useInfiniteQuery( key, - (_key: string, nextId: number = 0) => fetchItems(nextId, count++), + (_key: string, pageParam: number = 0) => pageParam, { - getFetchMore: (lastGroup, _allGroups) => lastGroup.nextId, + getNextPageParam: lastPage => lastPage + 1, } ) - states.push(state) - - return ( -
-

Status: {state.status}

-
- ) + return null } - const rendered = renderWithClient(client, ) + renderWithClient(client, ) - await waitFor(() => rendered.getByText('Status: success')) + await sleep(100) + expect(states.length).toBe(2) expect(states[0]).toEqual({ - canFetchMore: undefined, data: undefined, error: null, failureCount: 0, - fetchMore: expect.any(Function), + fetchNextPage: expect.any(Function), + fetchPreviousPage: expect.any(Function), + hasNextPage: undefined, + hasPreviousPage: undefined, isError: false, isFetched: false, isFetchedAfterMount: false, isFetching: true, - isFetchingMore: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, isIdle: false, isLoading: true, isPreviousData: false, @@ -99,22 +90,19 @@ describe('useInfiniteQuery', () => { }) expect(states[1]).toEqual({ - canFetchMore: true, - data: [ - { - items: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], - nextId: 1, - ts: 0, - }, - ], + data: [0], error: null, failureCount: 0, - fetchMore: expect.any(Function), - isFetchingMore: false, + fetchNextPage: expect.any(Function), + fetchPreviousPage: expect.any(Function), + hasNextPage: true, + hasPreviousPage: undefined, isError: false, isFetched: true, isFetchedAfterMount: true, isFetching: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, isIdle: false, isLoading: false, isPreviousData: false, @@ -127,7 +115,7 @@ describe('useInfiniteQuery', () => { }) }) - it('should not throw when fetchMore returns an error', async () => { + it('should not throw when fetchNextPage returns an error', async () => { const consoleMock = mockConsoleError() const key = queryKey() let noThrow: boolean @@ -136,30 +124,30 @@ describe('useInfiniteQuery', () => { const start = 1 const state = useInfiniteQuery( key, - async (_key, page: number = start) => { - if (page === 2) { + async (_key, pageParam: number = start) => { + if (pageParam === 2) { throw new Error('error') } - return page + return pageParam }, { retry: 1, retryDelay: 10, - getFetchMore: (lastPage, _pages) => lastPage + 1, + getNextPageParam: lastPage => lastPage + 1, } ) - const { fetchMore } = state + const { fetchNextPage } = state React.useEffect(() => { setActTimeout(() => { - fetchMore() + fetchNextPage() .then(() => { noThrow = true }) .catch(() => undefined) }, 20) - }, [fetchMore]) + }, [fetchNextPage]) return null } @@ -179,28 +167,28 @@ describe('useInfiniteQuery', () => { const state = useInfiniteQuery( [key, order], - async (_key, orderArg, pageArg = 0) => { + async (_key, orderParam, pageParam = 0) => { await sleep(10) - return `${pageArg}-${orderArg}` + return `${pageParam}-${orderParam}` }, { - getFetchMore: (_lastGroup, _allGroups) => 1, + getNextPageParam: () => 1, keepPreviousData: true, } ) states.push(state) - const { fetchMore } = state + const { fetchNextPage } = state React.useEffect(() => { setActTimeout(() => { - fetchMore() + fetchNextPage() }, 50) setActTimeout(() => { setOrder('asc') }, 100) - }, [fetchMore]) + }, [fetchNextPage]) return null } @@ -213,28 +201,28 @@ describe('useInfiniteQuery', () => { expect(states[0]).toMatchObject({ data: undefined, isFetching: true, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: false, isPreviousData: false, }) expect(states[1]).toMatchObject({ data: ['0-desc'], isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: true, isPreviousData: false, }) expect(states[2]).toMatchObject({ data: ['0-desc'], isFetching: true, - isFetchingMore: 'next', + isFetchingNextPage: true, isSuccess: true, isPreviousData: false, }) expect(states[3]).toMatchObject({ data: ['0-desc', '1-desc'], isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: true, isPreviousData: false, }) @@ -242,21 +230,21 @@ describe('useInfiniteQuery', () => { expect(states[4]).toMatchObject({ data: ['0-desc', '1-desc'], isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: true, isPreviousData: false, }) expect(states[5]).toMatchObject({ data: ['0-desc', '1-desc'], isFetching: true, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: true, isPreviousData: true, }) expect(states[6]).toMatchObject({ data: ['0-asc'], isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: true, isPreviousData: false, }) @@ -289,7 +277,56 @@ describe('useInfiniteQuery', () => { }) }) - it('should prepend pages when the previous option is set to true', async () => { + it('should be able to reverse the data', async () => { + const key = queryKey() + const states: UseInfiniteQueryResult[] = [] + + function Page() { + const state = useInfiniteQuery( + key, + (_key, pageParam: number = 0) => pageParam, + { + select: pages => [...pages].reverse(), + } + ) + + states.push(state) + + const { fetchNextPage } = state + + React.useEffect(() => { + setActTimeout(() => { + fetchNextPage({ pageParam: 1 }) + }, 10) + }, [fetchNextPage]) + + return null + } + + renderWithClient(client, ) + + await sleep(100) + + expect(states.length).toBe(4) + expect(states[0]).toMatchObject({ + data: undefined, + isSuccess: false, + }) + expect(states[1]).toMatchObject({ + data: [0], + isSuccess: true, + }) + expect(states[2]).toMatchObject({ + data: [0], + isSuccess: true, + }) + expect(states[3]).toMatchObject({ + data: [1, 0], + isSuccess: true, + }) + }) + + it('should be able to fetch a previous page', async () => { const key = queryKey() const states: UseInfiniteQueryResult[] = [] @@ -297,24 +334,24 @@ describe('useInfiniteQuery', () => { const start = 10 const state = useInfiniteQuery( key, - async (_key, page: number = start) => { + async (_key, pageParam: number = start) => { await sleep(10) - return page + return pageParam }, { - getFetchMore: (lastPage, _pages) => lastPage - 1, + getPreviousPageParam: firstPage => firstPage - 1, } ) states.push(state) - const { fetchMore } = state + const { fetchPreviousPage } = state React.useEffect(() => { setActTimeout(() => { - fetchMore(undefined, { previous: true }) + fetchPreviousPage() }, 20) - }, [fetchMore]) + }, [fetchPreviousPage]) return null } @@ -325,35 +362,223 @@ describe('useInfiniteQuery', () => { expect(states.length).toBe(4) expect(states[0]).toMatchObject({ - canFetchMore: undefined, data: undefined, + hasNextPage: undefined, + hasPreviousPage: undefined, isFetching: true, - isFetchingMore: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, isSuccess: false, }) expect(states[1]).toMatchObject({ - canFetchMore: true, data: [10], + hasNextPage: undefined, + hasPreviousPage: true, isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, isSuccess: true, }) expect(states[2]).toMatchObject({ - canFetchMore: true, data: [10], + hasNextPage: undefined, + hasPreviousPage: true, isFetching: true, - isFetchingMore: 'previous', + isFetchingNextPage: false, + isFetchingPreviousPage: true, isSuccess: true, }) expect(states[3]).toMatchObject({ - canFetchMore: true, data: [9, 10], + hasNextPage: undefined, + hasPreviousPage: true, isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, isSuccess: true, }) }) + it('should be able to refetch when providing page params manually', async () => { + const key = queryKey() + const states: UseInfiniteQueryResult[] = [] + + function Page() { + const state = useInfiniteQuery( + key, + (_key, pageParam: number = 10) => pageParam + ) + + states.push(state) + + const { fetchNextPage, fetchPreviousPage, refetch } = state + + React.useEffect(() => { + setActTimeout(() => { + fetchNextPage({ pageParam: 11 }) + }, 10) + setActTimeout(() => { + fetchPreviousPage({ pageParam: 9 }) + }, 20) + setActTimeout(() => { + refetch() + }, 30) + }, [fetchNextPage, fetchPreviousPage, refetch]) + + return null + } + + renderWithClient(client, ) + + await sleep(100) + + expect(states.length).toBe(8) + // Initial fetch + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: true, + isFetchingNextPage: false, + }) + // Initial fetch done + expect(states[1]).toMatchObject({ + data: [10], + isFetching: false, + isFetchingNextPage: false, + }) + // Fetch next page + expect(states[2]).toMatchObject({ + data: [10], + isFetching: true, + isFetchingNextPage: true, + }) + // Fetch next page done + expect(states[3]).toMatchObject({ + data: [10, 11], + isFetching: false, + isFetchingNextPage: false, + }) + // Fetch previous page + expect(states[4]).toMatchObject({ + data: [10, 11], + isFetching: true, + isFetchingNextPage: false, + isFetchingPreviousPage: true, + }) + // Fetch previous page done + expect(states[5]).toMatchObject({ + data: [9, 10, 11], + isFetching: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, + }) + // Refetch + expect(states[6]).toMatchObject({ + data: [9, 10, 11], + isFetching: true, + isFetchingNextPage: false, + isFetchingPreviousPage: false, + }) + // Refetch done + expect(states[7]).toMatchObject({ + data: [9, 10, 11], + isFetching: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, + }) + }) + + it('should be able to refetch when providing page params automatically', async () => { + const key = queryKey() + const states: UseInfiniteQueryResult[] = [] + + function Page() { + const state = useInfiniteQuery( + key, + (_key, pageParam: number = 10) => pageParam, + { + getPreviousPageParam: firstPage => firstPage - 1, + getNextPageParam: lastPage => lastPage + 1, + } + ) + + states.push(state) + + const { fetchNextPage, fetchPreviousPage, refetch } = state + + React.useEffect(() => { + setActTimeout(() => { + fetchNextPage() + }, 10) + setActTimeout(() => { + fetchPreviousPage() + }, 20) + setActTimeout(() => { + refetch() + }, 30) + }, [fetchNextPage, fetchPreviousPage, refetch]) + + return null + } + + renderWithClient(client, ) + + await sleep(100) + + expect(states.length).toBe(8) + // Initial fetch + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: true, + isFetchingNextPage: false, + }) + // Initial fetch done + expect(states[1]).toMatchObject({ + data: [10], + isFetching: false, + isFetchingNextPage: false, + }) + // Fetch next page + expect(states[2]).toMatchObject({ + data: [10], + isFetching: true, + isFetchingNextPage: true, + }) + // Fetch next page done + expect(states[3]).toMatchObject({ + data: [10, 11], + isFetching: false, + isFetchingNextPage: false, + }) + // Fetch previous page + expect(states[4]).toMatchObject({ + data: [10, 11], + isFetching: true, + isFetchingNextPage: false, + isFetchingPreviousPage: true, + }) + // Fetch previous page done + expect(states[5]).toMatchObject({ + data: [9, 10, 11], + isFetching: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, + }) + // Refetch + expect(states[6]).toMatchObject({ + data: [9, 10, 11], + isFetching: true, + isFetchingNextPage: false, + isFetchingPreviousPage: false, + }) + // Refetch done + expect(states[7]).toMatchObject({ + data: [9, 10, 11], + isFetching: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, + }) + }) + it('should silently cancel any ongoing fetch when fetching more', async () => { const key = queryKey() const states: UseInfiniteQueryResult[] = [] @@ -362,27 +587,27 @@ describe('useInfiniteQuery', () => { const start = 10 const state = useInfiniteQuery( key, - async (_key, page: number = start) => { + async (_key, pageParam: number = start) => { await sleep(50) - return page + return pageParam }, { - getFetchMore: (lastPage, _pages) => lastPage + 1, + getNextPageParam: lastPage => lastPage + 1, } ) states.push(state) - const { refetch, fetchMore } = state + const { refetch, fetchNextPage } = state React.useEffect(() => { setActTimeout(() => { refetch() }, 100) setActTimeout(() => { - fetchMore() + fetchNextPage() }, 110) - }, [fetchMore, refetch]) + }, [fetchNextPage, refetch]) return null } @@ -393,38 +618,38 @@ describe('useInfiniteQuery', () => { expect(states.length).toBe(5) expect(states[0]).toMatchObject({ - canFetchMore: undefined, + hasNextPage: undefined, data: undefined, isFetching: true, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: false, }) expect(states[1]).toMatchObject({ - canFetchMore: true, + hasNextPage: true, data: [10], isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: true, }) expect(states[2]).toMatchObject({ - canFetchMore: true, + hasNextPage: true, data: [10], isFetching: true, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: true, }) expect(states[3]).toMatchObject({ - canFetchMore: true, + hasNextPage: true, data: [10], isFetching: true, - isFetchingMore: 'next', + isFetchingNextPage: true, isSuccess: true, }) expect(states[4]).toMatchObject({ - canFetchMore: true, + hasNextPage: true, data: [10, 11], isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: true, }) }) @@ -437,24 +662,24 @@ describe('useInfiniteQuery', () => { const start = 10 const state = useInfiniteQuery( key, - async (_key, page: number = start) => { + async (_key, pageParam: number = start) => { await sleep(50) - return page + return pageParam }, { - getFetchMore: (lastPage, _pages) => lastPage + 1, + getNextPageParam: lastPage => lastPage + 1, } ) states.push(state) - const { refetch, fetchMore } = state + const { fetchNextPage } = state React.useEffect(() => { setActTimeout(() => { - fetchMore() + fetchNextPage() }, 10) - }, [fetchMore, refetch]) + }, [fetchNextPage]) return null } @@ -465,46 +690,46 @@ describe('useInfiniteQuery', () => { expect(states.length).toBe(2) expect(states[0]).toMatchObject({ - canFetchMore: undefined, + hasNextPage: undefined, data: undefined, isFetching: true, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: false, }) expect(states[1]).toMatchObject({ - canFetchMore: true, + hasNextPage: true, data: [10], isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: true, }) }) - it('should be able to override the cursor in the fetchMore callback', async () => { + it('should be able to override the cursor in the fetchNextPage callback', async () => { const key = queryKey() const states: UseInfiniteQueryResult[] = [] function Page() { const state = useInfiniteQuery( key, - async (_key, page: number = 0) => { + async (_key, pageParam: number = 0) => { await sleep(10) - return page + return pageParam }, { - getFetchMore: (lastPage, _pages) => lastPage + 1, + getNextPageParam: lastPage => lastPage + 1, } ) states.push(state) - const { fetchMore } = state + const { fetchNextPage } = state React.useEffect(() => { setActTimeout(() => { - fetchMore(5) + fetchNextPage({ pageParam: 5 }) }, 20) - }, [fetchMore]) + }, [fetchNextPage]) return null } @@ -515,31 +740,31 @@ describe('useInfiniteQuery', () => { expect(states.length).toBe(4) expect(states[0]).toMatchObject({ - canFetchMore: undefined, + hasNextPage: undefined, data: undefined, isFetching: true, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: false, }) expect(states[1]).toMatchObject({ - canFetchMore: true, + hasNextPage: true, data: [0], isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: true, }) expect(states[2]).toMatchObject({ - canFetchMore: true, + hasNextPage: true, data: [0], isFetching: true, - isFetchingMore: 'next', + isFetchingNextPage: true, isSuccess: true, }) expect(states[3]).toMatchObject({ - canFetchMore: true, + hasNextPage: true, data: [0, 5], isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: true, }) }) @@ -553,12 +778,12 @@ describe('useInfiniteQuery', () => { const state = useInfiniteQuery( key, - async (_key, page: number = firstPage) => { + async (_key, pageParam: number = firstPage) => { await sleep(10) - return page + return pageParam }, { - getFetchMore: (lastPage, _pages) => lastPage + 1, + getNextPageParam: lastPage => lastPage + 1, } ) @@ -586,401 +811,159 @@ describe('useInfiniteQuery', () => { expect(states.length).toBe(6) expect(states[0]).toMatchObject({ - canFetchMore: undefined, + hasNextPage: undefined, data: undefined, isFetching: true, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: false, }) // After first fetch expect(states[1]).toMatchObject({ - canFetchMore: true, + hasNextPage: true, data: [0], isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: true, }) // Set state expect(states[2]).toMatchObject({ - canFetchMore: true, + hasNextPage: true, data: [0], isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: true, }) // Cache update expect(states[3]).toMatchObject({ - canFetchMore: true, + hasNextPage: true, data: [7, 8], isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: true, }) // Refetch expect(states[4]).toMatchObject({ - canFetchMore: true, + hasNextPage: true, data: [7, 8], isFetching: true, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: true, }) // Refetch done expect(states[5]).toMatchObject({ - canFetchMore: true, + hasNextPage: true, data: [7, 8], isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, isSuccess: true, }) }) - it('should allow you to fetch more pages', async () => { + it('should set hasNextPage to false if getNextPageParam returns undefined', async () => { const key = queryKey() + const states: UseInfiniteQueryResult[] = [] function Page() { - const fetchCountRef = React.useRef(0) - const { - status, - data, - error, - isFetching, - isFetchingMore, - fetchMore, - canFetchMore, - refetch, - } = useInfiniteQuery( - key, - (_key, nextId = 0) => fetchItems(nextId, fetchCountRef.current++), - { - getFetchMore: (lastGroup, _allGroups) => lastGroup.nextId, - } - ) - - return ( -
-

Pagination

- {status === 'loading' ? ( - 'Loading...' - ) : status === 'error' ? ( - Error: {error?.message} - ) : ( - <> -
Data:
- {data?.map((page, i) => ( -
-
- Page {i}: {page.ts} -
-
- {page.items.map(item => ( -

Item: {item}

- ))} -
-
- ))} -
- - -
-
- {isFetching && !isFetchingMore - ? 'Background Updating...' - : null} -
- - )} -
- ) - } - - const rendered = renderWithClient(client, ) + const state = useInfiniteQuery(key, (_key, pageParam = 1) => pageParam, { + getNextPageParam: () => undefined, + }) - rendered.getByText('Loading...') + states.push(state) - await waitFor(() => { - rendered.getByText('Item: 9') - rendered.getByText('Page 0: 0') - }) + return null + } - fireEvent.click(rendered.getByText('Load More')) + renderWithClient(client, ) - await waitFor(() => rendered.getByText('Loading more...')) + await sleep(100) - await waitFor(() => { - rendered.getByText('Item: 19') - rendered.getByText('Page 0: 0') - rendered.getByText('Page 1: 1') + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + data: undefined, + hasNextPage: undefined, + isFetching: true, + isFetchingNextPage: false, + isSuccess: false, }) - - fireEvent.click(rendered.getByText('Refetch')) - - await waitFor(() => rendered.getByText('Background Updating...')) - await waitFor(() => { - rendered.getByText('Item: 19') - rendered.getByText('Page 0: 2') - rendered.getByText('Page 1: 3') + expect(states[1]).toMatchObject({ + data: [1], + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + isSuccess: true, }) }) - it('should compute canFetchMore correctly for falsy getFetchMore return value', async () => { + it('should compute hasNextPage correctly using initialData', async () => { const key = queryKey() + const states: UseInfiniteQueryResult[] = [] function Page() { - const fetchCountRef = React.useRef(0) - const { - status, - data, - error, - isFetching, - isFetchingMore, - fetchMore, - canFetchMore, - refetch, - } = useInfiniteQuery( - key, - (_key, nextId = 0) => fetchItems(nextId, fetchCountRef.current++), - { - getFetchMore: (_lastGroup, _allGroups) => undefined, - } - ) - - return ( -
-

Pagination

- {status === 'loading' ? ( - 'Loading...' - ) : status === 'error' ? ( - Error: {error?.message} - ) : ( - <> -
Data:
- {data?.map((page, i) => ( -
-
- Page {i}: {page.ts} -
-
- {page.items.map(item => ( -

Item: {item}

- ))} -
-
- ))} -
- - -
-
- {isFetching && !isFetchingMore - ? 'Background Updating...' - : null} -
- - )} -
- ) - } - - const rendered = renderWithClient(client, ) - - rendered.getByText('Loading...') - - await waitFor(() => { - rendered.getByText('Item: 9') - rendered.getByText('Page 0: 0') - }) - - rendered.getByText('Nothing more to load') - }) - - it('should compute canFetchMore correctly using initialData', async () => { - const key = queryKey() + const state = useInfiniteQuery(key, (_key, pageParam = 10) => pageParam, { + initialData: [10], + getNextPageParam: lastPage => (lastPage === 10 ? 11 : undefined), + }) - function Page() { - const fetchCountRef = React.useRef(0) - const { - status, - data, - error, - isFetching, - isFetchingMore, - fetchMore, - canFetchMore, - refetch, - } = useInfiniteQuery( - key, - (_key, nextId = 0) => fetchItems(nextId, fetchCountRef.current++), - { - staleTime: 1000, - initialData: [initialItems(0)], - getFetchMore: (lastGroup, _allGroups) => lastGroup.nextId, - } - ) + states.push(state) - return ( -
-

Pagination

- {status === 'loading' ? ( - 'Loading...' - ) : status === 'error' ? ( - Error: {error?.message} - ) : ( - <> -
Data:
- {data?.map((page, i) => ( -
-
- Page {i}: {page.ts} -
-
- {page.items.map(item => ( -

Item: {item}

- ))} -
-
- ))} -
- - -
-
- {isFetching && !isFetchingMore - ? 'Background Updating...' - : null} -
- - )} -
- ) + return null } - const rendered = renderWithClient(client, ) - - rendered.getByText('Item: 9') - rendered.getByText('Page 0: 0') - - fireEvent.click(rendered.getByText('Load More')) + renderWithClient(client, ) - await waitFor(() => rendered.getByText('Loading more...')) + await sleep(100) - await waitFor(() => { - rendered.getByText('Item: 19') - rendered.getByText('Page 1: 0') + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + data: [10], + hasNextPage: true, + isFetching: true, + isFetchingNextPage: false, + isSuccess: true, }) - - fireEvent.click(rendered.getByText('Refetch')) - - await waitFor(() => rendered.getByText('Background Updating...')) - await waitFor(() => { - rendered.getByText('Item: 19') - rendered.getByText('Page 0: 1') - rendered.getByText('Page 1: 2') + expect(states[1]).toMatchObject({ + data: [10], + hasNextPage: true, + isFetching: false, + isFetchingNextPage: false, + isSuccess: true, }) }) - it('should compute canFetchMore correctly for falsy getFetchMore return value using initialData', async () => { + it('should compute hasNextPage correctly for falsy getFetchMore return value using initialData', async () => { const key = queryKey() + const states: UseInfiniteQueryResult[] = [] function Page() { - const fetchCountRef = React.useRef(0) - const { - status, - data, - error, - isFetching, - isFetchingMore, - fetchMore, - canFetchMore, - refetch, - } = useInfiniteQuery( - key, - (_key, nextId = 0) => fetchItems(nextId, fetchCountRef.current++), - { - staleTime: 1000, - initialData: [initialItems(0)], - getFetchMore: (_lastGroup, _allGroups) => undefined, - } - ) + const state = useInfiniteQuery(key, (_key, pageParam = 10) => pageParam, { + initialData: [10], + getNextPageParam: () => undefined, + }) - return ( -
-

Pagination

- {status === 'loading' ? ( - 'Loading...' - ) : status === 'error' ? ( - Error: {error?.message} - ) : ( - <> -
Data:
- {data?.map((page, i) => ( -
-
- Page {i}: {page.ts} -
-
- {page.items.map(item => ( -

Item: {item}

- ))} -
-
- ))} -
- - -
-
- {isFetching && !isFetchingMore - ? 'Background Updating...' - : null} -
- - )} -
- ) + states.push(state) + + return null } - const rendered = renderWithClient(client, ) + renderWithClient(client, ) - rendered.getByText('Item: 9') - rendered.getByText('Page 0: 0') + await sleep(100) - rendered.getByText('Nothing more to load') + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + data: [10], + hasNextPage: false, + isFetching: true, + isFetchingNextPage: false, + isSuccess: true, + }) + expect(states[1]).toMatchObject({ + data: [10], + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + isSuccess: true, + }) }) it('should build fresh cursors on refetch', async () => { @@ -1006,16 +989,16 @@ describe('useInfiniteQuery', () => { status, data, error, - isFetchingMore, - fetchMore, - canFetchMore, + isFetchingNextPage, + fetchNextPage, + hasNextPage, refetch, } = useInfiniteQuery( key, (_key, nextId = 0) => fetchItemsWithLimit(nextId, fetchCountRef.current++), { - getFetchMore: (lastGroup, _allGroups) => lastGroup.nextId, + getNextPageParam: lastPage => lastPage.nextId, } ) @@ -1043,12 +1026,12 @@ describe('useInfiniteQuery', () => { ))}
@@ -1065,7 +1048,7 @@ describe('useInfiniteQuery', () => { Remove item
-
{!isFetchingMore ? 'Background Updating...' : null}
+
{!isFetchingNextPage ? 'Background Updating...' : null}
)} @@ -1119,7 +1102,7 @@ describe('useInfiniteQuery', () => { expect(rendered.queryAllByText('Item: 4')).toHaveLength(0) }) - it('should compute canFetchMore correctly for falsy getFetchMore return value on refetching', async () => { + it('should compute hasNextPage correctly for falsy getFetchMore return value on refetching', async () => { const key = queryKey() const MAX = 2 @@ -1133,9 +1116,9 @@ describe('useInfiniteQuery', () => { data, error, isFetching, - isFetchingMore, - fetchMore, - canFetchMore, + isFetchingNextPage, + fetchNextPage, + hasNextPage, refetch, } = useInfiniteQuery( key, @@ -1144,11 +1127,9 @@ describe('useInfiniteQuery', () => { nextId, fetchCountRef.current++, nextId === MAX || (nextId === MAX - 1 && isRemovedLastPage) - ? false - : undefined ), { - getFetchMore: (lastGroup, _allGroups) => lastGroup.nextId, + getNextPageParam: lastPage => lastPage.nextId, } ) @@ -1176,12 +1157,12 @@ describe('useInfiniteQuery', () => { ))}
@@ -1191,7 +1172,7 @@ describe('useInfiniteQuery', () => {
- {isFetching && !isFetchingMore + {isFetching && !isFetchingNextPage ? 'Background Updating...' : null}
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index fbc6ebb606..44f36cba3f 100644 --- a/src/react/tests/useQuery.test.tsx +++ b/src/react/tests/useQuery.test.tsx @@ -125,16 +125,19 @@ describe('useQuery', () => { await waitFor(() => rendered.getByText('Status: success')) expect(states[0]).toEqual({ - canFetchMore: undefined, data: undefined, error: null, failureCount: 0, - fetchMore: expect.any(Function), + fetchNextPage: expect.any(Function), + fetchPreviousPage: expect.any(Function), + hasNextPage: undefined, + hasPreviousPage: undefined, isError: false, isFetched: false, isFetchedAfterMount: false, isFetching: true, - isFetchingMore: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, isIdle: false, isLoading: true, isPreviousData: false, @@ -147,16 +150,19 @@ describe('useQuery', () => { }) expect(states[1]).toEqual({ - canFetchMore: undefined, data: 'test', error: null, failureCount: 0, - fetchMore: expect.any(Function), + fetchNextPage: expect.any(Function), + fetchPreviousPage: expect.any(Function), + hasNextPage: undefined, + hasPreviousPage: undefined, isError: false, isFetched: true, isFetchedAfterMount: true, isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, isIdle: false, isLoading: false, isPreviousData: false, @@ -199,16 +205,19 @@ describe('useQuery', () => { await waitFor(() => rendered.getByText('Status: error')) expect(states[0]).toEqual({ - canFetchMore: undefined, data: undefined, error: null, failureCount: 0, - fetchMore: expect.any(Function), + fetchNextPage: expect.any(Function), + fetchPreviousPage: expect.any(Function), + hasNextPage: undefined, + hasPreviousPage: undefined, isError: false, isFetched: false, isFetchedAfterMount: false, isFetching: true, - isFetchingMore: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, isIdle: false, isLoading: true, isPreviousData: false, @@ -221,16 +230,19 @@ describe('useQuery', () => { }) expect(states[1]).toEqual({ - canFetchMore: undefined, data: undefined, error: null, failureCount: 1, - fetchMore: expect.any(Function), + fetchNextPage: expect.any(Function), + fetchPreviousPage: expect.any(Function), + hasNextPage: undefined, + hasPreviousPage: undefined, isError: false, isFetched: false, isFetchedAfterMount: false, isFetching: true, - isFetchingMore: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, isIdle: false, isLoading: true, isPreviousData: false, @@ -243,16 +255,19 @@ describe('useQuery', () => { }) expect(states[2]).toEqual({ - canFetchMore: undefined, data: undefined, error: 'rejected', failureCount: 2, - fetchMore: expect.any(Function), + fetchNextPage: expect.any(Function), + fetchPreviousPage: expect.any(Function), + hasNextPage: undefined, + hasPreviousPage: undefined, isError: true, isFetched: false, isFetchedAfterMount: false, isFetching: false, - isFetchingMore: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, isIdle: false, isLoading: false, isPreviousData: false,