From a52ecf951bc6fc50444ab7af65d452e5b492e5b9 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Sun, 12 Apr 2026 13:38:26 +0100 Subject: [PATCH 1/2] prevent registration of query middleware to restart dispatch from the beginning --- .../core/buildMiddleware/devMiddleware.ts | 24 +---------- .../src/query/core/buildMiddleware/index.ts | 9 +++-- .../core/buildMiddleware/registration.ts | 40 +++++++++++++++++++ .../src/query/tests/buildMiddleware.test.tsx | 29 ++++++++++++++ 4 files changed, 76 insertions(+), 26 deletions(-) create mode 100644 packages/toolkit/src/query/core/buildMiddleware/registration.ts diff --git a/packages/toolkit/src/query/core/buildMiddleware/devMiddleware.ts b/packages/toolkit/src/query/core/buildMiddleware/devMiddleware.ts index caec68a216..df6e285f6c 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/devMiddleware.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/devMiddleware.ts @@ -1,4 +1,5 @@ import type { InternalHandlerBuilder } from './types' +import { registerMiddleware } from './registration' export const buildDevCheckHandler: InternalHandlerBuilder = ({ api, @@ -7,28 +8,7 @@ export const buildDevCheckHandler: InternalHandlerBuilder = ({ }) => { return (action, mwApi) => { if (api.util.resetApiState.match(action)) { - // dispatch after api reset - mwApi.dispatch(api.internalActions.middlewareRegistered(apiUid)) - } - - if ( - typeof process !== 'undefined' && - process.env.NODE_ENV === 'development' - ) { - if ( - api.internalActions.middlewareRegistered.match(action) && - action.payload === apiUid && - mwApi.getState()[reducerPath]?.config?.middlewareRegistered === - 'conflict' - ) { - console.warn(`There is a mismatch between slice and middleware for the reducerPath "${reducerPath}". -You can only have one api per reducer path, this will lead to crashes in various situations!${ - reducerPath === 'api' - ? ` -If you have multiple apis, you *have* to specify the reducerPath option when using createApi!` - : '' - }`) - } + registerMiddleware(api, mwApi, apiUid, reducerPath) } } } diff --git a/packages/toolkit/src/query/core/buildMiddleware/index.ts b/packages/toolkit/src/query/core/buildMiddleware/index.ts index 0a7bc92222..1cd1e8f3b5 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/index.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/index.ts @@ -18,6 +18,7 @@ import { buildDevCheckHandler } from './devMiddleware' import { buildInvalidationByTagsHandler } from './invalidationByTags' import { buildPollingHandler } from './polling' import { buildQueryLifecycleHandler } from './queryLifecycle' +import { registerMiddleware } from './registration' import type { BuildMiddlewareInput, InternalHandlerBuilder, @@ -97,14 +98,14 @@ export function buildMiddleware< if (!isAction(action)) { return next(action) } + + const mwApiWithNext = { ...mwApi, next } + if (!initialized) { initialized = true - // dispatch before any other action - mwApi.dispatch(api.internalActions.middlewareRegistered(apiUid)) + registerMiddleware(api, mwApiWithNext, apiUid, reducerPath) } - const mwApiWithNext = { ...mwApi, next } - const stateBefore = mwApi.getState() const [actionShouldContinue, internalProbeResult] = diff --git a/packages/toolkit/src/query/core/buildMiddleware/registration.ts b/packages/toolkit/src/query/core/buildMiddleware/registration.ts new file mode 100644 index 0000000000..a930d217af --- /dev/null +++ b/packages/toolkit/src/query/core/buildMiddleware/registration.ts @@ -0,0 +1,40 @@ +import type { SubMiddlewareApi } from './types' + +type MiddlewareRegistrationAction = { + type: string + payload: string +} + +type MiddlewareRegistrationApi = { + internalActions: { + middlewareRegistered(apiUid: string): MiddlewareRegistrationAction + } +} + +export function registerMiddleware( + api: MiddlewareRegistrationApi, + mwApi: SubMiddlewareApi & { + next(action: MiddlewareRegistrationAction): unknown + }, + apiUid: string, + reducerPath: string, +) { + // Forward the registration action through the remaining chain only. + // Restarting from the top-level dispatch can overflow the call stack + // when many RTK Query middlewares initialize on the same action. + mwApi.next(api.internalActions.middlewareRegistered(apiUid)) + + if ( + typeof process !== 'undefined' && + process.env.NODE_ENV === 'development' && + mwApi.getState()[reducerPath]?.config?.middlewareRegistered === 'conflict' + ) { + console.warn(`There is a mismatch between slice and middleware for the reducerPath "${reducerPath}". +You can only have one api per reducer path, this will lead to crashes in various situations!${ + reducerPath === 'api' + ? ` +If you have multiple apis, you *have* to specify the reducerPath option when using createApi!` + : '' + }`) + } +} diff --git a/packages/toolkit/src/query/tests/buildMiddleware.test.tsx b/packages/toolkit/src/query/tests/buildMiddleware.test.tsx index 3fb27f219e..87d9610688 100644 --- a/packages/toolkit/src/query/tests/buildMiddleware.test.tsx +++ b/packages/toolkit/src/query/tests/buildMiddleware.test.tsx @@ -1,3 +1,4 @@ +import { configureStore } from '@reduxjs/toolkit' import { createApi } from '@reduxjs/toolkit/query' import { delay } from 'msw' import { actionsReducer, setupApiStore } from '../../tests/utils/helpers' @@ -264,3 +265,31 @@ it('does not leak subscription state between multiple stores using the same API expect(store2InternalSubs.size).toBe(0) }) + +it('initializes a large RTK Query middleware chain without overflowing the stack', async () => { + const middlewareChain = Array.from({ length: 1000 }, () => { + const middleware = api.middleware + return ((mwApi) => middleware(mwApi)) as typeof api.middleware + }) + + const store = configureStore({ + reducer: { [api.reducerPath]: api.reducer }, + middleware: (gdm) => + gdm({ + serializableCheck: false, + immutableCheck: false, + }).concat(...middlewareChain), + }) + + await store.dispatch(getBanana.initiate(1)) + + expect(store.getState()[api.reducerPath].config.middlewareRegistered).toBe( + true, + ) + expect(store.getState()[api.reducerPath].queries['getBanana(1)']).toEqual( + expect.objectContaining({ + data: { url: 'banana/1' }, + status: 'fulfilled', + }), + ) +}) From 60e78e46191dc8ae1da76f390fd96215fcc7e1fb Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Sun, 12 Apr 2026 14:40:53 +0100 Subject: [PATCH 2/2] add timeout to test --- .../src/query/tests/buildMiddleware.test.tsx | 58 ++++++++++--------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/packages/toolkit/src/query/tests/buildMiddleware.test.tsx b/packages/toolkit/src/query/tests/buildMiddleware.test.tsx index 87d9610688..80185bd164 100644 --- a/packages/toolkit/src/query/tests/buildMiddleware.test.tsx +++ b/packages/toolkit/src/query/tests/buildMiddleware.test.tsx @@ -266,30 +266,34 @@ it('does not leak subscription state between multiple stores using the same API expect(store2InternalSubs.size).toBe(0) }) -it('initializes a large RTK Query middleware chain without overflowing the stack', async () => { - const middlewareChain = Array.from({ length: 1000 }, () => { - const middleware = api.middleware - return ((mwApi) => middleware(mwApi)) as typeof api.middleware - }) - - const store = configureStore({ - reducer: { [api.reducerPath]: api.reducer }, - middleware: (gdm) => - gdm({ - serializableCheck: false, - immutableCheck: false, - }).concat(...middlewareChain), - }) - - await store.dispatch(getBanana.initiate(1)) - - expect(store.getState()[api.reducerPath].config.middlewareRegistered).toBe( - true, - ) - expect(store.getState()[api.reducerPath].queries['getBanana(1)']).toEqual( - expect.objectContaining({ - data: { url: 'banana/1' }, - status: 'fulfilled', - }), - ) -}) +it( + 'initializes a large RTK Query middleware chain without overflowing the stack', + { timeout: 120_000 }, + async () => { + const middlewareChain = Array.from({ length: 1000 }, () => { + const middleware = api.middleware + return ((mwApi) => middleware(mwApi)) as typeof api.middleware + }) + + const store = configureStore({ + reducer: { [api.reducerPath]: api.reducer }, + middleware: (gdm) => + gdm({ + serializableCheck: false, + immutableCheck: false, + }).concat(...middlewareChain), + }) + + await store.dispatch(getBanana.initiate(1)) + + expect(store.getState()[api.reducerPath].config.middlewareRegistered).toBe( + true, + ) + expect(store.getState()[api.reducerPath].queries['getBanana(1)']).toEqual( + expect.objectContaining({ + data: { url: 'banana/1' }, + status: 'fulfilled', + }), + ) + }, +)