diff --git a/.eslintrc.js b/.eslintrc.js index 0184a7a466..5a71532bbb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,6 +18,12 @@ module.exports = { 'error', { prefer: 'type-imports', disallowTypeAnnotations: false }, ], + 'react-hooks/exhaustive-deps': [ + 'warn', + { + additionalHooks: '(usePossiblyImmediateEffect)', + }, + ], }, overrides: [ // { diff --git a/packages/toolkit/src/query/core/buildInitiate.ts b/packages/toolkit/src/query/core/buildInitiate.ts index 9462ddcded..717c0d2ea1 100644 --- a/packages/toolkit/src/query/core/buildInitiate.ts +++ b/packages/toolkit/src/query/core/buildInitiate.ts @@ -55,6 +55,7 @@ export type QueryActionCreatorResult< unsubscribe(): void refetch(): void updateSubscriptionOptions(options: SubscriptionOptions): void + queryCacheKey: string } type StartMutationActionCreator< @@ -284,6 +285,7 @@ Features like automatic cache collection, automatic refetching etc. will not be arg, requestId, subscriptionOptions, + queryCacheKey, abort, refetch() { dispatch( diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index a5384380e5..60486c2deb 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -460,33 +460,6 @@ export type MutationTrigger> = const defaultQueryStateSelector: QueryStateSelector = (x) => x const defaultMutationStateSelector: MutationStateSelector = (x) => x -const queryStatePreSelector = ( - currentState: QueryResultSelectorResult, - lastResult: UseQueryStateDefaultResult -): UseQueryStateDefaultResult => { - // data is the last known good request result we have tracked - or if none has been tracked yet the last good result for the current args - let data = currentState.isSuccess ? currentState.data : lastResult?.data - if (data === undefined) data = currentState.data - - const hasData = data !== undefined - - // isFetching = true any time a request is in flight - const isFetching = currentState.isLoading - // isLoading = true only when loading while no data is present yet (initial load with no data in the cache) - const isLoading = !hasData && isFetching - // isSuccess = true when data is present - const isSuccess = currentState.isSuccess || (isFetching && hasData) - - return { - ...currentState, - data, - currentData: currentState.data, - isFetching, - isLoading, - isSuccess, - } as UseQueryStateDefaultResult -} - /** * Wrapper around `defaultQueryStateSelector` to be used in `useQuery`. * We want the initial render to already come back with @@ -546,6 +519,55 @@ export function buildHooks({ return { buildQueryHooks, buildMutationHook, usePrefetch } + function queryStatePreSelector( + currentState: QueryResultSelectorResult, + lastResult: UseQueryStateDefaultResult | undefined, + queryArgs: any + ): UseQueryStateDefaultResult { + // if we had a last result and the current result is uninitialized, + // we might have called `api.util.resetApiState` + // in this case, reset the hook + if (lastResult?.endpointName && currentState.isUninitialized) { + const { endpointName } = lastResult + const endpointDefinition = context.endpointDefinitions[endpointName] + if ( + serializeQueryArgs({ + queryArgs: lastResult.originalArgs, + endpointDefinition, + endpointName, + }) === + serializeQueryArgs({ + queryArgs, + endpointDefinition, + endpointName, + }) + ) + lastResult = undefined + } + + // data is the last known good request result we have tracked - or if none has been tracked yet the last good result for the current args + let data = currentState.isSuccess ? currentState.data : lastResult?.data + if (data === undefined) data = currentState.data + + const hasData = data !== undefined + + // isFetching = true any time a request is in flight + const isFetching = currentState.isLoading + // isLoading = true only when loading while no data is present yet (initial load with no data in the cache) + const isLoading = !hasData && isFetching + // isSuccess = true when data is present + const isSuccess = currentState.isSuccess || (isFetching && hasData) + + return { + ...currentState, + data, + currentData: currentState.data, + isFetching, + isLoading, + isSuccess, + } as UseQueryStateDefaultResult + } + function usePrefetch>( endpointName: EndpointName, defaultOptions?: PrefetchOptions @@ -595,8 +617,27 @@ export function buildHooks({ const promiseRef = useRef>() + let { queryCacheKey, requestId } = promiseRef.current || {} + const subscriptionRemoved = useSelector( + (state: RootState) => + !!queryCacheKey && + !!requestId && + !state[api.reducerPath].subscriptions[queryCacheKey]?.[requestId] + ) + + usePossiblyImmediateEffect((): void | undefined => { + promiseRef.current = undefined + }, [subscriptionRemoved]) + usePossiblyImmediateEffect((): void | undefined => { const lastPromise = promiseRef.current + if ( + typeof process !== 'undefined' && + process.env.NODE_ENV === 'removeMeOnCompilation' + ) { + // this is only present to enforce the rule of hooks to keep `isSubscribed` in the dependency array + console.log(subscriptionRemoved) + } if (stableArg === skipToken) { lastPromise?.unsubscribe() @@ -624,6 +665,7 @@ export function buildHooks({ refetchOnMountOrArgChange, stableArg, stableSubscriptionOptions, + subscriptionRemoved, ]) useEffect(() => { @@ -733,7 +775,11 @@ export function buildHooks({ const selectDefaultResult = useMemo( () => createSelector( - [select(stableArg), (_: any, lastResult: any) => lastResult], + [ + select(stableArg), + (_: any, lastResult: any) => lastResult, + () => stableArg, + ], queryStatePreSelector ), [select, stableArg] diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index 51b4aef085..198b15648e 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -547,6 +547,52 @@ describe('hooks tests', () => { expect(screen.getByTestId('amount').textContent).toBe('2') ) }) + + describe('api.util.resetApiState resets hook', () => { + test('without `selectFromResult`', async () => { + const { result } = renderHook(() => api.endpoints.getUser.useQuery(5), { + wrapper: storeRef.wrapper, + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + act(() => void storeRef.store.dispatch(api.util.resetApiState())) + + expect(result.current).toEqual( + expect.objectContaining({ + isError: false, + isFetching: true, + isLoading: true, + isSuccess: false, + isUninitialized: false, + refetch: expect.any(Function), + status: 'pending', + }) + ) + }) + test('with `selectFromResult`', async () => { + const selectFromResult = jest.fn((x) => x) + const { result } = renderHook( + () => api.endpoints.getUser.useQuery(5, { selectFromResult }), + { + wrapper: storeRef.wrapper, + } + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + selectFromResult.mockClear() + act(() => void storeRef.store.dispatch(api.util.resetApiState())) + + expect(selectFromResult).toHaveBeenNthCalledWith(1, { + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + isUninitialized: true, + status: 'uninitialized', + }) + }) + }) }) describe('useLazyQuery', () => { diff --git a/packages/toolkit/src/query/tests/helpers.tsx b/packages/toolkit/src/query/tests/helpers.tsx index 0084822570..c42b4f79e7 100644 --- a/packages/toolkit/src/query/tests/helpers.tsx +++ b/packages/toolkit/src/query/tests/helpers.tsx @@ -17,6 +17,7 @@ import { createConsole, getLog, } from 'console-testing-library/pure' +import { cleanup } from '@testing-library/react' export const ANY = 0 as any @@ -213,6 +214,7 @@ export function setupApiStore< } }) afterEach(() => { + cleanup() if (!withoutListeners) { cleanupListeners() }