Issue Description
In Apollo Client 3.8.x, #11211 / #11292 reported that the devtools checks schedule a setTimeout that outlives tests, and #11289 fixed it by defaulting MockedProvider to connectToDevTools: false.
The v4 rewrite of MockedProvider dropped that default. In @apollo/client@4.2.7, testing/react/MockedProvider.js passes the devtools prop through unchanged:
const { mocks, defaultOptions, cache, localState, link, showWarnings, mockLinkDefaultOptions, devtools } = this.props;
const client = new ApolloClient({
// ...
devtools,
});
When the prop is omitted (the overwhelmingly common case in tests), ApolloClient falls back to enabled: __DEV__:
this.devtoolsConfig = {
...devtools,
enabled: devtools?.enabled ?? __DEV__,
};
…so connectToDevTools() runs, registers the client on window, and schedules the one-shot 10-second "install the Apollo devtools" suggestion setTimeout (core/ApolloClient.js, the hasSuggestedDevtools block). jsdom's default http://localhost URL passes the window.location.protocol guard, so this happens in effectively every jsdom test environment. The timer is not cancellable and its callback dereferences window with no existence guard.
Consequence: if the test worker outlives the jsdom environment that was active when the first client was constructed — i.e. the timer comes due in any window-less execution gap (environment teardown / worker shutdown) — the callback's first statement throws ReferenceError: window is not defined as an uncaught exception.
Real-world impact
We hit this as a rare, non-deterministic CI failure (twice in ~334 CI runs) under Vitest 4.1.8 + jsdom: every test passes, then the run fails on one unhandled error:
⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯
ReferenceError: window is not defined
❯ Timeout._onTimeout node_modules/@apollo/client/core/ApolloClient.js:141
❯ listOnTimeout node:internal/timers:605
❯ processTimers node:internal/timers:541
This error originated in "src/.../OurDialog.test.tsx" test file. It doesn't
mean the error was thrown inside the file itself, but while it was running.
Test Files 255 passed (255)
Tests 2325 passed (2325)
Errors 1 error
The run exits non-zero with all 2325 tests green. It only reproduces when the 10-second due time happens to land in a teardown/shutdown gap on a loaded CI machine, which is what makes it a flaky "green tests, red run" CI failure rather than something anyone hits at their desk.
Reproduction Steps (deterministic)
The mechanism isolates to a few lines — construct a client in a jsdom global, tear the environment down the way a test runner does, wait for the timer:
// repro.mjs — run with: node --conditions development repro.mjs
// deps: @apollo/client@4.2.7, graphql, rxjs, jsdom
import { JSDOM } from 'jsdom';
const dom = new JSDOM('', { url: 'http://localhost:3000/' });
const install = (name, value) =>
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
install('window', dom.window);
install('document', dom.window.document);
const { ApolloClient, InMemoryCache, ApolloLink } = await import('@apollo/client');
// What MockedProvider does in v4 when the `devtools` prop is omitted:
// devtools.enabled defaults to __DEV__ -> connectToDevTools() runs and
// schedules the one-shot 10s "install the devtools" setTimeout.
new ApolloClient({ cache: new InMemoryCache(), link: ApolloLink.empty() });
console.log('client registered on window:', window.__APOLLO_CLIENT__ !== undefined);
// What the test runner does when the file finishes: tear down the jsdom
// environment. The suggestion timer survives (plain global setTimeout).
delete globalThis.window;
delete globalThis.document;
console.log('environment torn down; waiting for the 10s suggestion timer...');
setTimeout(() => {
console.log('11s elapsed without a crash (bug not reproduced)');
}, 11_000);
Observed output (Node 24.5.0, @apollo/client@4.2.7 — identical on 4.1.6 at line 141):
client registered on window: true
environment torn down; waiting for the 10s suggestion timer...
node_modules/@apollo/client/core/ApolloClient.js:148
if (!window.__APOLLO_DEVTOOLS_GLOBAL_HOOK__) {
^
ReferenceError: window is not defined
at Timeout._onTimeout (.../@apollo/client/core/ApolloClient.js:148:21)
at listOnTimeout (node:internal/timers:608:17)
at process.processTimers (node:internal/timers:543:7)
(--conditions development selects the development build, matching how Vite/Vitest/Jest resolve @apollo/client in tests; the stack is byte-for-byte what our Vitest CI failure printed.)
Expected behavior
MockedProvider should not initiate devtools connectivity by default, restoring the v3 behavior deliberately introduced in #11289 — tests opt in via the devtools prop rather than every suite having to opt out. Independently, connectToDevTools() could guard the suggestion callback (typeof window === 'undefined' early return) and/or keep the timer handle so it can't crash a process whose environment is gone — that would protect non-MockedProvider clients constructed in tests too.
Workaround
Globally stub the registration hook in the test setup file:
// vitest.setup.ts
import { ApolloClient } from '@apollo/client';
(ApolloClient.prototype as unknown as Record<string, unknown>).connectToDevTools = () => {};
or pass devtools={{ enabled: false }} to every MockedProvider / client constructed in tests.
Versions
@apollo/client: reproduced on 4.1.6 and 4.2.7 (latest at time of writing; no CHANGELOG entry restoring the MockedProvider default)
react: 18
vitest: 4.1.8, jsdom environment (real-world CI occurrence)
- Node: 24.5.0
Issue Description
In Apollo Client 3.8.x, #11211 / #11292 reported that the devtools checks schedule a
setTimeoutthat outlives tests, and #11289 fixed it by defaultingMockedProvidertoconnectToDevTools: false.The v4 rewrite of
MockedProviderdropped that default. In@apollo/client@4.2.7,testing/react/MockedProvider.jspasses thedevtoolsprop through unchanged:When the prop is omitted (the overwhelmingly common case in tests),
ApolloClientfalls back toenabled: __DEV__:…so
connectToDevTools()runs, registers the client onwindow, and schedules the one-shot 10-second "install the Apollo devtools" suggestionsetTimeout(core/ApolloClient.js, thehasSuggestedDevtoolsblock). jsdom's defaulthttp://localhostURL passes thewindow.location.protocolguard, so this happens in effectively every jsdom test environment. The timer is not cancellable and its callback dereferenceswindowwith no existence guard.Consequence: if the test worker outlives the jsdom environment that was active when the first client was constructed — i.e. the timer comes due in any window-less execution gap (environment teardown / worker shutdown) — the callback's first statement throws
ReferenceError: window is not definedas an uncaught exception.Real-world impact
We hit this as a rare, non-deterministic CI failure (twice in ~334 CI runs) under Vitest 4.1.8 + jsdom: every test passes, then the run fails on one unhandled error:
The run exits non-zero with all 2325 tests green. It only reproduces when the 10-second due time happens to land in a teardown/shutdown gap on a loaded CI machine, which is what makes it a flaky "green tests, red run" CI failure rather than something anyone hits at their desk.
Reproduction Steps (deterministic)
The mechanism isolates to a few lines — construct a client in a jsdom global, tear the environment down the way a test runner does, wait for the timer:
Observed output (Node 24.5.0,
@apollo/client@4.2.7— identical on 4.1.6 at line 141):(
--conditions developmentselects the development build, matching how Vite/Vitest/Jest resolve@apollo/clientin tests; the stack is byte-for-byte what our Vitest CI failure printed.)Expected behavior
MockedProvidershould not initiate devtools connectivity by default, restoring the v3 behavior deliberately introduced in #11289 — tests opt in via thedevtoolsprop rather than every suite having to opt out. Independently,connectToDevTools()could guard the suggestion callback (typeof window === 'undefined'early return) and/or keep the timer handle so it can't crash a process whose environment is gone — that would protect non-MockedProviderclients constructed in tests too.Workaround
Globally stub the registration hook in the test setup file:
or pass
devtools={{ enabled: false }}to everyMockedProvider/ client constructed in tests.Versions
@apollo/client: reproduced on 4.1.6 and 4.2.7 (latest at time of writing; no CHANGELOG entry restoring theMockedProviderdefault)react: 18vitest: 4.1.8, jsdom environment (real-world CI occurrence)