import { configureStore } from '@reduxjs/toolkit' import { createApi } from '@reduxjs/toolkit/query/react' import { renderHook, waitFor } from '@testing-library/react' import { actionsReducer, setupApiStore, withProvider, } from '../../tests/utils/helpers' import type { BaseQueryApi } from '../baseQueryTypes' describe('baseline thunk behavior', () => { test('handles a non-async baseQuery without error', async () => { const baseQuery = (args?: any) => ({ data: args }) const api = createApi({ baseQuery, endpoints: (build) => ({ getUser: build.query({ query(id) { return { url: `user/${id}` } }, }), }), }) const { getUser } = api.endpoints const store = configureStore({ reducer: { [api.reducerPath]: api.reducer, }, middleware: (gDM) => gDM().concat(api.middleware), }) const promise = store.dispatch(getUser.initiate(1)) const { data } = await promise expect(data).toEqual({ url: 'user/1', }) const storeResult = getUser.select(1)(store.getState()) expect(storeResult).toEqual({ data: { url: 'user/1', }, endpointName: 'getUser', isError: false, isLoading: false, isSuccess: true, isUninitialized: false, originalArgs: 1, requestId: expect.any(String), status: 'fulfilled', startedTimeStamp: expect.any(Number), fulfilledTimeStamp: expect.any(Number), }) }) test('passes the extraArgument property to the baseQueryApi', async () => { const baseQuery = (_args: any, api: BaseQueryApi) => ({ data: api.extra }) const api = createApi({ baseQuery, endpoints: (build) => ({ getUser: build.query({ query: () => '', }), }), }) const store = configureStore({ reducer: { [api.reducerPath]: api.reducer, }, middleware: (gDM) => gDM({ thunk: { extraArgument: 'cakes' } }).concat(api.middleware), }) const { getUser } = api.endpoints const { data } = await store.dispatch(getUser.initiate()) expect(data).toBe('cakes') }) test('only triggers transformResponse when a query method is actually used', async () => { const baseQuery = (args?: any) => ({ data: args }) const transformResponse = vi.fn((response: any) => response) const api = createApi({ baseQuery, endpoints: (build) => ({ hasQuery: build.query({ query: (arg) => 'test', transformResponse, }), hasQueryFn: build.query( // @ts-expect-error { queryFn: () => ({ data: 'test' }), transformResponse, }, ), }), }) const store = configureStore({ reducer: { [api.reducerPath]: api.reducer, }, middleware: (gDM) => gDM({ thunk: { extraArgument: 'cakes' } }).concat(api.middleware), }) await store.dispatch(api.util.upsertQueryData('hasQuery', 'a', 'test')) expect(transformResponse).not.toHaveBeenCalled() transformResponse.mockReset() await store.dispatch(api.endpoints.hasQuery.initiate('b')) expect(transformResponse).toHaveBeenCalledTimes(1) transformResponse.mockReset() await store.dispatch(api.endpoints.hasQueryFn.initiate()) expect(transformResponse).not.toHaveBeenCalled() }) }) describe('re-triggering behavior on arg change', () => { const api = createApi({ baseQuery: () => ({ data: null }), endpoints: (build) => ({ getUser: build.query({ query: (obj) => obj, }), }), }) const { getUser } = api.endpoints const store = configureStore({ reducer: { [api.reducerPath]: api.reducer }, middleware: (gDM) => gDM().concat(api.middleware), }) const spy = vi.spyOn(getUser, 'initiate') beforeEach(() => void spy.mockClear()) test('re-trigger on literal value change', async () => { const { result, rerender } = renderHook( (props) => getUser.useQuery(props), { wrapper: withProvider(store), initialProps: 5, }, ) await waitFor(() => { expect(result.current.status).not.toBe('pending') }) expect(spy).toHaveBeenCalledOnce() for (let x = 1; x < 3; x++) { rerender(6) await waitFor(() => { expect(result.current.status).not.toBe('pending') }) expect(spy).toHaveBeenCalledTimes(2) } for (let x = 1; x < 3; x++) { rerender(7) await waitFor(() => { expect(result.current.status).not.toBe('pending') }) expect(spy).toHaveBeenCalledTimes(3) } }) test('only re-trigger on shallow-equal arg change', async () => { const { result, rerender } = renderHook( (props) => getUser.useQuery(props), { wrapper: withProvider(store), initialProps: { name: 'Bob', likes: 'iceCream' }, }, ) await waitFor(() => { expect(result.current.status).not.toBe('pending') }) expect(spy).toHaveBeenCalledOnce() for (let x = 1; x < 3; x++) { rerender({ name: 'Bob', likes: 'waffles' }) await waitFor(() => { expect(result.current.status).not.toBe('pending') }) expect(spy).toHaveBeenCalledTimes(2) } for (let x = 1; x < 3; x++) { rerender({ name: 'Alice', likes: 'waffles' }) await waitFor(() => { expect(result.current.status).not.toBe('pending') }) expect(spy).toHaveBeenCalledTimes(3) } }) test('re-triggers every time on deeper value changes', async () => { const name = 'Tim' const { result, rerender } = renderHook( (props) => getUser.useQuery(props), { wrapper: withProvider(store), initialProps: { person: { name } }, }, ) await waitFor(() => { expect(result.current.status).not.toBe('pending') }) expect(spy).toHaveBeenCalledOnce() for (let x = 1; x < 3; x++) { rerender({ person: { name: name + x } }) await waitFor(() => { expect(result.current.status).not.toBe('pending') }) expect(spy).toHaveBeenCalledTimes(x + 1) } }) test('do not re-trigger if the order of keys change while maintaining the same values', async () => { const { result, rerender } = renderHook( (props) => getUser.useQuery(props), { wrapper: withProvider(store), initialProps: { name: 'Tim', likes: 'Bananas' }, }, ) await waitFor(() => { expect(result.current.status).not.toBe('pending') }) expect(spy).toHaveBeenCalledOnce() for (let x = 1; x < 3; x++) { rerender({ likes: 'Bananas', name: 'Tim' }) await waitFor(() => { expect(result.current.status).not.toBe('pending') }) expect(spy).toHaveBeenCalledOnce() } }) }) describe('prefetch', () => { const baseQuery = () => ({ data: { name: 'Test User' } }) const api = createApi({ baseQuery, tagTypes: ['User'], endpoints: (build) => ({ getUser: build.query({ query: (id) => ({ url: `user/${id}` }), providesTags: (result, error, id) => [{ type: 'User', id }], }), updateUser: build.mutation({ query: ({ id, name }) => ({ url: `user/${id}`, method: 'PUT', body: { name }, }), invalidatesTags: (result, error, { id }) => [{ type: 'User', id }], }), }), keepUnusedDataFor: 0.1, // 100ms for faster test cleanup }) let storeRef = setupApiStore( api, { ...actionsReducer }, { withoutListeners: true, }, ) let getSubscriptions: () => Map let getSubscriptionCount: (queryCacheKey: string) => number beforeEach(() => { storeRef = setupApiStore( api, { ...actionsReducer }, { withoutListeners: true, }, ) // Get subscription helpers const subscriptionSelectors = storeRef.store.dispatch( api.internalActions.internal_getRTKQSubscriptions(), ) as any getSubscriptions = subscriptionSelectors.getSubscriptions getSubscriptionCount = subscriptionSelectors.getSubscriptionCount }) describe('subscription behavior', () => { it('prefetch should NOT create a subscription', async () => { const queryCacheKey = 'getUser(1)' // Initially no subscriptions expect(getSubscriptionCount(queryCacheKey)).toBe(0) // Dispatch prefetch storeRef.store.dispatch(api.util.prefetch('getUser', 1, {})) await Promise.all( storeRef.store.dispatch(api.util.getRunningQueriesThunk()), ) expect(getSubscriptionCount(queryCacheKey)).toBe(0) }) it('prefetch allows cache cleanup after keepUnusedDataFor', async () => { const queryCacheKey = 'getUser(1)' // Prefetch the data storeRef.store.dispatch(api.util.prefetch('getUser', 1, {})) await Promise.all( storeRef.store.dispatch(api.util.getRunningQueriesThunk()), ) // Verify data is in cache let state = api.endpoints.getUser.select(1)(storeRef.store.getState()) expect(state.data).toEqual({ name: 'Test User' }) // Wait longer than keepUnusedDataFor await new Promise((resolve) => setTimeout(resolve, 150)) state = api.endpoints.getUser.select(1)(storeRef.store.getState()) expect(state.status).toBe('uninitialized') expect(state.data).toBeUndefined() }) it('prefetch does NOT trigger refetch on tag invalidation', async () => { // Prefetch user 1 storeRef.store.dispatch(api.util.prefetch('getUser', 1, {})) await Promise.all( storeRef.store.dispatch(api.util.getRunningQueriesThunk()), ) // Verify data is in cache let state = api.endpoints.getUser.select(1)(storeRef.store.getState()) expect(state.data).toEqual({ name: 'Test User' }) // Invalidate the tag by updating the user await storeRef.store.dispatch( api.endpoints.updateUser.initiate({ id: 1, name: 'Updated' }), ) // Since there's no subscription, the cache entry gets removed on invalidation await Promise.all( storeRef.store.dispatch(api.util.getRunningQueriesThunk()), ) // Cache entry should be cleared (no subscription to keep it alive) state = api.endpoints.getUser.select(1)(storeRef.store.getState()) expect(state.status).toBe('uninitialized') expect(state.data).toBeUndefined() }) it('multiple prefetches do not accumulate subscriptions', async () => { const queryCacheKey = 'getUser(1)' expect(getSubscriptionCount(queryCacheKey)).toBe(0) // First prefetch storeRef.store.dispatch(api.util.prefetch('getUser', 1, {})) await Promise.all( storeRef.store.dispatch(api.util.getRunningQueriesThunk()), ) expect(getSubscriptionCount(queryCacheKey)).toBe(0) // Second prefetch (force refetch) storeRef.store.dispatch(api.util.prefetch('getUser', 1, { force: true })) await Promise.all( storeRef.store.dispatch(api.util.getRunningQueriesThunk()), ) // Still no subscriptions expect(getSubscriptionCount(queryCacheKey)).toBe(0) // Third prefetch storeRef.store.dispatch(api.util.prefetch('getUser', 1, { force: true })) await Promise.all( storeRef.store.dispatch(api.util.getRunningQueriesThunk()), ) expect(getSubscriptionCount(queryCacheKey)).toBe(0) }) it('prefetch followed by regular query should work correctly', async () => { const queryCacheKey = 'getUser(1)' // Prefetch first storeRef.store.dispatch(api.util.prefetch('getUser', 1, {})) await Promise.all( storeRef.store.dispatch(api.util.getRunningQueriesThunk()), ) // No subscription from prefetch expect(getSubscriptionCount(queryCacheKey)).toBe(0) // Now create a real subscription via initiate const promise = storeRef.store.dispatch(api.endpoints.getUser.initiate(1)) // Should have 1 subscription from the initiate call expect(getSubscriptionCount(queryCacheKey)).toBe(1) // Unsubscribe promise.unsubscribe() // Subscription should be cleaned up expect(getSubscriptionCount(queryCacheKey)).toBe(0) }) }) })