diff --git a/packages/toolkit/src/query/core/apiState.ts b/packages/toolkit/src/query/core/apiState.ts index 6b521a02cb..e068a32589 100644 --- a/packages/toolkit/src/query/core/apiState.ts +++ b/packages/toolkit/src/query/core/apiState.ts @@ -201,13 +201,6 @@ type BaseQuerySubState< * Time that the latest query was fulfilled */ fulfilledTimeStamp?: number - /** - * Infinite Query Specific substate properties - */ - hasNextPage?: boolean - hasPreviousPage?: boolean - direction?: 'forward' | 'backward' - param?: QueryArgFrom } export type QuerySubState< @@ -245,12 +238,6 @@ export type InfiniteQuerySubState< > = D extends InfiniteQueryDefinition ? QuerySubState, PageParamFrom>> & { - // TODO: These shouldn't be optional - hasNextPage?: boolean - hasPreviousPage?: boolean - isFetchingNextPage?: boolean - isFetchingPreviousPage?: boolean - param?: PageParamFrom direction?: InfiniteQueryDirection } : never diff --git a/packages/toolkit/src/query/core/buildSelectors.ts b/packages/toolkit/src/query/core/buildSelectors.ts index 89ac464a3c..65c42bdd7e 100644 --- a/packages/toolkit/src/query/core/buildSelectors.ts +++ b/packages/toolkit/src/query/core/buildSelectors.ts @@ -13,6 +13,8 @@ import type { import { expandTagDescription } from '../endpointDefinitions' import { flatten, isNotNullish } from '../utils' import type { + InfiniteData, + InfiniteQueryConfigOptions, InfiniteQuerySubState, MutationSubState, QueryCacheKey, @@ -26,6 +28,7 @@ import { QueryStatus, getRequestStatusFlags } from './apiState' import { getMutationCacheKey } from './buildSlice' import type { createSelector as _createSelector } from './rtkImports' import { createNextState } from './rtkImports' +import { getNextPageParam, getPreviousPageParam } from './buildThunks' export type SkipToken = typeof skipToken /** @@ -112,9 +115,20 @@ type InfiniteQueryResultSelectorFactory< queryArg: InfiniteQueryArgFrom | SkipToken, ) => (state: RootState) => InfiniteQueryResultSelectorResult +export type InfiniteQueryResultFlags = { + hasNextPage: boolean + hasPreviousPage: boolean + isFetchingNextPage: boolean + isFetchingPreviousPage: boolean + isFetchNextPageError: boolean + isFetchPreviousPageError: boolean +} + export type InfiniteQueryResultSelectorResult< Definition extends InfiniteQueryDefinition, -> = InfiniteQuerySubState & RequestStatusFlags +> = InfiniteQuerySubState & + RequestStatusFlags & + InfiniteQueryResultFlags type MutationResultSelectorFactory< Definition extends MutationDefinition, @@ -231,7 +245,52 @@ export function buildSelectors< const finalSelectQuerySubState = queryArgs === skipToken ? selectSkippedQuery : selectQuerySubstate - return createSelector(finalSelectQuerySubState, withRequestFlags) + const { infiniteQueryOptions } = endpointDefinition + + function withInfiniteQueryResultFlags( + substate: T, + ): T & RequestStatusFlags & InfiniteQueryResultFlags { + const infiniteSubstate = substate as InfiniteQuerySubState + const fetchDirection = infiniteSubstate.direction + const stateWithRequestFlags = { + ...infiniteSubstate, + ...getRequestStatusFlags(substate.status), + } + + const { isLoading, isError } = stateWithRequestFlags + + const isFetchNextPageError = isError && fetchDirection === 'forward' + const isFetchingNextPage = isLoading && fetchDirection === 'forward' + + const isFetchPreviousPageError = + isError && fetchDirection === 'backward' + const isFetchingPreviousPage = + isLoading && fetchDirection === 'backward' + + const hasNextPage = getHasNextPage( + infiniteQueryOptions, + stateWithRequestFlags.data, + ) + const hasPreviousPage = getHasPreviousPage( + infiniteQueryOptions, + stateWithRequestFlags.data, + ) + + return { + ...stateWithRequestFlags, + hasNextPage, + hasPreviousPage, + isFetchingNextPage, + isFetchingPreviousPage, + isFetchNextPageError, + isFetchPreviousPageError, + } + } + + return createSelector( + finalSelectQuerySubState, + withInfiniteQueryResultFlags, + ) }) as InfiniteQueryResultSelectorFactory } @@ -316,4 +375,20 @@ export function buildSelectors< ) .map((entry) => entry.originalArgs) } + + function getHasNextPage( + options: InfiniteQueryConfigOptions, + data?: InfiniteData, + ): boolean { + if (!data) return false + return getNextPageParam(options, data) != null + } + + function getHasPreviousPage( + options: InfiniteQueryConfigOptions, + data?: InfiniteData, + ): boolean { + if (!data || !options.getPreviousPageParam) return false + return getPreviousPageParam(options, data) != null + } } diff --git a/packages/toolkit/src/query/core/buildSlice.ts b/packages/toolkit/src/query/core/buildSlice.ts index 2b6ae7d2fd..ce089f0144 100644 --- a/packages/toolkit/src/query/core/buildSlice.ts +++ b/packages/toolkit/src/query/core/buildSlice.ts @@ -25,6 +25,7 @@ import type { ConfigState, QueryKeys, InfiniteQuerySubState, + InfiniteQueryDirection, } from './apiState' import { QueryStatus } from './apiState' import type { @@ -35,14 +36,15 @@ import type { RejectedAction, } from './buildThunks' import { calculateProvidedByThunk } from './buildThunks' -import type { - AssertTagTypes, - DefinitionType, - EndpointDefinitions, - FullTagDescription, - QueryArgFrom, - QueryDefinition, - ResultTypeFrom, +import { + isInfiniteQueryDefinition, + type AssertTagTypes, + type DefinitionType, + type EndpointDefinitions, + type FullTagDescription, + type QueryArgFrom, + type QueryDefinition, + type ResultTypeFrom, } from '../endpointDefinitions' import type { Patch } from 'immer' import { isDraft } from 'immer' @@ -205,15 +207,11 @@ export function buildSlice({ } substate.startedTimeStamp = meta.startedTimeStamp - // TODO: Awful - fix this most likely by just moving it to its own slice that only works on InfQuery's - if ( - 'param' in substate && - 'direction' in substate && - 'param' in arg && - 'direction' in arg - ) { - substate.param = arg.param - substate.direction = arg.direction as 'forward' | 'backward' | undefined + const endpointDefinition = definitions[meta.arg.endpointName] + + if (isInfiniteQueryDefinition(endpointDefinition) && 'direction' in arg) { + ;(substate as InfiniteQuerySubState).direction = + arg.direction as InfiniteQueryDirection } }) } @@ -223,11 +221,9 @@ export function buildSlice({ meta: { arg: QueryThunkArg requestId: string - // requestStatus: 'fulfilled' } & { fulfilledTimeStamp: number baseQueryMeta: unknown - // RTK_autoBatch: true }, payload: unknown, upserting: boolean, diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index 46d4f70699..2180114ad6 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -36,6 +36,7 @@ import type { SyncScreen } from '@testing-library/react-render-stream/pure' import { createRenderStream } from '@testing-library/react-render-stream/pure' import { HttpResponse, http } from 'msw' import { useEffect, useState } from 'react' +import type { InfiniteQueryResultFlags } from '../core/buildSelectors' // Just setup a temporary in-memory counter for tests that `getIncrementedAmount`. // This can be used to test how many renders happen due to data changes or @@ -1781,7 +1782,7 @@ describe('hooks tests', () => { ) }) - test('useInfiniteQuery fetchNextPage Trigger', async () => { + test.only('useInfiniteQuery fetchNextPage Trigger', async () => { const storeRef = setupApiStore(pokemonApi, undefined, { withoutTestLifecycles: true, }) @@ -1798,6 +1799,26 @@ describe('hooks tests', () => { expect(queries).toBe(count) } + const checkEntryFlags = ( + arg: string, + expectedFlags: Partial, + ) => { + const selector = pokemonApi.endpoints.getInfinitePokemon.select(arg) + const entry = selector(storeRef.store.getState()) + + const actualFlags: InfiniteQueryResultFlags = { + hasNextPage: false, + hasPreviousPage: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, + isFetchNextPageError: false, + isFetchPreviousPageError: false, + ...expectedFlags, + } + + expect(entry).toMatchObject(actualFlags) + } + const checkPageRows = ( withinDOM: () => SyncScreen, type: string, @@ -1836,30 +1857,64 @@ describe('hooks tests', () => { const utils = render(, { wrapper: storeRef.wrapper }) checkNumQueries(1) + checkEntryFlags('fire', {}) await waitForFetch(true) checkNumQueries(1) checkPageRows(getCurrentRender().withinDOM, 'fire', [0]) + checkEntryFlags('fire', { + hasNextPage: true, + }) fireEvent.click(screen.getByTestId('nextPage'), {}) + checkEntryFlags('fire', { + hasNextPage: true, + isFetchingNextPage: true, + }) await waitForFetch() checkPageRows(getCurrentRender().withinDOM, 'fire', [0, 1]) + checkEntryFlags('fire', { + hasNextPage: true, + }) fireEvent.click(screen.getByTestId('nextPage')) await waitForFetch() checkPageRows(getCurrentRender().withinDOM, 'fire', [0, 1, 2]) utils.rerender() + checkEntryFlags('water', {}) await waitForFetch(true) checkNumQueries(2) checkPageRows(getCurrentRender().withinDOM, 'water', [3]) + checkEntryFlags('water', { + hasNextPage: true, + hasPreviousPage: true, + }) fireEvent.click(screen.getByTestId('nextPage')) + checkEntryFlags('water', { + hasNextPage: true, + hasPreviousPage: true, + isFetchingNextPage: true, + }) await waitForFetch() checkPageRows(getCurrentRender().withinDOM, 'water', [3, 4]) + checkEntryFlags('water', { + hasNextPage: true, + hasPreviousPage: true, + }) fireEvent.click(screen.getByTestId('prevPage')) + checkEntryFlags('water', { + hasNextPage: true, + hasPreviousPage: true, + isFetchingPreviousPage: true, + }) await waitForFetch() checkPageRows(getCurrentRender().withinDOM, 'water', [2, 3, 4]) + checkEntryFlags('water', { + hasNextPage: true, + hasPreviousPage: true, + }) }) }) diff --git a/packages/toolkit/src/query/tests/infiniteQueries.test.ts b/packages/toolkit/src/query/tests/infiniteQueries.test.ts index b317fd87a4..5178f7588c 100644 --- a/packages/toolkit/src/query/tests/infiniteQueries.test.ts +++ b/packages/toolkit/src/query/tests/infiniteQueries.test.ts @@ -10,6 +10,7 @@ import { import userEvent from '@testing-library/user-event' import { HttpResponse, http } from 'msw' import util from 'util' +import type { InfiniteQueryActionCreatorResult } from '@reduxjs/toolkit/query/react' import { QueryStatus, createApi, @@ -24,6 +25,7 @@ import { } from '../../tests/utils/helpers' import type { BaseQueryApi } from '../baseQueryTypes' import { server } from '@internal/query/tests/mocks/server' +import type { InfiniteQueryResultFlags } from '../core/buildSelectors' describe('Infinite queries', () => { type Pokemon = { @@ -138,93 +140,163 @@ describe('Infinite queries', () => { process.env.NODE_ENV = 'test' }) - test('Basic infinite query behavior', async () => { + test.only('Basic infinite query behavior', async () => { + const checkFlags = ( + value: unknown, + expectedFlags: Partial, + ) => { + const actualFlags: InfiniteQueryResultFlags = { + hasNextPage: false, + hasPreviousPage: false, + isFetchingNextPage: false, + isFetchingPreviousPage: false, + isFetchNextPageError: false, + isFetchPreviousPageError: false, + ...expectedFlags, + } + + expect(value).toMatchObject(actualFlags) + } + + const checkEntryFlags = ( + arg: string, + expectedFlags: Partial, + ) => { + const selector = pokemonApi.endpoints.getInfinitePokemon.select(arg) + const entry = selector(storeRef.store.getState()) + + checkFlags(entry, expectedFlags) + } + + type InfiniteQueryResult = Awaited> + + const checkResultData = ( + result: InfiniteQueryResult, + expectedValues: Pokemon[][], + ) => { + expect(result.status).toBe(QueryStatus.fulfilled) + if (result.status === QueryStatus.fulfilled) { + expect(result.data.pages).toEqual(expectedValues) + } + } + const res1 = storeRef.store.dispatch( - // Should be `arg: string` pokemonApi.endpoints.getInfinitePokemon.initiate('fire', {}), ) + checkEntryFlags('fire', {}) + const entry1InitialLoad = await res1 - expect(entry1InitialLoad.status).toBe(QueryStatus.fulfilled) - // console.log('Value: ', util.inspect(entry1InitialLoad, { depth: Infinity })) - if (entry1InitialLoad.status === QueryStatus.fulfilled) { - expect(entry1InitialLoad.data.pages).toEqual([ - // one page, one entry - [{ id: '0', name: 'Pokemon 0' }], - ]) - } + checkResultData(entry1InitialLoad, [[{ id: '0', name: 'Pokemon 0' }]]) + checkFlags(entry1InitialLoad, { + hasNextPage: true, + }) - const entry1SecondPage = await storeRef.store.dispatch( + const res2 = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('fire', { direction: 'forward', }), ) - expect(entry1SecondPage.status).toBe(QueryStatus.fulfilled) - if (entry1SecondPage.status === QueryStatus.fulfilled) { - expect(entry1SecondPage.data.pages).toEqual([ - // two pages, one entry each - [{ id: '0', name: 'Pokemon 0' }], - [{ id: '1', name: 'Pokemon 1' }], - ]) - } + checkEntryFlags('fire', { + hasNextPage: true, + isFetchingNextPage: true, + }) + + const entry1SecondPage = await res2 + + checkResultData(entry1SecondPage, [ + [{ id: '0', name: 'Pokemon 0' }], + [{ id: '1', name: 'Pokemon 1' }], + ]) + checkFlags(entry1SecondPage, { + hasNextPage: true, + }) - const entry1PrevPageMissing = await storeRef.store.dispatch( + const res3 = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('fire', { direction: 'backward', }), ) - if (entry1PrevPageMissing.status === QueryStatus.fulfilled) { - expect(entry1PrevPageMissing.data.pages).toEqual([ - // two pages, one entry each - [{ id: '0', name: 'Pokemon 0' }], - [{ id: '1', name: 'Pokemon 1' }], - ]) - } + checkEntryFlags('fire', { + hasNextPage: true, + isFetchingPreviousPage: true, + }) + + const entry1PrevPageMissing = await res3 + + checkResultData(entry1PrevPageMissing, [ + [{ id: '0', name: 'Pokemon 0' }], + [{ id: '1', name: 'Pokemon 1' }], + ]) + checkFlags(entry1PrevPageMissing, { + hasNextPage: true, + }) - const entry2InitialLoad = await storeRef.store.dispatch( + const res4 = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('water', { initialPageParam: 3, }), ) - if (entry2InitialLoad.status === QueryStatus.fulfilled) { - expect(entry2InitialLoad.data.pages).toEqual([ - // one page, one entry - [{ id: '3', name: 'Pokemon 3' }], - ]) - } + checkEntryFlags('water', {}) + + const entry2InitialLoad = await res4 - const entry2NextPage = await storeRef.store.dispatch( + checkResultData(entry2InitialLoad, [[{ id: '3', name: 'Pokemon 3' }]]) + checkFlags(entry2InitialLoad, { + hasNextPage: true, + hasPreviousPage: true, + }) + + const res5 = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('water', { direction: 'forward', }), ) - if (entry2NextPage.status === QueryStatus.fulfilled) { - expect(entry2NextPage.data.pages).toEqual([ - // two pages, one entry each - [{ id: '3', name: 'Pokemon 3' }], - [{ id: '4', name: 'Pokemon 4' }], - ]) - } + checkEntryFlags('water', { + hasNextPage: true, + hasPreviousPage: true, + isFetchingNextPage: true, + }) + + const entry2NextPage = await res5 - const entry2PrevPage = await storeRef.store.dispatch( + checkResultData(entry2NextPage, [ + [{ id: '3', name: 'Pokemon 3' }], + [{ id: '4', name: 'Pokemon 4' }], + ]) + checkFlags(entry2NextPage, { + hasNextPage: true, + hasPreviousPage: true, + }) + + const res6 = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('water', { direction: 'backward', }), ) - if (entry2PrevPage.status === QueryStatus.fulfilled) { - expect(entry2PrevPage.data.pages).toEqual([ - // three pages, one entry each - [{ id: '2', name: 'Pokemon 2' }], - [{ id: '3', name: 'Pokemon 3' }], - [{ id: '4', name: 'Pokemon 4' }], - ]) - } + checkEntryFlags('water', { + hasNextPage: true, + hasPreviousPage: true, + isFetchingPreviousPage: true, + }) + + const entry2PrevPage = await res6 + + checkResultData(entry2PrevPage, [ + [{ id: '2', name: 'Pokemon 2' }], + [{ id: '3', name: 'Pokemon 3' }], + [{ id: '4', name: 'Pokemon 4' }], + ]) + checkFlags(entry2PrevPage, { + hasNextPage: true, + hasPreviousPage: true, + }) }) test.skip('does not break refetching query endpoints', async () => {