diff --git a/packages/toolkit/src/query/core/buildMiddleware/cacheCollection.ts b/packages/toolkit/src/query/core/buildMiddleware/cacheCollection.ts index 128c0ebe1..b7f8217bc 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/cacheCollection.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/cacheCollection.ts @@ -28,6 +28,13 @@ declare module '../../endpointDefinitions' { } } +// Per https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value , browsers store +// `setTimeout()` timer values in a 32-bit int. If we pass a value in that's larger than that, +// it wraps and ends up executing immediately. +// Our `keepUnusedDataFor` values are in seconds, so adjust the numbers here accordingly. +export const THIRTY_TWO_BIT_MAX_INT = 2_147_483_647 +export const THIRTY_TWO_BIT_MAX_TIMER_SECONDS = 2_147_483_647 / 1_000 - 1 + export const build: SubMiddlewareBuilder = ({ reducerPath, api, context }) => { const { removeQueryResult, unsubscribeQueryResult } = api.internalActions @@ -87,6 +94,14 @@ export const build: SubMiddlewareBuilder = ({ reducerPath, api, context }) => { ] as QueryDefinition const keepUnusedDataFor = endpointDefinition?.keepUnusedDataFor ?? config.keepUnusedDataFor + // Prevent `setTimeout` timers from overflowing a 32-bit internal int, by + // clamping the max value to be at most 1000ms less than the 32-bit max. + // Look, a 24.8-day keepalive ought to be enough for anybody, right? :) + // Also avoid negative values too. + const finalKeepUnusedDataFor = Math.max( + 0, + Math.min(keepUnusedDataFor, THIRTY_TWO_BIT_MAX_TIMER_SECONDS) + ) const currentTimeout = currentRemovalTimeouts[queryCacheKey] if (currentTimeout) { @@ -99,7 +114,7 @@ export const build: SubMiddlewareBuilder = ({ reducerPath, api, context }) => { api.dispatch(removeQueryResult({ queryCacheKey })) } delete currentRemovalTimeouts![queryCacheKey] - }, keepUnusedDataFor * 1000) + }, finalKeepUnusedDataFor * 1000) } } } diff --git a/packages/toolkit/src/query/tests/cacheCollection.test.ts b/packages/toolkit/src/query/tests/cacheCollection.test.ts index d0f21f8d2..80eb3ac9e 100644 --- a/packages/toolkit/src/query/tests/cacheCollection.test.ts +++ b/packages/toolkit/src/query/tests/cacheCollection.test.ts @@ -2,6 +2,10 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query' import { configureStore } from '@reduxjs/toolkit' import { waitMs } from './helpers' import type { Middleware, Reducer } from 'redux' +import { + THIRTY_TWO_BIT_MAX_INT, + THIRTY_TWO_BIT_MAX_TIMER_SECONDS, +} from '../core/buildMiddleware/cacheCollection' beforeAll(() => { jest.useFakeTimers('legacy') @@ -52,6 +56,35 @@ test(`query: await cleanup, keepUnusedDataFor set`, async () => { expect(onCleanup).toHaveBeenCalled() }) +test(`query: handles large keepUnuseDataFor values over 32-bit ms`, async () => { + const { store, api } = storeForApi( + createApi({ + baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), + endpoints: (build) => ({ + query: build.query({ + query: () => '/success', + }), + }), + keepUnusedDataFor: THIRTY_TWO_BIT_MAX_TIMER_SECONDS - 10, + }) + ) + + store.dispatch(api.endpoints.query.initiate('arg')).unsubscribe() + + // Shouldn't have been called right away + jest.advanceTimersByTime(1000), await waitMs() + expect(onCleanup).not.toHaveBeenCalled() + + // Shouldn't have been called any time in the next few minutes + jest.advanceTimersByTime(1_000_000), await waitMs() + expect(onCleanup).not.toHaveBeenCalled() + + // _Should_ be called _wayyyy_ in the future (like 24.8 days from now) + jest.advanceTimersByTime(THIRTY_TWO_BIT_MAX_TIMER_SECONDS * 1000), + await waitMs() + expect(onCleanup).toHaveBeenCalled() +}) + describe(`query: await cleanup, keepUnusedDataFor set`, () => { const { store, api } = storeForApi( createApi({