Skip to content

fix: register the DOM before @testing-library/react is evaluated - #8

Merged
owjs3901 merged 1 commit into
mainfrom
owjs3901/fix-dom-before-rtl
Sep 10, 2026
Merged

owjs3901 merged 1 commit into
mainfrom
owjs3901/fix-dom-before-rtl

Conversation

@owjs3901

Copy link
Copy Markdown
Contributor

The bug

react-dom and @testing-library/dom both decide whether a DOM exists while they are being evaluated, and never look again:

  • react-dom computes canUseDOM once and gates its entire DOM event-plugin setup on it. With no DOM at that moment, fireEvent.change runs but no onChange handler ever fires and component state never updates. It fails silently — no error, the assertion just sees a stale value.
  • @testing-library/dom binds screen to document.body once, and otherwise leaves stubs that throw For queries bound to document.body a global document has to be available.

src/index.ts imported both of them and registered happy-dom afterwards, in the module body. Imports are evaluated before the body in both ESM and CJS, so every preload produced a DOM that exists but that neither library ever uses.

Consumers work around this without knowing why. One repo using this package has a helper that reaches into __reactProps$ to call onChange directly, with the comment "fireEvent.input/change do NOT trigger React state updates in happy-dom + bun". That workaround is no longer needed.

Why dist/index.mjs is now a re-export

bun evaluates the CJS dependencies of a module graph while linking it, before any ESM module body runs. So an ESM entry that statically imports @testing-library/react evaluates react-dom before happy-dom is registered, no matter where the bundler puts the registration and no matter how the entry is split.

Measured with a fireEvent.change fixture preloaded into a separate bun test process:

entry shape result
registration inlined first in a bundled dist/index.mjs FAIL
two-file ESM chain (index.mjs -> register-dom.mjs, main.mjs) FAIL
CJS-first import inside the ESM entry FAIL
src/index.ts directly FAIL
two separate preload entries PASS
entry with no static @testing-library/react import PASS
dist/index.mjs = export * from './index.cjs' PASS

require is an ordinary runtime call, so the CJS build orders correctly; re-exporting it keeps a real ESM entry point.

Nothing changes for consumers. The exports map, main, types and files are untouched — only the build script line that produces dist/index.mjs. Named, namespace and side-effect imports were all verified against both entry points.

Changes

  • src/register-dom.ts (new) — performs the registration, imported first by src/index.ts so it is a separate module evaluation rather than a statement in the body.
  • src/index.ts — the init guard no longer reads GlobalRegistrator.isRegistered. That conflated "a DOM exists" with "this module has initialised", so anything that registered a DOM first silently skipped the jest-dom matchers, the bun:test expect wrapper and afterEach(cleanup). It is now a process-wide Symbol.for flag, which still de-duplicates when both builds load in one process.
  • src/index.ts — expect.extend now filters its argument to functions. import * as matchers gains a default key through the CJS interop and expect.extend rejects it with `default` is not a valid matcher, which made dist/index.cjs throw on load and be unusable as a preload. Nobody hit it because preload = ["bun-test-env-dom"] resolves the import condition.
  • scripts/write-esm-entry.ts (new) — emits dist/index.mjs, replacing the second bun build invocation.
  • src/__tests__/dist-preload.test.ts (new) — builds both entry points and drives each one as a preload in a separate bun test process, asserting fireEvent.change reaches a React onChange handler. The ordering cannot be observed from inside the test process, and the bundler decides where the registration lands, so the guard has to run the built files rather than the source.

Verification

  • bun run build exit 0, bun lint exit 0, bun test 28 pass / 0 fail at 100% coverage (the 1.0 threshold still holds).
  • Installed the built artifacts into a downstream repo with its package.json untouched and the original preload order: the RTL probe goes 0/2 -> 2/2, and its full suite stays at exactly the same pass/fail baseline, so this is a strict no-regression change there.

`react-dom` and `@testing-library/dom` both decide whether a DOM exists while
they are being evaluated, and never look again:

- `react-dom` computes `canUseDOM` once and gates its entire DOM event-plugin
  setup on it, so `fireEvent.change` never reaches a React handler and
  component state never updates.
- `@testing-library/dom` binds `screen` to `document.body` once, and otherwise
  leaves stubs that throw "For queries bound to document.body a global
  document has to be available".

`src/index.ts` imported both of them and registered happy-dom afterwards, in
the module body. Imports are evaluated before the body in both ESM and CJS, so
every preload produced a DOM that exists but that neither library ever uses.

Changes:

- `src/register-dom.ts` performs the registration and is imported first, so it
  is a separate module evaluation rather than a statement in the body.

- The init guard no longer reads `GlobalRegistrator.isRegistered`. That
  conflated "a DOM exists" with "this module has initialised", so anything
  that registered a DOM first silently skipped the jest-dom matchers, the
  `bun:test` expect wrapper and `afterEach(cleanup)`. It is now a process-wide
  `Symbol.for` flag, which still de-duplicates across module instances.

- `expect.extend` now filters its argument to functions. `import * as matchers`
  gains a `default` key through the CJS interop, and `expect.extend` rejects it
  with "`default` is not a valid matcher" — which made the CJS artifact throw
  on load and be unusable as a preload.

- `dist/index.mjs` is now emitted by `scripts/write-esm-entry.ts` as a
  re-export of `dist/index.cjs` instead of being bundled from `src/index.ts`.

  bun evaluates the CJS dependencies of a module graph while linking it, before
  any ESM module body runs, so an ESM entry that statically imports
  `@testing-library/react` evaluates `react-dom` before happy-dom is registered
  no matter where the bundler puts the registration. Measured with a
  `fireEvent.change` fixture: the registration inlined first in a bundled
  `dist/index.mjs`, a two-file ESM chain, and a CJS-first import inside the ESM
  entry all fail. `require` is an ordinary runtime call, so the CJS build
  orders correctly, and re-exporting it keeps a real ESM entry point. Both
  entry points and the `exports` map are unchanged for consumers; named,
  namespace and side-effect imports were all verified against both.

- `src/__tests__/dist-preload.test.ts` builds both entry points and drives each
  one as a preload in a separate `bun test` process. The ordering cannot be
  observed from inside this process, and the bundler decides where the
  registration lands, so the guard has to run the built files.
@owjs3901
owjs3901 merged commit 3e77641 into main Sep 10, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant