Skip to content

Commit f6690ed

Browse files
authored
fix(jsdom): support AbortSignal API (#8704)
1 parent 8cb219c commit f6690ed

File tree

2 files changed

+71
-3
lines changed

2 files changed

+71
-3
lines changed

packages/vitest/src/integrations/env/jsdom.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import type { DOMWindow } from 'jsdom'
12
import type { Environment } from '../../types/environment'
23
import type { JSDOMOptions } from '../../types/jsdom-options'
34
import { populateGlobal } from './utils'
45

5-
function catchWindowErrors(window: Window) {
6+
function catchWindowErrors(window: DOMWindow) {
67
let userErrorListenerCount = 0
78
function throwUnhandlerError(e: ErrorEvent) {
89
if (userErrorListenerCount === 0 && e.error != null) {
@@ -70,7 +71,10 @@ export default <Environment>{
7071
userAgent,
7172
...restOptions,
7273
})
73-
const clearWindowErrors = catchWindowErrors(dom.window as any)
74+
75+
const clearAddEventListenerPatch = patchAddEventListener(dom.window)
76+
77+
const clearWindowErrors = catchWindowErrors(dom.window)
7478

7579
// TODO: browser doesn't expose Buffer, but a lot of dependencies use it
7680
dom.window.Buffer = Buffer
@@ -120,6 +124,7 @@ export default <Environment>{
120124
return dom.getInternalVMContext()
121125
},
122126
teardown() {
127+
clearAddEventListenerPatch()
123128
clearWindowErrors()
124129
dom.window.close()
125130
dom = undefined as any
@@ -161,6 +166,8 @@ export default <Environment>{
161166
...restOptions,
162167
})
163168

169+
const clearAddEventListenerPatch = patchAddEventListener(dom.window)
170+
164171
const { keys, originals } = populateGlobal(global, dom.window, {
165172
bindFunctions: true,
166173
})
@@ -171,6 +178,7 @@ export default <Environment>{
171178

172179
return {
173180
teardown(global) {
181+
clearAddEventListenerPatch()
174182
clearWindowErrors()
175183
dom.window.close()
176184
delete global.jsdom
@@ -180,3 +188,43 @@ export default <Environment>{
180188
}
181189
},
182190
}
191+
192+
function patchAddEventListener(window: DOMWindow) {
193+
const JSDOMAbortSignal = window.AbortSignal
194+
const JSDOMAbortController = window.AbortController
195+
const originalAddEventListener = window.EventTarget.prototype.addEventListener
196+
197+
window.EventTarget.prototype.addEventListener = function addEventListener(
198+
type: string,
199+
callback: EventListenerOrEventListenerObject | null,
200+
options?: AddEventListenerOptions | boolean,
201+
) {
202+
if (typeof options === 'object' && options.signal != null) {
203+
const { signal, ...otherOptions } = options
204+
// - this happens because AbortSignal is provided by Node.js,
205+
// but jsdom APIs require jsdom's AbortSignal, while Node APIs
206+
// (like fetch and Request) require a Node.js AbortSignal
207+
// - disable narrow typing with "as any" because we need it later
208+
if (!((signal as any) instanceof JSDOMAbortSignal)) {
209+
const jsdomCompatOptions = Object.create(null)
210+
Object.assign(jsdomCompatOptions, otherOptions)
211+
212+
// use jsdom-native abort controller instead and forward the
213+
// previous one with `addEventListener`
214+
const jsdomAbortController = new JSDOMAbortController()
215+
signal.addEventListener('abort', () => {
216+
jsdomAbortController.abort(signal.reason)
217+
})
218+
219+
jsdomCompatOptions.signal = jsdomAbortController.signal
220+
return originalAddEventListener.call(this, type, callback, jsdomCompatOptions)
221+
}
222+
}
223+
224+
return originalAddEventListener.call(this, type, callback, options)
225+
}
226+
227+
return () => {
228+
window.EventTarget.prototype.addEventListener = originalAddEventListener
229+
}
230+
}

test/core/test/environments/jsdom.spec.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { stripVTControlCharacters } from 'node:util'
44
import { processError } from '@vitest/utils/error'
5-
import { expect, test } from 'vitest'
5+
import { expect, test, vi } from 'vitest'
66

77
test('MessageChannel and MessagePort are available', () => {
88
expect(MessageChannel).toBeDefined()
@@ -40,6 +40,26 @@ test('Fetch API accepts other APIs', () => {
4040
expect.soft(() => new Request('http://localhost', { method: 'POST', body: searchParams })).not.toThrowError()
4141
})
4242

43+
test('DOM APIs accept AbortController', () => {
44+
const element = document.createElement('div')
45+
document.body.append(element)
46+
const controller = new AbortController()
47+
const spy = vi.fn()
48+
element.addEventListener('click', spy, {
49+
signal: controller.signal,
50+
})
51+
52+
element.click()
53+
54+
expect(spy).toHaveBeenCalledTimes(1)
55+
56+
controller.abort()
57+
58+
element.click()
59+
60+
expect(spy).toHaveBeenCalledTimes(1)
61+
})
62+
4363
test('atob and btoa are available', () => {
4464
expect(atob('aGVsbG8gd29ybGQ=')).toBe('hello world')
4565
expect(btoa('hello world')).toBe('aGVsbG8gd29ybGQ=')

0 commit comments

Comments
 (0)