fix: register the DOM before @testing-library/react is evaluated - #8
Merged
Merged
Conversation
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
react-domand@testing-library/domboth decide whether a DOM exists while they are being evaluated, and never look again:react-domcomputescanUseDOMonce and gates its entire DOM event-plugin setup on it. With no DOM at that moment,fireEvent.changeruns but noonChangehandler ever fires and component state never updates. It fails silently — no error, the assertion just sees a stale value.@testing-library/dombindsscreentodocument.bodyonce, and otherwise leaves stubs that throwFor queries bound to document.body a global document has to be available.src/index.tsimported 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 callonChangedirectly, 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.mjsis now a re-exportbun 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/reactevaluatesreact-dombefore happy-dom is registered, no matter where the bundler puts the registration and no matter how the entry is split.Measured with a
fireEvent.changefixture preloaded into a separatebun testprocess:dist/index.mjsindex.mjs->register-dom.mjs,main.mjs)src/index.tsdirectly@testing-library/reactimportdist/index.mjs=export * from './index.cjs'requireis an ordinary runtime call, so the CJS build orders correctly; re-exporting it keeps a real ESM entry point.Nothing changes for consumers. The
exportsmap,main,typesandfilesare untouched — only the build script line that producesdist/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 bysrc/index.tsso it is a separate module evaluation rather than a statement in the body.src/index.ts— the init guard no longer readsGlobalRegistrator.isRegistered. That conflated "a DOM exists" with "this module has initialised", so anything that registered a DOM first silently skipped the jest-dom matchers, thebun:testexpect wrapper andafterEach(cleanup). It is now a process-wideSymbol.forflag, which still de-duplicates when both builds load in one process.src/index.ts—expect.extendnow filters its argument to functions.import * as matchersgains adefaultkey through the CJS interop andexpect.extendrejects it with`default` is not a valid matcher, which madedist/index.cjsthrow on load and be unusable as a preload. Nobody hit it becausepreload = ["bun-test-env-dom"]resolves theimportcondition.scripts/write-esm-entry.ts(new) — emitsdist/index.mjs, replacing the secondbun buildinvocation.src/__tests__/dist-preload.test.ts(new) — builds both entry points and drives each one as a preload in a separatebun testprocess, assertingfireEvent.changereaches a ReactonChangehandler. 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 buildexit 0,bun lintexit 0,bun test28 pass / 0 fail at 100% coverage (the 1.0 threshold still holds).package.jsonuntouched 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.