Skip to content

Commit a1b7361

Browse files
authored
fix(jsdom): support URL.createObjectURL, FormData.set(prop, blob) (#8935)
1 parent 312a612 commit a1b7361

File tree

4 files changed

+128
-25
lines changed

4 files changed

+128
-25
lines changed

.github/actions/setup-and-cache/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ inputs:
44
node-version:
55
required: false
66
description: Node version for setup-node
7-
default: 20.x
7+
default: 24.x
88

99
runs:
1010
using: composite

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ const LIVING_KEYS = [
135135
'Plugin',
136136
'MimeType',
137137
'FileReader',
138+
'FormData',
138139
'Blob',
139140
'File',
140141
'FileList',
@@ -184,7 +185,6 @@ const LIVING_KEYS = [
184185
// 'AbortSignal',
185186
// 'URL',
186187
// 'URLSearchParams',
187-
// 'FormData',
188188
]
189189

190190
const OTHER_KEYS = [

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

Lines changed: 81 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { DOMWindow } from 'jsdom'
22
import type { Environment } from '../../types/environment'
33
import type { JSDOMOptions } from '../../types/jsdom-options'
4+
import { URL as NodeURL } from 'node:url'
45
import { populateGlobal } from './utils'
56

67
function catchWindowErrors(window: DOMWindow) {
@@ -35,14 +36,18 @@ function catchWindowErrors(window: DOMWindow) {
3536
}
3637
}
3738

38-
let _FormData!: typeof FormData
39+
let NodeFormData_!: typeof FormData
40+
let NodeBlob_!: typeof Blob
41+
let NodeRequest_!: typeof Request
3942

4043
export default <Environment>{
4144
name: 'jsdom',
4245
viteEnvironment: 'client',
4346
async setupVM({ jsdom = {} }) {
4447
// delay initialization because it takes ~1s
45-
_FormData = globalThis.FormData
48+
NodeFormData_ = globalThis.FormData
49+
NodeBlob_ = globalThis.Blob
50+
NodeRequest_ = globalThis.Request
4651

4752
const { CookieJar, JSDOM, ResourceLoader, VirtualConsole } = await import(
4853
'jsdom',
@@ -82,12 +87,15 @@ export default <Environment>{
8287

8388
const clearWindowErrors = catchWindowErrors(dom.window)
8489

90+
const utils = createCompatUtils(dom.window)
91+
8592
// TODO: browser doesn't expose Buffer, but a lot of dependencies use it
8693
dom.window.Buffer = Buffer
8794
dom.window.jsdom = dom
88-
dom.window.FormData = createFormData(dom.window)
95+
dom.window.Request = createCompatRequest(utils)
96+
dom.window.URL = createJSDOMCompatURL(utils)
8997

90-
// inject web globals if they missing in JSDOM but otherwise available in Nodejs
98+
// inject web globals if they are missing in JSDOM but otherwise available in Nodejs
9199
// https://nodejs.org/dist/latest/docs/api/globals.html
92100
const globalNames = [
93101
'structuredClone',
@@ -111,13 +119,14 @@ export default <Environment>{
111119
// we also should override other APIs they use
112120
const overrideGlobals = [
113121
'fetch',
114-
'Request',
115122
'Response',
116123
'Headers',
117124
'AbortController',
118125
'AbortSignal',
119-
'URL',
120126
'URLSearchParams',
127+
// URL and Request is overriden with a compat one
128+
// 'URL',
129+
// 'Request',
121130
] as const
122131
for (const name of overrideGlobals) {
123132
const value = globalThis[name]
@@ -140,7 +149,9 @@ export default <Environment>{
140149
},
141150
async setup(global, { jsdom = {} }) {
142151
// delay initialization because it takes ~1s
143-
_FormData = globalThis.FormData
152+
NodeFormData_ = globalThis.FormData
153+
NodeBlob_ = globalThis.Blob
154+
NodeRequest_ = globalThis.Request
144155

145156
const { CookieJar, JSDOM, ResourceLoader, VirtualConsole } = await import(
146157
'jsdom',
@@ -183,9 +194,11 @@ export default <Environment>{
183194
})
184195

185196
const clearWindowErrors = catchWindowErrors(global)
197+
const utils = createCompatUtils(dom.window)
186198

187199
global.jsdom = dom
188-
global.FormData = createFormData(dom.window)
200+
global.Request = createCompatRequest(utils)
201+
global.URL = createJSDOMCompatURL(utils)
189202

190203
return {
191204
teardown(global) {
@@ -200,26 +213,71 @@ export default <Environment>{
200213
},
201214
}
202215

203-
// Node.js 24 has a global FormData that Request accepts
204-
// FormData is not used anywhere else in JSDOM, so we can safely
205-
// override it with Node.js implementation, but keep the DOM behaviour
206-
// this is required because Request (and other fetch API)
207-
// are not implemented by JSDOM
208-
function createFormData(window: DOMWindow) {
209-
const JSDOMFormData = window.FormData
210-
if (!_FormData) {
211-
return JSDOMFormData
216+
function createCompatRequest(utils: CompatUtils) {
217+
return class Request extends NodeRequest_ {
218+
constructor(...args: [input: RequestInfo, init?: RequestInit]) {
219+
const [input, init] = args
220+
if (init?.body != null) {
221+
const compatInit = { ...init }
222+
if (init.body instanceof utils.window.Blob) {
223+
compatInit.body = utils.makeCompatBlob(init.body as any) as any
224+
}
225+
if (init.body instanceof utils.window.FormData) {
226+
compatInit.body = utils.makeCompatFormData(init.body)
227+
}
228+
super(input, compatInit)
229+
}
230+
else {
231+
super(...args)
232+
}
233+
}
212234
}
235+
}
236+
237+
function createJSDOMCompatURL(utils: CompatUtils): typeof URL {
238+
return class URL extends NodeURL {
239+
static createObjectURL(blob: any): string {
240+
if (blob instanceof utils.window.Blob) {
241+
const compatBlob = utils.makeCompatBlob(blob)
242+
return NodeURL.createObjectURL(compatBlob as any)
243+
}
244+
return NodeURL.createObjectURL(blob)
245+
}
246+
} as typeof URL
247+
}
213248

214-
return class FormData extends _FormData {
215-
constructor(...args: any[]) {
216-
super()
217-
const formData = new JSDOMFormData(...args)
249+
interface CompatUtils {
250+
window: DOMWindow
251+
makeCompatBlob: (blob: Blob) => Blob
252+
makeCompatFormData: (formData: FormData) => FormData
253+
}
254+
255+
function createCompatUtils(window: DOMWindow): CompatUtils {
256+
// this returns a hidden Symbol(impl)
257+
// this is cursed, and jsdom should just implement fetch API itself
258+
const implSymbol = Object.getOwnPropertySymbols(
259+
Object.getOwnPropertyDescriptors(new window.Blob()),
260+
)[0]
261+
const utils = {
262+
window,
263+
makeCompatFormData(formData: FormData) {
264+
const nodeFormData = new NodeFormData_()
218265
formData.forEach((value, key) => {
219-
this.append(key, value)
266+
if (value instanceof window.Blob) {
267+
nodeFormData.append(key, utils.makeCompatBlob(value as any) as any)
268+
}
269+
else {
270+
nodeFormData.append(key, value)
271+
}
220272
})
221-
}
273+
return nodeFormData
274+
},
275+
makeCompatBlob(blob: Blob) {
276+
const buffer = (blob as any)[implSymbol]._buffer
277+
return new NodeBlob_([buffer], { type: blob.type })
278+
},
222279
}
280+
return utils
223281
}
224282

225283
function patchAddEventListener(window: DOMWindow) {

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ test('Fetch API accepts other APIs', async () => {
4040
expect.soft(() => new Request('http://localhost', { method: 'POST', body: searchParams })).not.toThrowError()
4141
})
4242

43+
test('fetch api doesnt override the init object', () => {
44+
const body = new FormData()
45+
const init: RequestInit = {
46+
method: 'post',
47+
body,
48+
}
49+
const _request = new Request('http://localhost', init)
50+
expect(init.body).toBe(body)
51+
})
52+
4353
describe('FormData', () => {
4454
test('can pass down a simple form data', async () => {
4555
const formData = new FormData()
@@ -142,6 +152,32 @@ describe('FormData', () => {
142152
expect(error).toEqual(expectedError)
143153
}
144154
})
155+
156+
test('supports Blob', () => {
157+
const form = new FormData()
158+
const key = 'prop'
159+
160+
const data = new Blob()
161+
162+
form.set(key, data)
163+
164+
const retrievedBlob = form.get(key)
165+
166+
expect(retrievedBlob).toBeInstanceOf(Blob)
167+
})
168+
169+
test('supports File', () => {
170+
const form = new FormData()
171+
const key = 'prop'
172+
173+
const data = new File([], 'name')
174+
175+
form.set(key, data)
176+
177+
const retrievedBlob = form.get(key)
178+
179+
expect(retrievedBlob).toBeInstanceOf(File)
180+
})
145181
})
146182

147183
test('DOM APIs accept AbortController', () => {
@@ -234,6 +270,15 @@ test('request doesn\'t support absolute URL because jsdom doesn\'t provide compa
234270
}).toThrow(/Failed to parse URL/)
235271
})
236272

273+
test('URL.createObjectUrl works properly', () => {
274+
expect(() => {
275+
URL.createObjectURL(new Blob())
276+
}).not.toThrow()
277+
expect(() => {
278+
URL.createObjectURL(new File([], 'name.js'))
279+
}).not.toThrow()
280+
})
281+
237282
test('jsdom global is exposed', () => {
238283
// @ts-expect-error -- jsdom is not exposed in our types because we use a single tsconfig for all
239284
const dom = jsdom

0 commit comments

Comments
 (0)