diff --git a/config/scripts/happy-dom-mutation-observer-retention.test.ts b/config/scripts/happy-dom-mutation-observer-retention.test.ts new file mode 100644 index 000000000..475a84c9b --- /dev/null +++ b/config/scripts/happy-dom-mutation-observer-retention.test.ts @@ -0,0 +1,72 @@ +/** @vitest-environment happy-dom */ +import { describe, expect, it } from 'vitest' + +import { + installHappyDomMutationObserverRetention, + retainedMutationCallbackCount +} from './happy-dom-mutation-observer-retention' + +function readListenerCallbacks(target: Node): unknown[] { + const listenersSymbol = Object.getOwnPropertySymbols(target).find( + (candidate) => candidate.description === 'mutationListeners' + ) + const listeners = listenersSymbol + ? (target as unknown as Record)[listenersSymbol] + : [] + if (!Array.isArray(listeners)) { + return [] + } + return listeners.map((listener: { callback?: { deref: () => unknown } }) => + listener.callback?.deref() + ) +} + +describe('happy-dom MutationObserver retention', () => { + it('pins the internal callback that happy-dom only holds weakly', () => { + expect(installHappyDomMutationObserverRetention()).toBe(true) + const target = document.createElement('div') + document.body.append(target) + const observer = new MutationObserver(() => {}) + observer.observe(target, { childList: true, subtree: true }) + + const callbacks = readListenerCallbacks(target) + expect(callbacks.length).toBe(1) + expect(callbacks[0]).toBeTypeOf('function') + expect(retainedMutationCallbackCount(observer)).toBe(1) + + observer.disconnect() + expect(retainedMutationCallbackCount(observer)).toBe(0) + target.remove() + }) + + it('keeps delivering records after the weak callback would have been collected', async () => { + installHappyDomMutationObserverRetention() + const target = document.createElement('div') + document.body.append(target) + let deliveries = 0 + const observer = new MutationObserver(() => { + deliveries += 1 + }) + observer.observe(target, { childList: true, subtree: true }) + + target.replaceChildren(document.createElement('div')) + await Promise.resolve() + expect(deliveries).toBe(1) + + const collectGarbage = (globalThis as { gc?: () => void }).gc + if (collectGarbage) { + for (let round = 0; round < 5; round += 1) { + await new Promise((resolve) => setTimeout(resolve, 1)) + collectGarbage() + } + await new Promise((resolve) => setTimeout(resolve, 1)) + } + + target.replaceChildren(document.createElement('span')) + await Promise.resolve() + expect(deliveries).toBe(2) + + observer.disconnect() + target.remove() + }) +}) diff --git a/config/scripts/happy-dom-mutation-observer-retention.ts b/config/scripts/happy-dom-mutation-observer-retention.ts new file mode 100644 index 000000000..a315b3d52 --- /dev/null +++ b/config/scripts/happy-dom-mutation-observer-retention.ts @@ -0,0 +1,81 @@ +// Why: happy-dom stores each MutationObserver's internal callback in a WeakRef, so any GC pause +// under parallel test load silently and permanently stops a still-connected observer. Browsers +// keep that callback reachable for as long as the observer observes; mirror that lifetime here so +// DOM-driven tests never lose mutation records mid-run. + +type HappyDomMutationListener = { + callback?: { deref: () => unknown } +} + +type PatchableMutationObserver = { + observe: (target: Node, options?: MutationObserverInit) => void + disconnect: () => void +} + +const MUTATION_LISTENERS_SYMBOL_DESCRIPTION = 'mutationListeners' +const RETENTION_INSTALLED = Symbol.for('orca.happyDomMutationObserverRetention') + +const retainedCallbacks = new WeakMap>() + +function readMutationListeners(target: Node): HappyDomMutationListener[] { + const listenersSymbol = Object.getOwnPropertySymbols(target).find( + (candidate) => candidate.description === MUTATION_LISTENERS_SYMBOL_DESCRIPTION + ) + if (!listenersSymbol) { + return [] + } + const listeners = (target as unknown as Record)[listenersSymbol] + return Array.isArray(listeners) ? (listeners as HappyDomMutationListener[]) : [] +} + +/** Number of internal callbacks pinned for `observer`; drops to 0 once it disconnects. */ +export function retainedMutationCallbackCount(observer: MutationObserver): number { + return retainedCallbacks.get(observer)?.size ?? 0 +} + +export function installHappyDomMutationObserverRetention(): boolean { + const observerClass = (globalThis as { MutationObserver?: typeof MutationObserver }) + .MutationObserver + if (!observerClass) { + return false + } + const prototype = observerClass.prototype as unknown as PatchableMutationObserver & + Record + if (prototype[RETENTION_INSTALLED] === true) { + return true + } + const observe = prototype.observe + const disconnect = prototype.disconnect + + prototype.observe = function patchedObserve( + this: object, + target: Node, + options?: MutationObserverInit + ): void { + const existing = new Set(readMutationListeners(target)) + observe.call(this as unknown as PatchableMutationObserver, target, options) + const pinned = retainedCallbacks.get(this) ?? new Set() + for (const listener of readMutationListeners(target)) { + if (existing.has(listener)) { + continue + } + const callback = listener.callback?.deref() + if (callback) { + pinned.add(callback) + } + } + if (pinned.size > 0) { + retainedCallbacks.set(this, pinned) + } + } + + prototype.disconnect = function patchedDisconnect(this: object): void { + disconnect.call(this as unknown as PatchableMutationObserver) + retainedCallbacks.delete(this) + } + + prototype[RETENTION_INSTALLED] = true + return true +} + +installHappyDomMutationObserverRetention() diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 1146cd4a5..735e1b2a0 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -17,6 +17,8 @@ export default defineConfig({ environment: 'node', // Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's. execArgv: ['--no-experimental-webstorage'], + // Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does. + setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')], include: [ 'src/**/*.test.ts', 'src/**/*.test.tsx',