diff --git a/CHANGELOG.md b/CHANGELOG.md index a8ce621bb3..303ecda07f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ ### Fixes +- Don't start the `AsyncExpiringMap` cleanup interval at import time, and restart it after the map empties ([#6811](https://github.com/getsentry/sentry-react-native/pull/6811)) - Prevent Sentry's iOS `-force_load` flag from being dropped when another pod sets `OTHER_LDFLAGS[sdk=…]` ([#6801](https://github.com/getsentry/sentry-react-native/pull/6801)) - Fix Android build failing with `cannot find symbol ReplayIntegration` when `sentry-android-replay` is excluded ([#6803](https://github.com/getsentry/sentry-react-native/pull/6803)) - Preserve already-quoted React Native bundle script paths in the Expo iOS plugin ([#6796](https://github.com/getsentry/sentry-react-native/pull/6796)) diff --git a/packages/core/src/js/utils/AsyncExpiringMap.ts b/packages/core/src/js/utils/AsyncExpiringMap.ts index bba14e01f1..2b7efe53df 100644 --- a/packages/core/src/js/utils/AsyncExpiringMap.ts +++ b/packages/core/src/js/utils/AsyncExpiringMap.ts @@ -19,7 +19,8 @@ export class AsyncExpiringMap { this._ttl = ttl; this._map = new Map(); this._cleanupIntervalMs = cleanupInterval; - this.startCleanup(); + // The cleanup interval is started lazily on the first `set()`. Starting it here would keep the + // interval (and therefore a Node/Jest process) alive just from importing the module, with nothing to clean. } /** @@ -143,9 +144,7 @@ export class AsyncExpiringMap { * Clear all entries. */ public clear(): void { - if (this._cleanupInterval) { - clearInterval(this._cleanupInterval); - } + this.stopCleanup(); this._map.clear(); } @@ -155,6 +154,9 @@ export class AsyncExpiringMap { public stopCleanup(): void { if (this._cleanupInterval) { clearInterval(this._cleanupInterval); + // Reset so `set()` can restart cleanup on demand. Without this the handle stays truthy after being + // cleared, so `set()` never re-arms the interval and later entries are only evicted lazily on access. + this._cleanupInterval = undefined; } } @@ -162,6 +164,11 @@ export class AsyncExpiringMap { * Start the cleanup interval. */ public startCleanup(): void { - this._cleanupInterval = setInterval(() => this.cleanup(), this._cleanupIntervalMs); + const interval = setInterval(() => this.cleanup(), this._cleanupIntervalMs); + // `unref` exists on Node timers (Jest, tests, tooling) but not on the React Native `setInterval` number + // (typed as `number` here), so access it defensively. It ensures the interval never keeps a Node process + // alive on its own. + (interval as unknown as { unref?: () => void }).unref?.(); + this._cleanupInterval = interval; } } diff --git a/packages/core/test/utils/AsyncExpiringMap.test.ts b/packages/core/test/utils/AsyncExpiringMap.test.ts index f8953d7e34..96850887ac 100644 --- a/packages/core/test/utils/AsyncExpiringMap.test.ts +++ b/packages/core/test/utils/AsyncExpiringMap.test.ts @@ -142,6 +142,38 @@ describe('AsyncExpiringMap', () => { expect(retrievedValue).toBeUndefined(); }); + it('does not start a cleanup interval on construction', () => { + // Importing a module that constructs a map at module scope must not start a timer, otherwise it keeps a + // Node/Jest process alive just from the import. See https://github.com/getsentry/sentry-react-native/issues/6805 + const timersBefore = jest.getTimerCount(); + + // eslint-disable-next-line no-new + new AsyncExpiringMap(); + + expect(jest.getTimerCount()).toBe(timersBefore); + }); + + it('restarts the cleanup interval after the map empties and a new entry is added', () => { + const ttl = 2000; + const cleanupInterval = ttl / 2; + const map = new AsyncExpiringMap({ ttl, cleanupInterval }); + const internalMap = (map as unknown as { _map: Map })._map; + + // First entry: the interval sweeps it and stops itself once the map is empty. + map.set('first', 'value'); + now += ttl; + jest.advanceTimersByTime(ttl); + expect(internalMap.size).toBe(0); + + // Second entry added after the interval stopped must re-arm cleanup so the interval evicts it too. + map.set('second', 'value'); + now += ttl; + jest.advanceTimersByTime(ttl); + + // Asserted via the internal map, not get()/has(), so it proves the interval evicted it rather than a lazy read. + expect(internalMap.size).toBe(0); + }); + it('stops cleanup when stopCleanup is called', () => { const map = new AsyncExpiringMap();