| 1 | import { createApi } from '@reduxjs/toolkit/query'
|
|---|
| 2 | import { delay } from 'msw'
|
|---|
| 3 | import { actionsReducer, setupApiStore } from '../../tests/utils/helpers'
|
|---|
| 4 | import { vi } from 'vitest'
|
|---|
| 5 |
|
|---|
| 6 | const baseQuery = (args?: any) => ({ data: args })
|
|---|
| 7 | const api = createApi({
|
|---|
| 8 | baseQuery,
|
|---|
| 9 | tagTypes: ['Banana', 'Bread'],
|
|---|
| 10 | endpoints: (build) => ({
|
|---|
| 11 | getBanana: build.query<unknown, number>({
|
|---|
| 12 | query(id) {
|
|---|
| 13 | return { url: `banana/${id}` }
|
|---|
| 14 | },
|
|---|
| 15 | providesTags: ['Banana'],
|
|---|
| 16 | }),
|
|---|
| 17 | getBananas: build.query<unknown, void>({
|
|---|
| 18 | query() {
|
|---|
| 19 | return { url: 'bananas' }
|
|---|
| 20 | },
|
|---|
| 21 | providesTags: ['Banana'],
|
|---|
| 22 | }),
|
|---|
| 23 | getBread: build.query<unknown, number>({
|
|---|
| 24 | query(id) {
|
|---|
| 25 | return { url: `bread/${id}` }
|
|---|
| 26 | },
|
|---|
| 27 | providesTags: ['Bread'],
|
|---|
| 28 | }),
|
|---|
| 29 | invalidateFruit: build.mutation({
|
|---|
| 30 | query: (fruit?: 'Banana' | 'Bread' | null) => ({
|
|---|
| 31 | url: `invalidate/fruit/${fruit || ''}`,
|
|---|
| 32 | }),
|
|---|
| 33 | invalidatesTags(result, error, arg) {
|
|---|
| 34 | return [arg]
|
|---|
| 35 | },
|
|---|
| 36 | }),
|
|---|
| 37 | }),
|
|---|
| 38 | })
|
|---|
| 39 | const { getBanana, getBread, invalidateFruit } = api.endpoints
|
|---|
| 40 |
|
|---|
| 41 | const storeRef = setupApiStore(api, {
|
|---|
| 42 | ...actionsReducer,
|
|---|
| 43 | })
|
|---|
| 44 |
|
|---|
| 45 | it('invalidates the specified tags', async () => {
|
|---|
| 46 | await storeRef.store.dispatch(getBanana.initiate(1))
|
|---|
| 47 | expect(storeRef.store.getState().actions).toMatchSequence(
|
|---|
| 48 | api.internalActions.middlewareRegistered.match,
|
|---|
| 49 | getBanana.matchPending,
|
|---|
| 50 | getBanana.matchFulfilled,
|
|---|
| 51 | )
|
|---|
| 52 |
|
|---|
| 53 | await storeRef.store.dispatch(api.util.invalidateTags(['Banana', 'Bread']))
|
|---|
| 54 |
|
|---|
| 55 | // Slight pause to let the middleware run and such
|
|---|
| 56 | await delay(20)
|
|---|
| 57 |
|
|---|
| 58 | const firstSequence = [
|
|---|
| 59 | api.internalActions.middlewareRegistered.match,
|
|---|
| 60 | getBanana.matchPending,
|
|---|
| 61 | getBanana.matchFulfilled,
|
|---|
| 62 | api.util.invalidateTags.match,
|
|---|
| 63 | getBanana.matchPending,
|
|---|
| 64 | getBanana.matchFulfilled,
|
|---|
| 65 | ]
|
|---|
| 66 | expect(storeRef.store.getState().actions).toMatchSequence(...firstSequence)
|
|---|
| 67 |
|
|---|
| 68 | await storeRef.store.dispatch(getBread.initiate(1))
|
|---|
| 69 | await storeRef.store.dispatch(api.util.invalidateTags([{ type: 'Bread' }]))
|
|---|
| 70 |
|
|---|
| 71 | await delay(20)
|
|---|
| 72 |
|
|---|
| 73 | expect(storeRef.store.getState().actions).toMatchSequence(
|
|---|
| 74 | ...firstSequence,
|
|---|
| 75 | getBread.matchPending,
|
|---|
| 76 | getBread.matchFulfilled,
|
|---|
| 77 | api.util.invalidateTags.match,
|
|---|
| 78 | getBread.matchPending,
|
|---|
| 79 | getBread.matchFulfilled,
|
|---|
| 80 | )
|
|---|
| 81 | })
|
|---|
| 82 |
|
|---|
| 83 | it('invalidates tags correctly when null or undefined are provided as tags', async () => {
|
|---|
| 84 | await storeRef.store.dispatch(getBanana.initiate(1))
|
|---|
| 85 | await storeRef.store.dispatch(
|
|---|
| 86 | api.util.invalidateTags([undefined, null, 'Banana']),
|
|---|
| 87 | )
|
|---|
| 88 |
|
|---|
| 89 | // Slight pause to let the middleware run and such
|
|---|
| 90 | await delay(20)
|
|---|
| 91 |
|
|---|
| 92 | const apiActions = [
|
|---|
| 93 | api.internalActions.middlewareRegistered.match,
|
|---|
| 94 | getBanana.matchPending,
|
|---|
| 95 | getBanana.matchFulfilled,
|
|---|
| 96 | api.util.invalidateTags.match,
|
|---|
| 97 | getBanana.matchPending,
|
|---|
| 98 | getBanana.matchFulfilled,
|
|---|
| 99 | ]
|
|---|
| 100 |
|
|---|
| 101 | expect(storeRef.store.getState().actions).toMatchSequence(...apiActions)
|
|---|
| 102 | })
|
|---|
| 103 |
|
|---|
| 104 | it.each([
|
|---|
| 105 | {
|
|---|
| 106 | tags: [undefined, null, 'Bread'] as Parameters<
|
|---|
| 107 | typeof api.util.invalidateTags
|
|---|
| 108 | >['0'],
|
|---|
| 109 | },
|
|---|
| 110 | { tags: [undefined, null] },
|
|---|
| 111 | { tags: [] },
|
|---|
| 112 | ])(
|
|---|
| 113 | 'does not invalidate with tags=$tags if no query matches',
|
|---|
| 114 | async ({ tags }) => {
|
|---|
| 115 | await storeRef.store.dispatch(getBanana.initiate(1))
|
|---|
| 116 | await storeRef.store.dispatch(api.util.invalidateTags(tags))
|
|---|
| 117 |
|
|---|
| 118 | // Slight pause to let the middleware run and such
|
|---|
| 119 | await delay(20)
|
|---|
| 120 |
|
|---|
| 121 | const apiActions = [
|
|---|
| 122 | api.internalActions.middlewareRegistered.match,
|
|---|
| 123 | getBanana.matchPending,
|
|---|
| 124 | getBanana.matchFulfilled,
|
|---|
| 125 | api.util.invalidateTags.match,
|
|---|
| 126 | ]
|
|---|
| 127 |
|
|---|
| 128 | expect(storeRef.store.getState().actions).toMatchSequence(...apiActions)
|
|---|
| 129 | },
|
|---|
| 130 | )
|
|---|
| 131 |
|
|---|
| 132 | it.each([
|
|---|
| 133 | { mutationArg: 'Bread' as 'Bread' | null | undefined },
|
|---|
| 134 | { mutationArg: undefined },
|
|---|
| 135 | { mutationArg: null },
|
|---|
| 136 | ])(
|
|---|
| 137 | 'does not invalidate queries when a mutation with tags=[$mutationArg] runs and does not match anything',
|
|---|
| 138 | async ({ mutationArg }) => {
|
|---|
| 139 | await storeRef.store.dispatch(getBanana.initiate(1))
|
|---|
| 140 | await storeRef.store.dispatch(invalidateFruit.initiate(mutationArg))
|
|---|
| 141 |
|
|---|
| 142 | // Slight pause to let the middleware run and such
|
|---|
| 143 | await delay(20)
|
|---|
| 144 |
|
|---|
| 145 | const apiActions = [
|
|---|
| 146 | api.internalActions.middlewareRegistered.match,
|
|---|
| 147 | getBanana.matchPending,
|
|---|
| 148 | getBanana.matchFulfilled,
|
|---|
| 149 | invalidateFruit.matchPending,
|
|---|
| 150 | invalidateFruit.matchFulfilled,
|
|---|
| 151 | ]
|
|---|
| 152 |
|
|---|
| 153 | expect(storeRef.store.getState().actions).toMatchSequence(...apiActions)
|
|---|
| 154 | },
|
|---|
| 155 | )
|
|---|
| 156 |
|
|---|
| 157 | it('correctly stringifies subscription state and dispatches subscriptionsUpdated', async () => {
|
|---|
| 158 | // Create a fresh store for this test to avoid interference
|
|---|
| 159 | const testStoreRef = setupApiStore(
|
|---|
| 160 | api,
|
|---|
| 161 | {
|
|---|
| 162 | ...actionsReducer,
|
|---|
| 163 | },
|
|---|
| 164 | { withoutListeners: true },
|
|---|
| 165 | )
|
|---|
| 166 |
|
|---|
| 167 | // Start multiple subscriptions
|
|---|
| 168 | const subscription1 = testStoreRef.store.dispatch(
|
|---|
| 169 | getBanana.initiate(1, {
|
|---|
| 170 | subscriptionOptions: { pollingInterval: 1000 },
|
|---|
| 171 | }),
|
|---|
| 172 | )
|
|---|
| 173 | const subscription2 = testStoreRef.store.dispatch(
|
|---|
| 174 | getBanana.initiate(2, {
|
|---|
| 175 | subscriptionOptions: { refetchOnFocus: true },
|
|---|
| 176 | }),
|
|---|
| 177 | )
|
|---|
| 178 | const subscription3 = testStoreRef.store.dispatch(
|
|---|
| 179 | api.endpoints.getBananas.initiate(),
|
|---|
| 180 | )
|
|---|
| 181 |
|
|---|
| 182 | // Wait for the subscriptions to be established
|
|---|
| 183 | await Promise.all([subscription1, subscription2, subscription3])
|
|---|
| 184 |
|
|---|
| 185 | // Wait for the subscription sync timer (500ms + buffer)
|
|---|
| 186 | await delay(600)
|
|---|
| 187 |
|
|---|
| 188 | // Check the final subscription state in the store
|
|---|
| 189 | const finalState = testStoreRef.store.getState()
|
|---|
| 190 | const subscriptionState = finalState[api.reducerPath].subscriptions
|
|---|
| 191 |
|
|---|
| 192 | // Should have subscriptions for getBanana(1), getBanana(2), and getBananas()
|
|---|
| 193 | expect(subscriptionState).toMatchObject({
|
|---|
| 194 | 'getBanana(1)': {
|
|---|
| 195 | [subscription1.requestId]: { pollingInterval: 1000 },
|
|---|
| 196 | },
|
|---|
| 197 | 'getBanana(2)': {
|
|---|
| 198 | [subscription2.requestId]: { refetchOnFocus: true },
|
|---|
| 199 | },
|
|---|
| 200 | 'getBananas(undefined)': {
|
|---|
| 201 | [subscription3.requestId]: {},
|
|---|
| 202 | },
|
|---|
| 203 | })
|
|---|
| 204 |
|
|---|
| 205 | // Verify the subscription entries have the expected structure
|
|---|
| 206 | expect(Object.keys(subscriptionState)).toHaveLength(3)
|
|---|
| 207 | expect(subscriptionState['getBanana(1)']?.[subscription1.requestId]).toEqual({
|
|---|
| 208 | pollingInterval: 1000,
|
|---|
| 209 | })
|
|---|
| 210 | expect(subscriptionState['getBanana(2)']?.[subscription2.requestId]).toEqual({
|
|---|
| 211 | refetchOnFocus: true,
|
|---|
| 212 | })
|
|---|
| 213 | expect(
|
|---|
| 214 | subscriptionState['getBananas(undefined)']?.[subscription3.requestId],
|
|---|
| 215 | ).toEqual({})
|
|---|
| 216 | })
|
|---|
| 217 |
|
|---|
| 218 | it('does not leak subscription state between multiple stores using the same API instance (SSR scenario)', async () => {
|
|---|
| 219 | vi.useFakeTimers()
|
|---|
| 220 | // Simulate SSR: create API once at module level
|
|---|
| 221 | const sharedApi = createApi({
|
|---|
| 222 | baseQuery: (args?: any) => ({ data: args }),
|
|---|
| 223 | tagTypes: ['Test'],
|
|---|
| 224 | endpoints: (build) => ({
|
|---|
| 225 | getTest: build.query<unknown, number>({
|
|---|
| 226 | query(id) {
|
|---|
| 227 | return { url: `test/${id}` }
|
|---|
| 228 | },
|
|---|
| 229 | }),
|
|---|
| 230 | }),
|
|---|
| 231 | })
|
|---|
| 232 |
|
|---|
| 233 | // Create first store (simulating first SSR request)
|
|---|
| 234 | const store1Ref = setupApiStore(sharedApi, {}, { withoutListeners: true })
|
|---|
| 235 |
|
|---|
| 236 | // Add subscription in store1
|
|---|
| 237 | const sub1 = store1Ref.store.dispatch(
|
|---|
| 238 | sharedApi.endpoints.getTest.initiate(1, {
|
|---|
| 239 | subscriptionOptions: { pollingInterval: 1000 },
|
|---|
| 240 | }),
|
|---|
| 241 | )
|
|---|
| 242 | vi.advanceTimersByTime(10)
|
|---|
| 243 | await sub1
|
|---|
| 244 |
|
|---|
| 245 | // Wait for subscription sync (500ms + buffer)
|
|---|
| 246 | vi.advanceTimersByTime(600)
|
|---|
| 247 |
|
|---|
| 248 | // Verify store1 has the subscription
|
|---|
| 249 | const store1SubscriptionSelectors = store1Ref.store.dispatch(
|
|---|
| 250 | sharedApi.internalActions.internal_getRTKQSubscriptions(),
|
|---|
| 251 | ) as any
|
|---|
| 252 | const store1InternalSubs = store1SubscriptionSelectors.getSubscriptions()
|
|---|
| 253 | expect(store1InternalSubs.size).toBe(1)
|
|---|
| 254 |
|
|---|
| 255 | // Create second store (simulating second SSR request)
|
|---|
| 256 | const store2Ref = setupApiStore(sharedApi, {}, { withoutListeners: true })
|
|---|
| 257 |
|
|---|
| 258 | // Check subscriptions via internal action
|
|---|
| 259 | const store2SubscriptionSelectors = store2Ref.store.dispatch(
|
|---|
| 260 | sharedApi.internalActions.internal_getRTKQSubscriptions(),
|
|---|
| 261 | ) as any
|
|---|
| 262 |
|
|---|
| 263 | const store2InternalSubs = store2SubscriptionSelectors.getSubscriptions()
|
|---|
| 264 |
|
|---|
| 265 | expect(store2InternalSubs.size).toBe(0)
|
|---|
| 266 | })
|
|---|