UNPKG

@reduxjs/toolkit

Version:

The official, opinionated, batteries-included toolset for efficient Redux development

709 lines (644 loc) 22.3 kB
import { DEFAULT_DELAY_MS, fakeTimerWaitFor, setupApiStore, } from '@internal/tests/utils/helpers' import type { QueryActionCreatorResult } from '@reduxjs/toolkit/query' import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query' beforeAll(() => { vi.useFakeTimers() }) const api = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), endpoints: () => ({}), }) const storeRef = setupApiStore(api) const onNewCacheEntry = vi.fn() const gotFirstValue = vi.fn() const onCleanup = vi.fn() const onCatch = vi.fn() beforeEach(() => { onNewCacheEntry.mockClear() gotFirstValue.mockClear() onCleanup.mockClear() onCatch.mockClear() }) describe.each([['query'], ['mutation']] as const)( 'generic cases: %s', (type) => { test(`${type}: new cache entry only`, async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build[type as 'mutation']<unknown, string>({ query: () => '/success', onCacheEntryAdded(arg, { dispatch, getState }) { onNewCacheEntry(arg) }, }), }), }) storeRef.store.dispatch(extended.endpoints.injected.initiate('arg')) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') }) test(`${type}: await cacheEntryRemoved`, async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ // Lying to TS here injected: build[type as 'mutation']<unknown, string>({ query: () => '/success', async onCacheEntryAdded( arg, { dispatch, getState, cacheEntryRemoved }, ) { onNewCacheEntry(arg) await cacheEntryRemoved onCleanup() }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.injected.initiate('arg'), ) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') expect(onCleanup).not.toHaveBeenCalled() await promise if (type === 'mutation') { promise.reset() } else { ;(promise as unknown as QueryActionCreatorResult<any>).unsubscribe() } await vi.advanceTimersByTimeAsync(DEFAULT_DELAY_MS) if (type === 'query') { await vi.advanceTimersByTimeAsync(59000) expect(onCleanup).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(2000) } expect(onCleanup).toHaveBeenCalled() }) test(`${type}: await cacheDataLoaded, await cacheEntryRemoved (success)`, async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build[type as 'mutation']<number, string>({ query: () => '/success', async onCacheEntryAdded( arg, { dispatch, getState, cacheEntryRemoved, cacheDataLoaded }, ) { onNewCacheEntry(arg) const firstValue = await cacheDataLoaded gotFirstValue(firstValue) await cacheEntryRemoved onCleanup() }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.injected.initiate('arg'), ) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') expect(gotFirstValue).not.toHaveBeenCalled() expect(onCleanup).not.toHaveBeenCalled() await fakeTimerWaitFor(() => { expect(gotFirstValue).toHaveBeenCalled() }) expect(gotFirstValue).toHaveBeenCalledWith({ data: { value: 'success' }, meta: { request: expect.any(Request), response: expect.any(Object), // Response is not available in jest env }, }) expect(onCleanup).not.toHaveBeenCalled() if (type === 'mutation') { promise.reset() } else { ;(promise as unknown as QueryActionCreatorResult<any>).unsubscribe() } await vi.advanceTimersByTimeAsync(DEFAULT_DELAY_MS) if (type === 'query') { await vi.advanceTimersByTimeAsync(59000) expect(onCleanup).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(2000) } expect(onCleanup).toHaveBeenCalled() }) test(`${type}: await cacheDataLoaded, await cacheEntryRemoved (cacheDataLoaded never resolves)`, async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build[type as 'mutation']<unknown, string>({ query: () => '/error', // we will initiate only once and that one time will be an error -> cacheDataLoaded will never resolve async onCacheEntryAdded( arg, { dispatch, getState, cacheEntryRemoved, cacheDataLoaded }, ) { onNewCacheEntry(arg) // this will wait until cacheEntryRemoved, then reject => nothing past that line will execute // but since this special "cacheEntryRemoved" rejection is handled outside, there will be no // uncaught rejection error const firstValue = await cacheDataLoaded gotFirstValue(firstValue) await cacheEntryRemoved onCleanup() }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.injected.initiate('arg'), ) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') if (type === 'mutation') { promise.reset() } else { ;(promise as unknown as QueryActionCreatorResult<any>).unsubscribe() } await vi.advanceTimersByTimeAsync(DEFAULT_DELAY_MS) if (type === 'query') { await vi.advanceTimersByTimeAsync(120000) } expect(gotFirstValue).not.toHaveBeenCalled() expect(onCleanup).not.toHaveBeenCalled() }) test(`${type}: try { await cacheDataLoaded }, await cacheEntryRemoved (cacheDataLoaded never resolves)`, async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build[type as 'mutation']<unknown, string>({ query: () => '/error', // we will initiate only once and that one time will be an error -> cacheDataLoaded will never resolve async onCacheEntryAdded( arg, { dispatch, getState, cacheEntryRemoved, cacheDataLoaded }, ) { onNewCacheEntry(arg) try { // this will wait until cacheEntryRemoved, then reject => nothing else in this try..catch block will execute const firstValue = await cacheDataLoaded gotFirstValue(firstValue) } catch (e) { onCatch(e) } await cacheEntryRemoved onCleanup() }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.injected.initiate('arg'), ) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') await promise if (type === 'mutation') { promise.reset() } else { ;(promise as unknown as QueryActionCreatorResult<any>).unsubscribe() } await vi.advanceTimersByTimeAsync(DEFAULT_DELAY_MS) if (type === 'query') { await vi.advanceTimersByTimeAsync(59000) expect(onCleanup).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(2000) } expect(onCleanup).toHaveBeenCalled() expect(gotFirstValue).not.toHaveBeenCalled() expect(onCatch.mock.calls[0][0]).toMatchObject({ message: 'Promise never resolved before cacheEntryRemoved.', }) }) test(`${type}: try { await cacheDataLoaded, await cacheEntryRemoved } (cacheDataLoaded never resolves)`, async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build[type as 'mutation']<unknown, string>({ query: () => '/error', // we will initiate only once and that one time will be an error -> cacheDataLoaded will never resolve async onCacheEntryAdded( arg, { dispatch, getState, cacheEntryRemoved, cacheDataLoaded }, ) { onNewCacheEntry(arg) try { // this will wait until cacheEntryRemoved, then reject => nothing else in this try..catch block will execute const firstValue = await cacheDataLoaded gotFirstValue(firstValue) // cleanup in this scenario only needs to be done for stuff within this try..catch block - totally valid scenario await cacheEntryRemoved onCleanup() } catch (e) { onCatch(e) } }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.injected.initiate('arg'), ) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') await promise if (type === 'mutation') { promise.reset() } else { ;(promise as unknown as QueryActionCreatorResult<any>).unsubscribe() } await vi.advanceTimersByTimeAsync(DEFAULT_DELAY_MS) if (type === 'query') { await vi.advanceTimersByTimeAsync(59000) expect(onCleanup).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(2000) } expect(onCleanup).not.toHaveBeenCalled() expect(gotFirstValue).not.toHaveBeenCalled() expect(onCatch.mock.calls[0][0]).toMatchObject({ message: 'Promise never resolved before cacheEntryRemoved.', }) }) test(`${type}: try { await cacheDataLoaded } finally { await cacheEntryRemoved } (cacheDataLoaded never resolves)`, async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build[type as 'mutation']<unknown, string>({ query: () => '/error', // we will initiate only once and that one time will be an error -> cacheDataLoaded will never resolve async onCacheEntryAdded( arg, { dispatch, getState, cacheEntryRemoved, cacheDataLoaded }, ) { onNewCacheEntry(arg) try { // this will wait until cacheEntryRemoved, then reject => nothing else in this try..catch block will execute const firstValue = await cacheDataLoaded gotFirstValue(firstValue) } catch (e) { onCatch(e) } finally { await cacheEntryRemoved onCleanup() } }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.injected.initiate('arg'), ) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') await promise if (type === 'mutation') { promise.reset() } else { ;(promise as unknown as QueryActionCreatorResult<any>).unsubscribe() } await vi.advanceTimersByTimeAsync(DEFAULT_DELAY_MS) if (type === 'query') { await vi.advanceTimersByTimeAsync(59000) expect(onCleanup).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(2000) } expect(onCleanup).toHaveBeenCalled() expect(gotFirstValue).not.toHaveBeenCalled() expect(onCatch.mock.calls[0][0]).toMatchObject({ message: 'Promise never resolved before cacheEntryRemoved.', }) }) }, ) test(`query: getCacheEntry`, async () => { const snapshot = vi.fn() const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build.query<unknown, string>({ query: () => '/success', async onCacheEntryAdded( arg, { dispatch, getState, getCacheEntry, cacheEntryRemoved, cacheDataLoaded, }, ) { snapshot(getCacheEntry()) gotFirstValue(await cacheDataLoaded) snapshot(getCacheEntry()) await cacheEntryRemoved snapshot(getCacheEntry()) }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.injected.initiate('arg'), ) await promise promise.unsubscribe() await fakeTimerWaitFor(() => { expect(gotFirstValue).toHaveBeenCalled() }) await vi.advanceTimersByTimeAsync(120000) expect(snapshot).toHaveBeenCalledTimes(3) expect(snapshot.mock.calls[0][0]).toMatchObject({ endpointName: 'injected', isError: false, isLoading: true, isSuccess: false, isUninitialized: false, originalArgs: 'arg', requestId: promise.requestId, startedTimeStamp: expect.any(Number), status: 'pending', }) expect(snapshot.mock.calls[1][0]).toMatchObject({ data: { value: 'success', }, endpointName: 'injected', fulfilledTimeStamp: expect.any(Number), isError: false, isLoading: false, isSuccess: true, isUninitialized: false, originalArgs: 'arg', requestId: promise.requestId, startedTimeStamp: expect.any(Number), status: 'fulfilled', }) expect(snapshot.mock.calls[2][0]).toMatchObject({ isError: false, isLoading: false, isSuccess: false, isUninitialized: true, status: 'uninitialized', }) }) test(`mutation: getCacheEntry`, async () => { const snapshot = vi.fn() const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build.mutation<unknown, string>({ query: () => '/success', async onCacheEntryAdded( arg, { dispatch, getState, getCacheEntry, cacheEntryRemoved, cacheDataLoaded, }, ) { snapshot(getCacheEntry()) gotFirstValue(await cacheDataLoaded) snapshot(getCacheEntry()) await cacheEntryRemoved snapshot(getCacheEntry()) }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.injected.initiate('arg'), ) await fakeTimerWaitFor(() => { expect(gotFirstValue).toHaveBeenCalled() }) promise.reset() await vi.advanceTimersByTimeAsync(DEFAULT_DELAY_MS) expect(snapshot).toHaveBeenCalledTimes(3) expect(snapshot.mock.calls[0][0]).toMatchObject({ endpointName: 'injected', isError: false, isLoading: true, isSuccess: false, isUninitialized: false, startedTimeStamp: expect.any(Number), status: 'pending', }) expect(snapshot.mock.calls[1][0]).toMatchObject({ data: { value: 'success', }, endpointName: 'injected', fulfilledTimeStamp: expect.any(Number), isError: false, isLoading: false, isSuccess: true, isUninitialized: false, startedTimeStamp: expect.any(Number), status: 'fulfilled', }) expect(snapshot.mock.calls[2][0]).toMatchObject({ isError: false, isLoading: false, isSuccess: false, isUninitialized: true, status: 'uninitialized', }) }) test('query: updateCachedData', async () => { const trackCalls = vi.fn() const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build.query<{ value: string }, string>({ query: () => '/success', async onCacheEntryAdded( arg, { dispatch, getState, getCacheEntry, updateCachedData, cacheEntryRemoved, cacheDataLoaded, }, ) { expect(getCacheEntry().data).toEqual(undefined) // calling `updateCachedData` when there is no data yet should not do anything updateCachedData((draft) => { draft.value = 'TEST' trackCalls() }) expect(trackCalls).not.toHaveBeenCalled() expect(getCacheEntry().data).toEqual(undefined) gotFirstValue(await cacheDataLoaded) expect(getCacheEntry().data).toEqual({ value: 'success' }) updateCachedData((draft) => { draft.value = 'TEST' trackCalls() }) expect(trackCalls).toHaveBeenCalledOnce() expect(getCacheEntry().data).toEqual({ value: 'TEST' }) await cacheEntryRemoved expect(getCacheEntry().data).toEqual(undefined) // calling `updateCachedData` when there is no data any more should not do anything updateCachedData((draft) => { draft.value = 'TEST2' trackCalls() }) expect(trackCalls).toHaveBeenCalledOnce() expect(getCacheEntry().data).toEqual(undefined) onCleanup() }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.injected.initiate('arg'), ) await promise promise.unsubscribe() await fakeTimerWaitFor(() => { expect(gotFirstValue).toHaveBeenCalled() }) await vi.advanceTimersByTimeAsync(61000) await fakeTimerWaitFor(() => { expect(onCleanup).toHaveBeenCalled() }) }) test('updateCachedData - infinite query', async () => { const trackCalls = vi.fn() const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ infiniteInjected: build.infiniteQuery<{ value: string }, string, number>({ query: () => '/success', infiniteQueryOptions: { initialPageParam: 1, getNextPageParam: ( lastPage, allPages, lastPageParam, allPageParams, ) => lastPageParam + 1, }, async onCacheEntryAdded( arg, { dispatch, getState, getCacheEntry, updateCachedData, cacheEntryRemoved, cacheDataLoaded, }, ) { expect(getCacheEntry().data).toEqual(undefined) // calling `updateCachedData` when there is no data yet should not do anything updateCachedData((draft) => { draft.pages = [{ value: 'TEST' }] draft.pageParams = [1] trackCalls() }) expect(trackCalls).not.toHaveBeenCalled() expect(getCacheEntry().data).toEqual(undefined) gotFirstValue(await cacheDataLoaded) expect(getCacheEntry().data).toEqual({ pages: [{ value: 'success' }], pageParams: [1], }) updateCachedData((draft) => { draft.pages = [{ value: 'TEST' }] draft.pageParams = [1] trackCalls() }) expect(trackCalls).toHaveBeenCalledOnce() expect(getCacheEntry().data).toEqual({ pages: [{ value: 'TEST' }], pageParams: [1], }) await cacheEntryRemoved expect(getCacheEntry().data).toEqual(undefined) // calling `updateCachedData` when there is no data any more should not do anything updateCachedData((draft) => { draft.pages = [{ value: 'TEST' }, { value: 'TEST2' }] draft.pageParams = [1, 2] trackCalls() }) expect(trackCalls).toHaveBeenCalledOnce() expect(getCacheEntry().data).toEqual(undefined) onCleanup() }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.infiniteInjected.initiate('arg'), ) await promise promise.unsubscribe() await fakeTimerWaitFor(() => { expect(gotFirstValue).toHaveBeenCalled() }) await vi.advanceTimersByTimeAsync(61000) await fakeTimerWaitFor(() => { expect(onCleanup).toHaveBeenCalled() }) }) test('dispatching further actions does not trigger another lifecycle', async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build.query<unknown, void>({ query: () => '/success', async onCacheEntryAdded() { onNewCacheEntry() }, }), }), }) await storeRef.store.dispatch(extended.endpoints.injected.initiate()) expect(onNewCacheEntry).toHaveBeenCalledOnce() await storeRef.store.dispatch(extended.endpoints.injected.initiate()) expect(onNewCacheEntry).toHaveBeenCalledOnce() await storeRef.store.dispatch( extended.endpoints.injected.initiate(undefined, { forceRefetch: true }), ) expect(onNewCacheEntry).toHaveBeenCalledOnce() }) test('dispatching a query initializer with `subscribe: false` does also start a lifecycle', async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build.query<unknown, void>({ query: () => '/success', async onCacheEntryAdded() { onNewCacheEntry() }, }), }), }) await storeRef.store.dispatch( extended.endpoints.injected.initiate(undefined, { subscribe: false }), ) expect(onNewCacheEntry).toHaveBeenCalledOnce() // will not be called a second time though await storeRef.store.dispatch(extended.endpoints.injected.initiate(undefined)) expect(onNewCacheEntry).toHaveBeenCalledOnce() }) test('dispatching a mutation initializer with `track: false` does not start a lifecycle', async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build.mutation<unknown, void>({ query: () => '/success', async onCacheEntryAdded() { onNewCacheEntry() }, }), }), }) await storeRef.store.dispatch( extended.endpoints.injected.initiate(undefined, { track: false }), ) expect(onNewCacheEntry).not.toHaveBeenCalled() await storeRef.store.dispatch(extended.endpoints.injected.initiate(undefined)) expect(onNewCacheEntry).toHaveBeenCalledOnce() })