diff --git a/.changepacks/changepack_log_Kd7pQm2VxR9sT4nLbY6Ha.json b/.changepacks/changepack_log_Kd7pQm2VxR9sT4nLbY6Ha.json new file mode 100644 index 0000000..ae82cc6 --- /dev/null +++ b/.changepacks/changepack_log_Kd7pQm2VxR9sT4nLbY6Ha.json @@ -0,0 +1,5 @@ +{ + "changes": { "package.json": "Patch" }, + "note": "Register the DOM before @testing-library/react is evaluated", + "date": "2026-09-10T09:20:00.000000000Z" +} diff --git a/package.json b/package.json index 580a007..97a07cd 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "lint:fix": "biome check --write .", "lint": "biome check .", "test": "bun test", - "build": "tsc && bun scripts/clean-dts.ts && bun build --target node src/index.ts --production --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --outfile dist/index.mjs --format esm --packages external", + "build": "tsc && bun scripts/clean-dts.ts && bun build --target node src/index.ts --production --outfile dist/index.cjs --format cjs --packages external && bun scripts/write-esm-entry.ts", "prepare": "bun run husky" } } diff --git a/scripts/write-esm-entry.ts b/scripts/write-esm-entry.ts new file mode 100644 index 0000000..c155777 --- /dev/null +++ b/scripts/write-esm-entry.ts @@ -0,0 +1,15 @@ +/** + * Emits `dist/index.mjs` as a re-export of `dist/index.cjs`. + * + * The ESM entry cannot be bundled from `src/index.ts` on its own. bun evaluates + * the CJS dependencies of a module graph while it is being linked, before any + * ESM module body runs, so an ESM entry that statically imports + * `@testing-library/react` always evaluates `react-dom` before happy-dom is + * registered - no matter where the bundler places the registration, and no + * matter how the entry is split across files. + * + * Re-exporting the CJS build keeps a real ESM entry point while leaving the + * ordering to `require`, which is an ordinary runtime call and therefore + * happens in source order. + */ +await Bun.write('dist/index.mjs', "export * from './index.cjs'\n") diff --git a/src/__tests__/dist-preload.test.ts b/src/__tests__/dist-preload.test.ts new file mode 100644 index 0000000..a085a76 --- /dev/null +++ b/src/__tests__/dist-preload.test.ts @@ -0,0 +1,130 @@ +import { expect, test } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const root = resolve(import.meta.dir, '../..') +const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes) +const spec = (path: string) => JSON.stringify(path.replaceAll('\\', '/')) + +/** + * The ordering this guards cannot be observed from inside this process. + * + * `react-dom` decides whether a DOM exists while it is being evaluated + * (`canUseDOM`) and gates its entire DOM event-plugin setup on that answer, so + * a DOM registered afterwards leaves a page React never dispatches into: + * `fireEvent.change` runs, but no `onChange` handler ever fires. Only a fresh + * process that preloads the artifact and then tries can see it. + * + * It also has to run against the *built* entry points rather than + * `src/index.ts`, because the bundler decides where the registration lands + * relative to the imports it emits. Source order proves nothing about what + * consumers install. + */ + +function buildArtifacts() { + const bundle = Bun.spawnSync( + [ + 'bun', + 'build', + '--target', + 'node', + 'src/index.ts', + '--production', + '--outfile', + 'dist/index.cjs', + '--format', + 'cjs', + '--packages', + 'external', + ], + { cwd: root, stdout: 'pipe', stderr: 'pipe' }, + ) + expect(bundle.exitCode).toBe(0) + + const esmEntry = Bun.spawnSync(['bun', 'scripts/write-esm-entry.ts'], { + cwd: root, + stdout: 'pipe', + stderr: 'pipe', + }) + expect(esmEntry.exitCode).toBe(0) +} + +function preloadAndType(entry: string) { + const dir = mkdtempSync(join(tmpdir(), 'bun-test-env-dom-')) + try { + // The fixture lives outside the package so this suite never collects it, + // which rules out bare specifiers and JSX - both resolve from the file's + // own directory. Absolute specifiers and `createElement` keep it portable. + const reactPath = Bun.resolveSync('react', root) + const rtlPath = Bun.resolveSync('@testing-library/react', root) + + writeFileSync( + join(dir, 'bunfig.toml'), + `[test]\npreload = [${spec(resolve(root, entry))}]\ncoverage = false\n`, + ) + writeFileSync( + join(dir, 'order.test.ts'), + `import { expect, mock, test } from 'bun:test' +import { fireEvent, render } from ${spec(rtlPath)} +import { createElement, useState } from ${spec(reactPath)} + +function Field({ onSubmit }: { onSubmit: (value: string) => void }) { + const [value, setValue] = useState('') + return createElement( + 'form', + { + onSubmit: (event: { preventDefault: () => void }) => { + event.preventDefault() + onSubmit(value) + }, + }, + createElement('label', { htmlFor: 'field' }, 'field'), + createElement('input', { + id: 'field', + value, + onChange: (event: { target: { value: string } }) => + setValue(event.target.value), + }), + createElement('button', { type: 'submit' }, 'submit'), + ) +} + +test('fireEvent.change reaches the React onChange handler', () => { + const onSubmit = mock((_value: string) => {}) + const view = render(createElement(Field, { onSubmit })) + fireEvent.change(view.getByLabelText('field'), { target: { value: 'typed' } }) + fireEvent.click(view.getByRole('button', { name: 'submit' })) + expect(onSubmit).toHaveBeenCalledWith('typed') +}) +`, + ) + + const run = Bun.spawnSync( + [ + 'bun', + `--config=${join(dir, 'bunfig.toml')}`, + 'test', + join(dir, 'order.test.ts'), + ], + { cwd: root, stdout: 'pipe', stderr: 'pipe' }, + ) + return { + output: decode(run.stderr) + decode(run.stdout), + exitCode: run.exitCode, + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +test.each([ + 'dist/index.cjs', + 'dist/index.mjs', +])('%s registers a DOM before @testing-library/react is evaluated', (entry) => { + buildArtifacts() + const { output, exitCode } = preloadAndType(entry) + expect(output).toContain('1 pass') + expect(output).toContain('0 fail') + expect(exitCode).toBe(0) +}) diff --git a/src/index.ts b/src/index.ts index e3f6e82..ad2883f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ +import './register-dom.ts' import * as test from 'bun:test' import { afterEach, expect } from 'bun:test' -import { GlobalRegistrator } from '@happy-dom/global-registrator' import type { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers' import * as matchers from '@testing-library/jest-dom/matchers' import { cleanup, render } from '@testing-library/react' @@ -8,9 +8,30 @@ import type { ReactElement } from 'react' import { formatHTMLElement } from './formatHTMLElement.ts' import { isReactElement } from './isReactElement.ts' -if (!GlobalRegistrator.isRegistered) { - GlobalRegistrator.register() - expect.extend(matchers) +// The CJS and ESM builds can both end up loaded in one process - a preload +// pulls in one, a test file imports the other. Guard on a process-wide symbol +// so the second copy does not extend `expect` or install `afterEach` twice. +// +// This used to read `GlobalRegistrator.isRegistered`, which conflated "a DOM +// exists" with "this module has initialised". Anything that registered a DOM +// first silently disabled every line below it: the jest-dom matchers, the +// `bun:test` expect wrapper, and the `cleanup` hook. +const INITIALIZED = Symbol.for('bun-test-env-dom.initialized') +const globalFlags = globalThis as unknown as Record + +if (!globalFlags[INITIALIZED]) { + globalFlags[INITIALIZED] = true + + // `import * as matchers` does not have the same shape in both builds: the + // CJS interop helper adds a `default` key, and `expect.extend` rejects any + // entry that is not a function ("`default` is not a valid matcher"). + expect.extend( + Object.fromEntries( + Object.entries(matchers as Record).filter( + ([, value]) => typeof value === 'function', + ), + ) as Parameters[0], + ) const methods = ['toMatchSnapshot', 'toMatchInlineSnapshot', 'toContain'] diff --git a/src/register-dom.ts b/src/register-dom.ts new file mode 100644 index 0000000..c52b1d1 --- /dev/null +++ b/src/register-dom.ts @@ -0,0 +1,24 @@ +import { GlobalRegistrator } from '@happy-dom/global-registrator' + +/** + * Installs happy-dom's globals. + * + * This is a module of its own, imported first by `./index.ts`, because + * `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". + * + * Registering after those modules load therefore yields a DOM that exists but + * that neither library will ever use. Imports are evaluated before the + * importing module's body in both ESM and CJS, so the registration cannot live + * in `./index.ts` alongside the `@testing-library/react` import. + */ +if (!GlobalRegistrator.isRegistered) { + GlobalRegistrator.register() +}