Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changepacks/changepack_log_Kd7pQm2VxR9sT4nLbY6Ha.json
Original file line number Diff line number Diff line change
@@ -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"
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
15 changes: 15 additions & 0 deletions scripts/write-esm-entry.ts
Original file line number Diff line number Diff line change
@@ -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")
130 changes: 130 additions & 0 deletions src/__tests__/dist-preload.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
29 changes: 25 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,37 @@
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'
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<symbol, boolean | undefined>

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<string, unknown>).filter(
([, value]) => typeof value === 'function',
),
) as Parameters<typeof expect.extend>[0],
)

const methods = ['toMatchSnapshot', 'toMatchInlineSnapshot', 'toContain']

Expand Down
24 changes: 24 additions & 0 deletions src/register-dom.ts
Original file line number Diff line number Diff line change
@@ -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()
}
Loading