test(env): keep happy-dom MutationObserver callbacks alive across GC (#12438)
happy-dom holds each observer's internal callback in a WeakRef, so a GC pause under sharded CI load permanently kills a still-connected observer. That is what made the activity portal readiness test flake: once the observer died, no amount of re-applying ready DOM could release the latch. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
ed4d6979b1
commit
e8d4818b5a
|
|
@ -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<symbol, unknown>)[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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<object, Set<unknown>>()
|
||||
|
||||
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<symbol, unknown>)[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<symbol, unknown>
|
||||
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<unknown>()
|
||||
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()
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue