Skip to content

Commit 40e26c6

Browse files
sentry[bot]sergicalLms24claude
authored
fix(browser-utils): Handle NS_ERROR_NOT_INITIALIZED from setTimeout in Firefox (#25024)
Replay calls our `setTimeout` from `@sentry/browser-utils`. When `window.setTimeout` is wrapped (by default it is, by our own `browserApiErrors` integration), that `setTimeout` comes from a sandbox iframe that is removed right away. In rare cases Firefox throws `NS_ERROR_NOT_INITIALIZED` when it is called, which shows up as an unhandled rejection from Replay's `afterSendEvent` handler. This catches the error, falls back to `window.setTimeout` and caches it. --------- Co-authored-by: Sergiy Dybskiy <sergical@users.noreply.github.com> Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Lukas Stracke <lukas.stracke@sentry.io> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 3a7ad4b commit 40e26c6

2 files changed

Lines changed: 64 additions & 1 deletion

File tree

‎packages/browser-utils/src/getNativeImplementation.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,5 +119,15 @@ export function fetch(...rest: Parameters<typeof WINDOW.fetch>): ReturnType<type
119119
* avoiding triggering change detection.
120120
*/
121121
export function setTimeout(...rest: Parameters<typeof WINDOW.setTimeout>): ReturnType<typeof WINDOW.setTimeout> {
122-
return getNativeImplementation('setTimeout')(...rest);
122+
try {
123+
return getNativeImplementation('setTimeout')(...rest);
124+
} catch (e) {
125+
// The implementation retrieved from the (since removed) sandbox iframe may throw when invoked,
126+
// e.g. Firefox throws `NS_ERROR_NOT_INITIALIZED` for timers of a detached window.
127+
// In this case, fall back to (and cache) the window's own setTimeout.
128+
DEBUG_BUILD && debug.warn('Could not use native setTimeout implementation, falling back to window.setTimeout: ', e);
129+
const fallback = WINDOW.setTimeout.bind(WINDOW) as typeof WINDOW.setTimeout;
130+
cachedImplementations.setTimeout = fallback;
131+
return fallback(...rest);
132+
}
123133
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6+
import { clearCachedImplementation, setTimeout as sentrySetTimeout } from '../src/getNativeImplementation';
7+
import { WINDOW } from '../src/types';
8+
9+
describe('setTimeout', () => {
10+
const originalSetTimeout = WINDOW.setTimeout;
11+
12+
beforeEach(() => {
13+
clearCachedImplementation('setTimeout');
14+
});
15+
16+
afterEach(() => {
17+
WINDOW.setTimeout = originalSetTimeout;
18+
clearCachedImplementation('setTimeout');
19+
vi.restoreAllMocks();
20+
});
21+
22+
it('falls back to window.setTimeout if the sandboxed iframe implementation throws', async () => {
23+
// Simulate a wrapped (non-native) window.setTimeout, e.g. by a browser extension
24+
const wrappedSetTimeout = vi.fn((...args: Parameters<typeof originalSetTimeout>) => originalSetTimeout(...args));
25+
WINDOW.setTimeout = wrappedSetTimeout as unknown as typeof WINDOW.setTimeout;
26+
27+
// Simulate Firefox throwing `NS_ERROR_NOT_INITIALIZED` when calling setTimeout of a detached iframe window
28+
const detachedSetTimeout = vi.fn(() => {
29+
const error = new Error('');
30+
error.name = 'NS_ERROR_NOT_INITIALIZED';
31+
throw error;
32+
});
33+
const fakeIframe = { hidden: false, contentWindow: { setTimeout: detachedSetTimeout } };
34+
35+
const document = WINDOW.document as Document;
36+
vi.spyOn(document, 'createElement').mockReturnValue(fakeIframe as unknown as HTMLElement);
37+
vi.spyOn(document.head, 'appendChild').mockImplementation(node => node);
38+
vi.spyOn(document.head, 'removeChild').mockImplementation(node => node);
39+
40+
const callback = vi.fn();
41+
42+
expect(() => sentrySetTimeout(callback, 0)).not.toThrow();
43+
expect(detachedSetTimeout).toHaveBeenCalledTimes(1);
44+
expect(wrappedSetTimeout).toHaveBeenCalledTimes(1);
45+
46+
// Subsequent calls use the cached fallback and do not hit the broken implementation again
47+
sentrySetTimeout(callback, 0);
48+
expect(detachedSetTimeout).toHaveBeenCalledTimes(1);
49+
expect(wrappedSetTimeout).toHaveBeenCalledTimes(2);
50+
51+
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(2));
52+
});
53+
});

0 commit comments

Comments
 (0)