Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
435516605e fix(blob): read file-backed blobs when constructing Blob from multiple parts
When a `Bun.file()` blob was combined with other parts in `new Blob([...])`,
its contents were silently dropped because `sharedView()` returns empty for
file-backed blobs whose data hasn't been read into memory yet.

Now synchronously reads file contents when file-backed blobs appear as parts
in the Blob constructor, matching the behavior when `Bun.file()` is the sole
part.

Closes #27071

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-17 04:23:20 +00:00
2 changed files with 113 additions and 2 deletions

View File

@@ -3836,6 +3836,33 @@ fn fromJSMovable(
return FromJSFunction(global, arg);
}
/// Synchronously read a file-backed blob's contents and push them to the joiner.
/// Used when constructing a new Blob from parts that include file-backed blobs.
fn readFileIntoJoiner(blob: *Blob, global: *JSGlobalObject, joiner: *StringJoiner) bun.JSError!void {
const store = blob.store orelse return;
if (store.data != .file) return;
const file = store.data.file;
const res = jsc.Node.fs.NodeFS.readFile(
global.bunVM().nodeFS(),
.{
.encoding = .buffer,
.path = file.pathlike,
.offset = blob.offset,
.max_size = blob.size,
},
.sync,
);
switch (res) {
.err => |err| {
return global.throwValue(try err.toJS(global));
},
.result => |result| {
joiner.push(result.slice(), result.buffer.allocator);
},
}
}
fn fromJSWithoutDeferGC(
global: *JSGlobalObject,
arg: JSValue,
@@ -4022,7 +4049,11 @@ fn fromJSWithoutDeferGC(
.DOMWrapper => {
if (item.as(Blob)) |blob| {
could_have_non_ascii = could_have_non_ascii or blob.charset != .all_ascii;
joiner.pushStatic(blob.sharedView());
if (blob.needsToReadFile()) {
try readFileIntoJoiner(blob, global, &joiner);
} else {
joiner.pushStatic(blob.sharedView());
}
continue;
} else {
const sliced = try current.toSliceClone(global);
@@ -4042,7 +4073,11 @@ fn fromJSWithoutDeferGC(
.DOMWrapper => {
if (current.as(Blob)) |blob| {
could_have_non_ascii = could_have_non_ascii or blob.charset != .all_ascii;
joiner.pushStatic(blob.sharedView());
if (blob.needsToReadFile()) {
try readFileIntoJoiner(blob, global, &joiner);
} else {
joiner.pushStatic(blob.sharedView());
}
} else {
const sliced = try current.toSliceClone(global);
const allocator = sliced.allocator.get();

View File

@@ -0,0 +1,76 @@
import { expect, test } from "bun:test";
import { tempDir } from "harness";
test("new Blob([Bun.file(), buffer]) includes file contents", async () => {
using dir = tempDir("blob-file-concat", {
"testfile.txt": "HELLO_FROM_FILE",
});
const file = Bun.file(`${dir}/testfile.txt`);
const buffer = Buffer.from("BUFFER_DATA");
// file + buffer
const r1 = await new Blob([file, buffer]).text();
expect(r1).toBe("HELLO_FROM_FILEBUFFER_DATA");
// buffer + file
const r2 = await new Blob([buffer, file]).text();
expect(r2).toBe("BUFFER_DATAHELLO_FROM_FILE");
// file + file
const r3 = await new Blob([file, file]).text();
expect(r3).toBe("HELLO_FROM_FILEHELLO_FROM_FILE");
// single file still works
const r4 = await new Blob([file]).text();
expect(r4).toBe("HELLO_FROM_FILE");
// size should be correct
expect(new Blob([file, buffer]).size).toBe(26);
expect(new Blob([buffer, file]).size).toBe(26);
expect(new Blob([file, file]).size).toBe(30);
});
test("new Blob([Bun.file(), string]) includes file contents", async () => {
using dir = tempDir("blob-file-string", {
"testfile.txt": "FILE_CONTENT",
});
const file = Bun.file(`${dir}/testfile.txt`);
const r1 = await new Blob([file, "STRING_DATA"]).text();
expect(r1).toBe("FILE_CONTENTSTRING_DATA");
const r2 = await new Blob(["STRING_DATA", file]).text();
expect(r2).toBe("STRING_DATAFILE_CONTENT");
});
test("new Blob([Bun.file(), Uint8Array]) includes file contents", async () => {
using dir = tempDir("blob-file-uint8", {
"testfile.txt": "FILE_DATA",
});
const file = Bun.file(`${dir}/testfile.txt`);
const uint8 = new Uint8Array([65, 66, 67]); // "ABC"
const r1 = await new Blob([file, uint8]).text();
expect(r1).toBe("FILE_DATAABC");
const r2 = await new Blob([uint8, file]).text();
expect(r2).toBe("ABCFILE_DATA");
});
test("new Blob([Bun.file(), Blob]) includes file contents", async () => {
using dir = tempDir("blob-file-blob", {
"testfile.txt": "FILE_DATA",
});
const file = Bun.file(`${dir}/testfile.txt`);
const otherBlob = new Blob(["BLOB_DATA"]);
const r1 = await new Blob([file, otherBlob]).text();
expect(r1).toBe("FILE_DATABLOB_DATA");
const r2 = await new Blob([otherBlob, file]).text();
expect(r2).toBe("BLOB_DATAFILE_DATA");
});