Skip to content

MockedProvider v4 regression: devtools suggestion setTimeout fires after test-environment teardown — the connectToDevTools: false default from #11289 was lost in the v4 rewrite #13344

Description

@cheapsteak

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions