diff --git a/package.json b/package.json index deb0162..bece073 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,14 @@ "scripts": { "test": "npm run test:node", "test:node": "node --import=tsx ./node_modules/poku/lib/bin/index.js tests --showLogs", + "test:node:none": "node --import=tsx ./node_modules/poku/lib/bin/index.js tests --showLogs --isolation=none", + "test:node:process": "node --import=tsx ./node_modules/poku/lib/bin/index.js tests --showLogs --isolation=process", "test:bun": "bun ./node_modules/poku/lib/bin/index.js tests --showLogs", + "test:bun:none": "bun ./node_modules/poku/lib/bin/index.js tests --showLogs --isolation=none", + "test:bun:process": "bun ./node_modules/poku/lib/bin/index.js tests --showLogs --isolation=process", "test:deno": "deno run -A npm:poku tests --showLogs", + "test:deno:none": "deno run -A npm:poku tests --showLogs --isolation=none", + "test:deno:process": "deno run -A npm:poku tests --showLogs --isolation=process", "clean": "rimraf dist", "build": "npm run clean && tsc -p tsconfig.dist.json", "typecheck": "tsc -p tsconfig.build.json --noEmit", @@ -56,15 +62,11 @@ "provenance": true }, "peerDependencies": { - "@happy-dom/global-registrator": ">=20", "happy-dom": ">=20", "jsdom": ">=22", "poku": ">=4.1.0" }, "peerDependenciesMeta": { - "@happy-dom/global-registrator": { - "optional": true - }, "happy-dom": { "optional": true }, diff --git a/src/dom-env.ts b/src/dom-env.ts index 0dc31a7..34859c5 100644 --- a/src/dom-env.ts +++ b/src/dom-env.ts @@ -1,4 +1,4 @@ -import { GlobalRegistrator } from '@happy-dom/global-registrator'; +import { GlobalWindow } from 'happy-dom'; import type { RuntimeOptions } from './types.ts'; type SetupDomEnvironmentOptions = { @@ -19,31 +19,55 @@ const applyReactActEnvironment = (enabled: boolean) => { } }; +const defineGlobal = (key: keyof typeof globalThis, value: unknown) => { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }); +}; + export const setupHappyDomEnvironment = async ( options: SetupDomEnvironmentOptions ) => { if (!globalThis.window || !globalThis.document) { - GlobalRegistrator.register({ - url: options.runtimeOptions.domUrl, - }); + // Save native event primitives before installing Happy DOM globals. + // Deno's runtime calls globalThis.dispatchEvent('beforeunload') on exit + // using its own native Event constructor — if Happy DOM's dispatchEvent + // is installed instead, the type check inside it throws a TypeError. + const existingDispatchEvent = globalThis.dispatchEvent; + const existingEvent = globalThis.Event; + const existingCustomEvent = globalThis.CustomEvent; + + const happyWindow = new GlobalWindow({ url: options.runtimeOptions.domUrl }); - const nativeDispatchEvent = globalThis.window.dispatchEvent; - if (typeof nativeDispatchEvent === 'function') { - globalThis.dispatchEvent = nativeDispatchEvent.bind(globalThis.window); + defineGlobal('window', happyWindow); + defineGlobal('document', happyWindow.document); + defineGlobal('navigator', happyWindow.navigator); + defineGlobal('HTMLElement', happyWindow.HTMLElement); + defineGlobal('Element', happyWindow.Element); + defineGlobal('Node', happyWindow.Node); + defineGlobal('Text', happyWindow.Text); + defineGlobal('SVGElement', happyWindow.SVGElement); + defineGlobal('MutationObserver', happyWindow.MutationObserver); + defineGlobal('requestAnimationFrame', happyWindow.requestAnimationFrame); + defineGlobal('cancelAnimationFrame', happyWindow.cancelAnimationFrame); + + // Prefer the runtime's native Event constructors so that Deno's internal + // event dispatch (e.g. beforeunload) continues to work correctly. + defineGlobal('Event', typeof existingEvent === 'function' ? existingEvent : happyWindow.Event); + defineGlobal('CustomEvent', typeof existingCustomEvent === 'function' ? existingCustomEvent : happyWindow.CustomEvent); + + if (typeof existingDispatchEvent === 'function') { + globalThis.dispatchEvent = existingDispatchEvent; + } else { + globalThis.dispatchEvent = happyWindow.dispatchEvent.bind(happyWindow) as unknown as typeof globalThis.dispatchEvent; } } applyReactActEnvironment(Boolean(options.enableReactActEnvironment)); }; -const defineGlobal = (key: keyof typeof globalThis, value: unknown) => { - Object.defineProperty(globalThis, key, { - configurable: true, - writable: true, - value, - }); -}; - export const setupJsdomEnvironment = async ( options: SetupDomEnvironmentOptions ) => { @@ -64,7 +88,7 @@ export const setupJsdomEnvironment = async ( pretendToBeVisual: true, }); - defineGlobal('window', dom.window as unknown as Window & typeof globalThis); + defineGlobal('window', dom.window); defineGlobal('document', dom.window.document); defineGlobal('navigator', dom.window.navigator); defineGlobal('HTMLElement', dom.window.HTMLElement); diff --git a/src/plugin-setup.ts b/src/plugin-setup.ts index 6fe12bf..0bc565b 100644 --- a/src/plugin-setup.ts +++ b/src/plugin-setup.ts @@ -8,6 +8,13 @@ type TsxEsmApiModule = { const TSX_LOADER_MODULE = 'tsx/esm/api'; +// Once tsx is registered in a Node.js process it cannot be safely deregistered +// and re-registered (tsx's hook worker re-instantiation fails with an invalid +// URL scheme). Under isolation:'none' everything runs in the same process for +// its lifetime, so keeping the loader registered permanently is correct. +const TSX_LOADER_REGISTERED_KEY = Symbol.for('@pokujs/dom.tsx-loader-registered'); +type GlobalWithTsxFlag = typeof globalThis & { [TSX_LOADER_REGISTERED_KEY]?: boolean }; + const appendMissingRuntimeArgs = (runtimeOptionArgs: string[]) => { for (const arg of runtimeOptionArgs) { if (process.argv.includes(arg)) continue; @@ -19,7 +26,11 @@ const loadDomSetupModule = async (domSetupPath: string) => { await import(pathToFileURL(domSetupPath).href); }; -const registerNodeTsxLoader = async (packageTag: string) => { +const registerNodeTsxLoader = async (packageTag: string): Promise<() => void> => { + const g = globalThis as GlobalWithTsxFlag; + + if (g[TSX_LOADER_REGISTERED_KEY]) return () => {}; + const requireFromCwd = createRequire(`${process.cwd()}/`); try { @@ -29,7 +40,9 @@ const registerNodeTsxLoader = async (packageTag: string) => { throw new Error('Missing register() export from tsx loader API'); } - return mod.register(); + mod.register(); + g[TSX_LOADER_REGISTERED_KEY] = true; + return () => {}; } catch (error) { throw new Error( `[${packageTag}] isolation "none" in Node.js requires a working "tsx" installation to load .tsx/.jsx test files.`,