Spaces:
Sleeping
Sleeping
| import { createApi } from '@reduxjs/toolkit/query' | |
| import { delay } from 'msw' | |
| import { actionsReducer, setupApiStore } from '../../tests/utils/helpers' | |
| import { vi } from 'vitest' | |
| const baseQuery = (args?: any) => ({ data: args }) | |
| const api = createApi({ | |
| baseQuery, | |
| tagTypes: ['Banana', 'Bread'], | |
| endpoints: (build) => ({ | |
| getBanana: build.query<unknown, number>({ | |
| query(id) { | |
| return { url: `banana/${id}` } | |
| }, | |
| providesTags: ['Banana'], | |
| }), | |
| getBananas: build.query<unknown, void>({ | |
| query() { | |
| return { url: 'bananas' } | |
| }, | |
| providesTags: ['Banana'], | |
| }), | |
| getBread: build.query<unknown, number>({ | |
| query(id) { | |
| return { url: `bread/${id}` } | |
| }, | |
| providesTags: ['Bread'], | |
| }), | |
| invalidateFruit: build.mutation({ | |
| query: (fruit?: 'Banana' | 'Bread' | null) => ({ | |
| url: `invalidate/fruit/${fruit || ''}`, | |
| }), | |
| invalidatesTags(result, error, arg) { | |
| return [arg] | |
| }, | |
| }), | |
| }), | |
| }) | |
| const { getBanana, getBread, invalidateFruit } = api.endpoints | |
| const storeRef = setupApiStore(api, { | |
| ...actionsReducer, | |
| }) | |
| it('invalidates the specified tags', async () => { | |
| await storeRef.store.dispatch(getBanana.initiate(1)) | |
| expect(storeRef.store.getState().actions).toMatchSequence( | |
| api.internalActions.middlewareRegistered.match, | |
| getBanana.matchPending, | |
| getBanana.matchFulfilled, | |
| ) | |
| await storeRef.store.dispatch(api.util.invalidateTags(['Banana', 'Bread'])) | |
| // Slight pause to let the middleware run and such | |
| await delay(20) | |
| const firstSequence = [ | |
| api.internalActions.middlewareRegistered.match, | |
| getBanana.matchPending, | |
| getBanana.matchFulfilled, | |
| api.util.invalidateTags.match, | |
| getBanana.matchPending, | |
| getBanana.matchFulfilled, | |
| ] | |
| expect(storeRef.store.getState().actions).toMatchSequence(...firstSequence) | |
| await storeRef.store.dispatch(getBread.initiate(1)) | |
| await storeRef.store.dispatch(api.util.invalidateTags([{ type: 'Bread' }])) | |
| await delay(20) | |
| expect(storeRef.store.getState().actions).toMatchSequence( | |
| ...firstSequence, | |
| getBread.matchPending, | |
| getBread.matchFulfilled, | |
| api.util.invalidateTags.match, | |
| getBread.matchPending, | |
| getBread.matchFulfilled, | |
| ) | |
| }) | |
| it('invalidates tags correctly when null or undefined are provided as tags', async () => { | |
| await storeRef.store.dispatch(getBanana.initiate(1)) | |
| await storeRef.store.dispatch( | |
| api.util.invalidateTags([undefined, null, 'Banana']), | |
| ) | |
| // Slight pause to let the middleware run and such | |
| await delay(20) | |
| const apiActions = [ | |
| api.internalActions.middlewareRegistered.match, | |
| getBanana.matchPending, | |
| getBanana.matchFulfilled, | |
| api.util.invalidateTags.match, | |
| getBanana.matchPending, | |
| getBanana.matchFulfilled, | |
| ] | |
| expect(storeRef.store.getState().actions).toMatchSequence(...apiActions) | |
| }) | |
| it.each([ | |
| { | |
| tags: [undefined, null, 'Bread'] as Parameters< | |
| typeof api.util.invalidateTags | |
| >['0'], | |
| }, | |
| { tags: [undefined, null] }, | |
| { tags: [] }, | |
| ])( | |
| 'does not invalidate with tags=$tags if no query matches', | |
| async ({ tags }) => { | |
| await storeRef.store.dispatch(getBanana.initiate(1)) | |
| await storeRef.store.dispatch(api.util.invalidateTags(tags)) | |
| // Slight pause to let the middleware run and such | |
| await delay(20) | |
| const apiActions = [ | |
| api.internalActions.middlewareRegistered.match, | |
| getBanana.matchPending, | |
| getBanana.matchFulfilled, | |
| api.util.invalidateTags.match, | |
| ] | |
| expect(storeRef.store.getState().actions).toMatchSequence(...apiActions) | |
| }, | |
| ) | |
| it.each([ | |
| { mutationArg: 'Bread' as 'Bread' | null | undefined }, | |
| { mutationArg: undefined }, | |
| { mutationArg: null }, | |
| ])( | |
| 'does not invalidate queries when a mutation with tags=[$mutationArg] runs and does not match anything', | |
| async ({ mutationArg }) => { | |
| await storeRef.store.dispatch(getBanana.initiate(1)) | |
| await storeRef.store.dispatch(invalidateFruit.initiate(mutationArg)) | |
| // Slight pause to let the middleware run and such | |
| await delay(20) | |
| const apiActions = [ | |
| api.internalActions.middlewareRegistered.match, | |
| getBanana.matchPending, | |
| getBanana.matchFulfilled, | |
| invalidateFruit.matchPending, | |
| invalidateFruit.matchFulfilled, | |
| ] | |
| expect(storeRef.store.getState().actions).toMatchSequence(...apiActions) | |
| }, | |
| ) | |
| it('correctly stringifies subscription state and dispatches subscriptionsUpdated', async () => { | |
| // Create a fresh store for this test to avoid interference | |
| const testStoreRef = setupApiStore( | |
| api, | |
| { | |
| ...actionsReducer, | |
| }, | |
| { withoutListeners: true }, | |
| ) | |
| // Start multiple subscriptions | |
| const subscription1 = testStoreRef.store.dispatch( | |
| getBanana.initiate(1, { | |
| subscriptionOptions: { pollingInterval: 1000 }, | |
| }), | |
| ) | |
| const subscription2 = testStoreRef.store.dispatch( | |
| getBanana.initiate(2, { | |
| subscriptionOptions: { refetchOnFocus: true }, | |
| }), | |
| ) | |
| const subscription3 = testStoreRef.store.dispatch( | |
| api.endpoints.getBananas.initiate(), | |
| ) | |
| // Wait for the subscriptions to be established | |
| await Promise.all([subscription1, subscription2, subscription3]) | |
| // Wait for the subscription sync timer (500ms + buffer) | |
| await delay(600) | |
| // Check the final subscription state in the store | |
| const finalState = testStoreRef.store.getState() | |
| const subscriptionState = finalState[api.reducerPath].subscriptions | |
| // Should have subscriptions for getBanana(1), getBanana(2), and getBananas() | |
| expect(subscriptionState).toMatchObject({ | |
| 'getBanana(1)': { | |
| [subscription1.requestId]: { pollingInterval: 1000 }, | |
| }, | |
| 'getBanana(2)': { | |
| [subscription2.requestId]: { refetchOnFocus: true }, | |
| }, | |
| 'getBananas(undefined)': { | |
| [subscription3.requestId]: {}, | |
| }, | |
| }) | |
| // Verify the subscription entries have the expected structure | |
| expect(Object.keys(subscriptionState)).toHaveLength(3) | |
| expect(subscriptionState['getBanana(1)']?.[subscription1.requestId]).toEqual({ | |
| pollingInterval: 1000, | |
| }) | |
| expect(subscriptionState['getBanana(2)']?.[subscription2.requestId]).toEqual({ | |
| refetchOnFocus: true, | |
| }) | |
| expect( | |
| subscriptionState['getBananas(undefined)']?.[subscription3.requestId], | |
| ).toEqual({}) | |
| }) | |
| it('does not leak subscription state between multiple stores using the same API instance (SSR scenario)', async () => { | |
| vi.useFakeTimers() | |
| // Simulate SSR: create API once at module level | |
| const sharedApi = createApi({ | |
| baseQuery: (args?: any) => ({ data: args }), | |
| tagTypes: ['Test'], | |
| endpoints: (build) => ({ | |
| getTest: build.query<unknown, number>({ | |
| query(id) { | |
| return { url: `test/${id}` } | |
| }, | |
| }), | |
| }), | |
| }) | |
| // Create first store (simulating first SSR request) | |
| const store1Ref = setupApiStore(sharedApi, {}, { withoutListeners: true }) | |
| // Add subscription in store1 | |
| const sub1 = store1Ref.store.dispatch( | |
| sharedApi.endpoints.getTest.initiate(1, { | |
| subscriptionOptions: { pollingInterval: 1000 }, | |
| }), | |
| ) | |
| vi.advanceTimersByTime(10) | |
| await sub1 | |
| // Wait for subscription sync (500ms + buffer) | |
| vi.advanceTimersByTime(600) | |
| // Verify store1 has the subscription | |
| const store1SubscriptionSelectors = store1Ref.store.dispatch( | |
| sharedApi.internalActions.internal_getRTKQSubscriptions(), | |
| ) as any | |
| const store1InternalSubs = store1SubscriptionSelectors.getSubscriptions() | |
| expect(store1InternalSubs.size).toBe(1) | |
| // Create second store (simulating second SSR request) | |
| const store2Ref = setupApiStore(sharedApi, {}, { withoutListeners: true }) | |
| // Check subscriptions via internal action | |
| const store2SubscriptionSelectors = store2Ref.store.dispatch( | |
| sharedApi.internalActions.internal_getRTKQSubscriptions(), | |
| ) as any | |
| const store2InternalSubs = store2SubscriptionSelectors.getSubscriptions() | |
| expect(store2InternalSubs.size).toBe(0) | |
| }) | |