Give File its own prototype with Symbol.toStringTag "File"

Closed
RO
3mo ago
+266-14

Repro

const file = new File(["Hello"], "file.txt", { type: "text/plain" });
file[Symbol.toStringTag]              // "Blob"   (expected "File")
Object.prototype.toString.call(file)  // "[object Blob]"
File.prototype === Blob.prototype     // true    (expected false)

Node and browsers return "File" / "[object File]". Packages that branch on the string tag (e.g. some fetch/form-data libraries) misclassify File instances as plain Blobs.

Cause

JSDOMFile.cpp set File.prototype directly to the Blob.prototype object and allocated new File() instances with JSBlobStructure():

// This is not quite right. But we'll fix it if someone files an issue about it.
object->putDirect(vm, vm.propertyNames->prototype, zigGlobal->JSBlobPrototype(), ...);
...
Structure* structure = globalObject->JSBlobStructure();

There was no distinct File.prototype, so instances inherited Symbol.toStringTag = "Blob" from JSBlobPrototype.

Fix

Add JSDOMFilePrototype (a plain object whose [[Prototype]] is Blob.prototype) with Symbol.toStringTag = "File", and a cached m_JSDOMFileStructure on ZigGlobalObject that points instances at it. The File constructor's own [[Prototype]] is now the Blob constructor and File.prototype.constructor is File, matching Node and Web IDL.

Paths routed through the new structure:

  • new File() and its subclasses (JSDOMFile::construct)
  • BlobExt::to_js when is_jsdom_file is set (structuredClone, postMessage)

BUN__createJSDOMFileUnsafely touches JSDOMFileConstructor() so File.prototype.constructor is wired even when a File is materialized (e.g. structuredClone in a fresh Worker) before globalThis.File is accessed.

Related: Blob.prototype.slice on a File previously returned an object with is_jsdom_file = true (so instanceof File === true). That flag is now cleared on the slice so file.slice() is a plain Blob as the File API spec and Node/browsers require.

Instances remain WebCore::JSBlob cells; only the structure/prototype differs. jsDynamicCast<JSBlob*> and the is_jsdom_file-based instanceof File check are unchanged.

Verification

New tests in test/js/web/fetch/blob.test.ts cover new File(), File subclasses, file.slice(), structuredClone(file), a Worker receiving a File before touching globalThis.File, and regression guards for new Blob(). All existing blob.test.ts, FormData.test.ts, and structured-clone tests pass with the debug build; the new tests fail with USE_SYSTEM_BUN=1.

Fixes #14102

Note: #31656 addresses the same issue by putting Symbol.toStringTag on each File instance. This PR instead gives File its own prototype so File.prototype !== Blob.prototype, Object.getPrototypeOf(File.prototype) === Blob.prototype, and File.prototype.constructor === File all hold as they do in Node and browsers.


[review] gate passed · iteration 3 · 8 files touched

fails on main (without fix)
ASAN without fix: 8 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/fetch/blob.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (84c40f0f0)

test/js/web/fetch/blob.test.ts:
(pass) blob: imports have sourcemapped stacktraces [20.78ms]
(pass) Blob.slice [44.42ms]
(pass) Bun.file().slice [51.93ms]
(pass) new Blob [4.24ms]
(pass) new Blob stringifies non-Blob object parts in order [12.12ms]
(pass) blob: can be fetched [13.26ms]
(pass) blob: URL has Content-Type [10.97ms]
(pass) blob: can be imported [15.36ms]
(pass) blob: can reliable get type from fetch #10072 [253.73ms]
(pass) new Blob(new Uint8Array()) is supported [5.06ms]
(pass) new File(new Uint8Array()) is supported [4.63ms]
(pass) new File('123', '123') is NOT supported [3.03ms]
(pass) new File() lastModified option > lastModified: NaN -> 0 [3.25ms]
(pass) new File() lastModified option > lastModified: "not 
... (truncated)

release without fix: 1 failed, 2 skipped
bun test v1.4.0-canary.1 (02de7dc01)

