Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
17 changes: 12 additions & 5 deletions packages/core/src/js/utils/AsyncExpiringMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export class AsyncExpiringMap<K, V> {
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.
}

/**
Expand Down Expand Up @@ -143,9 +144,7 @@ export class AsyncExpiringMap<K, V> {
* Clear all entries.
*/
public clear(): void {
if (this._cleanupInterval) {
clearInterval(this._cleanupInterval);
}
this.stopCleanup();
this._map.clear();
}

Expand All @@ -155,13 +154,21 @@ export class AsyncExpiringMap<K, V> {
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;
}
}

/**
* 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;
}
}
32 changes: 32 additions & 0 deletions packages/core/test/utils/AsyncExpiringMap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();

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<string, string>({ ttl, cleanupInterval });
const internalMap = (map as unknown as { _map: Map<string, unknown> })._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<string, string>();

Expand Down
Loading