From f2ac003287952aba482f27c37d5172393335a611 Mon Sep 17 00:00:00 2001 From: lojhan Date: Sun, 5 Apr 2026 16:30:28 -0300 Subject: [PATCH 1/2] fix: preserve native dispatchEvent for Deno compatibility Replace GlobalRegistrator.register() with new GlobalWindow() from happy-dom, then selectively install DOM globals while preserving any existing dispatchEvent, Event, and CustomEvent on globalThis. The old approach called GlobalRegistrator.register() which unconditionally overwrites globalThis.dispatchEvent with Happy DOM's implementation. Deno's runtime dispatches a native 'beforeunload' event on process exit using its own Event constructor; Happy DOM's dispatchEvent rejects it with a TypeError because the event is not an instance of Happy DOM's internal Event class. The fix saves the runtime's native event primitives before setting up the Happy DOM window and restores them afterward. This leaves Happy DOM's DOM APIs (document, HTMLElement, etc.) fully functional while keeping Deno's shutdown path intact. Also removes @happy-dom/global-registrator from peerDependencies since it is no longer used. --- package.json | 4 ---- src/dom-env.ts | 54 ++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index deb0162..44f631e 100644 --- a/package.json +++ b/package.json @@ -56,15 +56,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..82510c8 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 as unknown as Window & typeof globalThis); + 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 ) => { From 33028a49a0e86642b9ef4995c3d95aec3aa24af0 Mon Sep 17 00:00:00 2001 From: lojhan Date: Sun, 5 Apr 2026 17:35:56 -0300 Subject: [PATCH 2/2] fix: deno dispatch event compatibility --- package.json | 6 ++++++ src/dom-env.ts | 4 ++-- src/plugin-setup.ts | 17 +++++++++++++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 44f631e..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", diff --git a/src/dom-env.ts b/src/dom-env.ts index 82510c8..34859c5 100644 --- a/src/dom-env.ts +++ b/src/dom-env.ts @@ -41,7 +41,7 @@ export const setupHappyDomEnvironment = async ( const happyWindow = new GlobalWindow({ url: options.runtimeOptions.domUrl }); - defineGlobal('window', happyWindow as unknown as Window & typeof globalThis); + defineGlobal('window', happyWindow); defineGlobal('document', happyWindow.document); defineGlobal('navigator', happyWindow.navigator); defineGlobal('HTMLElement', happyWindow.HTMLElement); @@ -88,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.`,