test/js/web/fetch/blob.test.ts:
(pass) blob: imports have sourcemapped stacktraces [0.59ms]
(pass) Blob.slice [0.59ms]
(pass) Bun.file().slice [0.56ms]
(pass) new Blob [0.06ms]
(pass) new Blob stringifies non-Blob object parts in order [0.17ms]
(pass) blob: can be fetched [0.27ms]
(pass) blob: URL has Content-Type [0.18ms]
(pass) blob: can be imported [0.26ms]
(pass) blob: can reliable get type from fetch #10072 [204.20ms]
(pass) new Blob(new Uint8Array()) is supported [0.25ms]
(pass) new File(new Uint8Array()) is supported [0.09ms]
(pass) new File('123', '123') is NOT supported [0.06ms]
(pass) new File() lastModified option > lastModified: NaN -> 0 [0.05ms]
(pass) new File() lastModified option > lastModified: "not a number" -> 0
(pass) new File() lastModified option > lastModified: {} -> 0
(pass) new File() lastModified option > lastModified: {
  valueOf: [Function: valueOf],
} -> 0 [0.02ms]
(pass) new File() lastModified option > lastModified: null -> 0
(pass) new File() lastModified option > lastModified: "" -> 0
(pass) new File() lastModified option > lastModified: false -> 0
(pass) new File() lastModified option > lastModi
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/fetch/blob.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (84c40f0f0)

test/js/web/fetch/blob.test.ts:
(pass) blob: imports have sourcemapped stacktraces [20.44ms]
(pass) Blob.slice [45.04ms]
(pass) Bun.file().slice [51.07ms]
(pass) new Blob [4.17ms]
(pass) new Blob stringifies non-Blob object parts in order [11.45ms]
(pass) blob: can be fetched [13.78ms]
(pass) blob: URL has Content-Type [10.86ms]
(pass) blob: can be imported [15.39ms]
(pass) blob: can reliable get type from fetch #10072 [256.77ms]
(pass) new Blob(new Uint8Array()) is supported [4.90ms]
(pass) new File(new Uint8Array()) is supported [4.79ms]
(pass) new File('123', '123') is NOT supported [3.04ms]
(pass) new File() lastModified option > lastModified: NaN -> 0 [3.19ms]
(pass) new File() lastModified option > lastModified: "not 
... (truncated)

release with fix: 2 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 687ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/119] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 243 extern-C blocks audited
[2/119] gen cpp.rs (cppbind)
[2/119] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

info: checking for self-update (current version: 1.29.0)
  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

   Compiling bun_core v0.0.0 (/workspace/bun/src/bun_core)
   Compiling bun_errno v0.0.0
... (truncated)
diff hotspot
src/jsc/bindings/JSDOMFile.cpp       |  87 +++++++++++++++++---
 src/jsc/bindings/JSDOMFile.h         |   1 +
 src/jsc/bindings/ZigGlobalObject.cpp |   5 ++
 src/jsc/bindings/ZigGlobalObject.h   |   2 +
 src/jsc/bindings/blob.cpp            |   5 +-
 src/jsc/webcore_types.rs             |   5 +-
 src/runtime/webcore/Blob.rs          |  22 ++++-
 test/js/web/fetch/blob.test.ts       | 153 +++++++++++++++++++++++++++++++++++
 8 files changed, 266 insertions(+), 14 deletions(-)

gate history · 2 passed · 0 rejected · iteration 3

evidence per changed file
file                                  reads  edits  tests
src/jsc/bindings/JSDOMFile.cpp            4      6     25
src/jsc/bindings/JSDOMFile.h              1      1     25
src/jsc/bindings/ZigGlobalObject.cpp      3      1     25
src/jsc/bindings/ZigGlobalObject.h        2      2     25
src/jsc/bindings/blob.cpp                 4      4     25
src/jsc/webcore_types.rs                  2      1     25
src/runtime/webcore/Blob.rs              13      6     25
test/js/web/fetch/blob.test.ts           11     16     25