diff --git a/src/bun_core/string/StringJoiner.rs b/src/bun_core/string/StringJoiner.rs index 2af7b9b2a65..f70c32b7a9d 100644 --- a/src/bun_core/string/StringJoiner.rs +++ b/src/bun_core/string/StringJoiner.rs @@ -1,87 +1,53 @@ //! Rope-like data structure for joining many small strings into one big string. -//! Implemented as a flat `Vec` of potentially-owned slices plus a running +//! Implemented as a flat `Vec` of borrowed-or-owned slices plus a running //! length, so the join-time output buffer can be sized exactly once. -use crate::RawSlice; use crate::string::strings; use bun_alloc::AllocError; // PORT NOTE: Zig's `std.mem.Allocator` param field dropped — global mimalloc is used for // node and duplicated-string allocations. #[derive(Default)] -pub struct StringJoiner { +pub struct StringJoiner<'a> { /// Total length of all nodes pub len: usize, /// Slices in insertion order. Stored flat instead of as a singly-linked /// list so a join with N pieces does ~log₂N Vec reallocs instead of N /// `Box` allocations and N pointer-chasing dereferences on drain. - nodes: Vec, + nodes: Vec>, /// Avoid an extra pass over the list when joining - pub watcher: Watcher, + pub watcher: Watcher<'a>, } -// SAFETY: `nodes` holds `RawSlice` raw fat pointers which alias -// caller-owned (`owns_slice = false`) or joiner-owned (`owns_slice = true`) -// storage; no aliasing escapes `&mut self` methods. The Zig original is -// passed across bundler worker threads (see Chunk.IntermediateOutput). -unsafe impl Send for StringJoiner {} -// SAFETY: `&StringJoiner` only exposes read-only views (`last_byte`, -// `node_slices`, `contains`) over `RawSlice` storage with no interior -// mutability; concurrent shared reads of the owned/borrowed-until-`done()` -// byte buffers are data-race-free. -unsafe impl Sync for StringJoiner {} - -struct Node { - /// Replaces Zig's `NullableAllocator`: when `true`, `slice` was heap-allocated by - /// this joiner (via `push_owned`/`push_cloned`) and is freed on node drop; - /// when `false`, `slice` is borrowed and the caller guarantees it outlives `done()`. - owns_slice: bool, - // TODO(port): lifetime — borrowed slices must outlive `done()`; the port avoids - // struct lifetime params, so this is stored as a typed raw fat pointer. - // `RawSlice` (one encapsulated unsafe in `.slice()`) replaces the open-coded - // raw deref at every read site; the backing storage outlives the node by - // either ownership (`owns_slice`) or caller contract. - slice: RawSlice, +enum Node<'a> { + /// Borrowed for `'a`; the caller's data must stay valid until the joiner's + /// last read (`done`/`done_with_end`/`node_slices`/`contains`/`last_byte`). + Borrowed(&'a [u8]), + /// Heap-allocated by this joiner (via `push_owned`/`push_cloned`); freed + /// when the node drops. + Owned(Box<[u8]>), } -impl Node { +impl Node<'_> { #[inline] fn slice(&self) -> &[u8] { - self.slice.slice() - } -} - -// SAFETY: `Node` is a plain (slice, ownership-bit) record; the `RawSlice` raw -// pointer is uniquely owned (or borrowed under caller contract) through the -// `Vec` rooted at `StringJoiner.nodes` and never shared aliased across threads -// concurrently. The Zig original moves these between bundler worker threads. -unsafe impl Send for Node {} -// SAFETY: `&Node` only reads the immutable `RawSlice` backing bytes via -// `slice()`; there is no interior mutability, so concurrent shared access from -// multiple threads cannot race. -unsafe impl Sync for Node {} - -impl Drop for Node { - fn drop(&mut self) { - if self.owns_slice { - // SAFETY: when owns_slice is true, slice was produced by Box::<[u8]>::into_raw - // in `push_cloned`/`push_owned` and has not been freed. - drop(unsafe { crate::heap::take(self.slice.as_ptr().cast_mut()) }); + match self { + Node::Borrowed(slice) => slice, + Node::Owned(boxed) => boxed, } } } #[derive(Default)] -pub struct Watcher { - // TODO(port): lifetime — callers may assign non-'static data; never freed in Zig. - pub input: &'static [u8], +pub struct Watcher<'a> { + pub input: &'a [u8], pub estimated_count: u32, pub needs_newline: bool, } -impl StringJoiner { +impl<'a> StringJoiner<'a> { /// Pre-allocate room for `additional` more pushed slices, so a join with a /// known piece count does a single nodes allocation instead of log₂N grows. pub fn reserve(&mut self, additional: usize) { @@ -89,7 +55,7 @@ impl StringJoiner { } /// `data` is expected to live until `.done` is called - pub fn push_static(&mut self, data: &[u8]) { + pub fn push_static(&mut self, data: &'a [u8]) { self.push(data); } @@ -98,10 +64,7 @@ impl StringJoiner { if data.is_empty() { return; } - let raw: *const [u8] = crate::heap::into_raw(data); - // SAFETY: `raw` is a fresh `Box::into_raw` allocation owned by the node - // until `Node::drop` reclaims it (`owns_slice = true`). - self.push_raw(unsafe { RawSlice::from_raw(raw) }, true); + self.push_node(Node::Owned(data)); } /// `data` is cloned @@ -117,18 +80,16 @@ impl StringJoiner { // The optional allocator only encoded ownership of `data`, which has no Rust // analogue for a borrowed `&[u8]`; callers wanting owned semantics use // `push_owned`/`push_cloned` instead. - pub fn push(&mut self, data: &[u8]) { + pub fn push(&mut self, data: &'a [u8]) { if data.is_empty() { return; } - self.push_raw(RawSlice::new(data), false); + self.push_node(Node::Borrowed(data)); } - fn push_raw(&mut self, data: RawSlice, owned: bool) { - let data_slice = data.slice(); - if data_slice.is_empty() { - return; - } + fn push_node(&mut self, node: Node<'a>) { + let data_slice = node.slice(); + debug_assert!(!data_slice.is_empty()); self.len += data_slice.len(); self.watcher.estimated_count += (self.watcher.input.len() > 0 @@ -136,10 +97,41 @@ impl StringJoiner { as u32; self.watcher.needs_newline = data_slice[data_slice.len() - 1] != b'\n'; - self.nodes.push(Node { - owns_slice: owned, - slice: data, - }); + self.nodes.push(node); + } + + /// Re-tag every borrowed segment (and `watcher.input`) as `'static` so the + /// joiner can be stored in lifetime-free storage and read later (e.g. the + /// bundler's deferred `Chunk.intermediate_output`). + /// + /// # Safety + /// Every borrowed segment previously pushed (`push`/`push_static`) and + /// `watcher.input` must remain valid — not freed, moved, or reallocated — + /// for as long as the returned joiner (or anything it is moved into) is + /// alive. + pub unsafe fn detach_lifetime(self) -> StringJoiner<'static> { + StringJoiner { + len: self.len, + nodes: self + .nodes + .into_iter() + .map(|node| match node { + Node::Borrowed(slice) => { + // SAFETY: caller contract — the backing storage outlives + // the returned joiner. + Node::Borrowed(unsafe { &*core::ptr::from_ref::<[u8]>(slice) }) + } + Node::Owned(boxed) => Node::Owned(boxed), + }) + .collect(), + watcher: Watcher { + // SAFETY: caller contract — `watcher.input` outlives the + // returned joiner. + input: unsafe { &*core::ptr::from_ref::<[u8]>(self.watcher.input) }, + estimated_count: self.watcher.estimated_count, + needs_newline: self.watcher.needs_newline, + }, + } } /// This deinits the string joiner on success, the new string is owned by the caller. @@ -158,7 +150,7 @@ impl StringJoiner { let mut out = Vec::::with_capacity(len); for node in self.nodes.drain(..) { out.extend_from_slice(node.slice()); - // `drop(node)` runs `Node::drop`, freeing `slice` when owned. + // `drop(node)` frees the buffer when owned. } debug_assert_eq!(out.len(), len); Ok(out.into_boxed_slice()) @@ -212,7 +204,112 @@ impl StringJoiner { } } -// `Drop` for `StringJoiner` is implicit: `Vec::drop` runs `Node::drop` -// for each element, which frees joiner-owned slices. +// `Drop` for `StringJoiner` is implicit: `Vec::drop` frees joiner-owned +// slices (`Node::Owned`); borrowed nodes are not freed. + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn push_kinds_concatenate_in_insertion_order() { + let owned: Box<[u8]> = Box::from(b"owned".as_slice()); + let cloned_src = b"cloned".to_vec(); + let mut j = StringJoiner::default(); + j.push(b"borrowed "); + j.push_static(b"static "); + j.push_owned(owned); + j.push_cloned(&cloned_src); + drop(cloned_src); + assert_eq!(j.len, "borrowed static ownedcloned".len()); + assert_eq!(&*j.done().unwrap(), b"borrowed static ownedcloned"); + assert_eq!(j.len, 0); + } + + #[test] + fn empty_pushes_are_skipped() { + let mut j = StringJoiner::default(); + j.push(b""); + j.push_static(b""); + j.push_owned(Box::default()); + j.push_cloned(b""); + assert_eq!(j.len, 0); + assert_eq!(j.node_slices().count(), 0); + assert_eq!(&*j.done().unwrap(), b""); + } + + #[test] + fn done_with_end_appends_suffix() { + let mut j = StringJoiner::default(); + assert_eq!(&*j.done_with_end(b"").unwrap(), b""); + assert_eq!(&*j.done_with_end(b"suffix").unwrap(), b"suffix"); + j.push(b"body"); + assert_eq!(&*j.done_with_end(b"!\n").unwrap(), b"body!\n"); + } + + #[test] + fn last_byte_contains_and_node_slices() { + let mut j = StringJoiner::default(); + assert_eq!(j.last_byte(), 0); + j.push(b"abc"); + j.push_cloned(b"def"); + assert_eq!(j.last_byte(), b'f'); + assert!(!j.contains(b"cd")); + assert!(j.contains(b"de")); + let slices: Vec<&[u8]> = j.node_slices().collect(); + assert_eq!(slices, vec![b"abc".as_slice(), b"def".as_slice()]); + } + + #[test] + fn ensure_newline_at_end_tracks_watcher() { + let mut j = StringJoiner::default(); + j.push(b"no newline"); + j.ensure_newline_at_end(); + j.ensure_newline_at_end(); + assert_eq!(&*j.done().unwrap(), b"no newline\n"); + + let mut j = StringJoiner::default(); + j.push(b"has newline\n"); + j.ensure_newline_at_end(); + assert_eq!(&*j.done().unwrap(), b"has newline\n"); + } + + #[test] + fn watcher_estimates_unique_key_occurrences() { + let mut j = StringJoiner { + watcher: Watcher { + input: b"KEY", + ..Default::default() + }, + ..Default::default() + }; + j.push(b"prefix KEY suffix"); + j.push(b"no match"); + j.push_cloned(b"another KEY"); + assert_eq!(j.watcher.estimated_count, 2); + } + + #[test] + fn detach_lifetime_round_trips_borrowed_data() { + let borrowed = b"KEY borrowed ".to_vec(); + let input = b"KEY".to_vec(); + let mut j = StringJoiner { + watcher: Watcher { + input: &input, + ..Default::default() + }, + ..Default::default() + }; + j.push(&borrowed); + j.push_cloned(b"cloned KEY"); + // SAFETY: `borrowed` and `input` are declared before `detached`, so they outlive it. + let mut detached = unsafe { j.detach_lifetime() }; + assert_eq!(detached.len, "KEY borrowed cloned KEY".len()); + assert_eq!(detached.watcher.input, b"KEY"); + assert_eq!(detached.watcher.estimated_count, 2); + assert!(detached.watcher.needs_newline); + assert_eq!(&*detached.done().unwrap(), b"KEY borrowed cloned KEY"); + } +} // ported from: src/string/StringJoiner.zig diff --git a/src/bundler/Chunk.rs b/src/bundler/Chunk.rs index 34f74806d9d..f5fa0403ded 100644 --- a/src/bundler/Chunk.rs +++ b/src/bundler/Chunk.rs @@ -113,7 +113,7 @@ impl Default for Content { // SAFETY: `Chunk` is processed across the bundler thread pool (see // `computeCrossChunkDependencies`, `generateChunksInParallel`). Raw-pointer -// fields (`Layers::Borrowed`, `StringJoiner` nodes, `ChunkRenamer` arena) +// fields (`Layers::Borrowed`, `ChunkRenamer` arena) // point into bundler-arena storage that outlives the // pool join and is only mutated by the owning task. Zig has no Send/Sync // distinction; mirror `InputFile`'s blanket impls (bundle_v2.rs). @@ -378,7 +378,7 @@ pub enum IntermediateOutput { /// If the chunk doesn't have any references to other chunks, then /// `joiner` contains the contents of the chunk. This is more efficient /// because it avoids doing a join operation twice. - Joiner(StringJoiner), + Joiner(StringJoiner<'static>), #[default] Empty, diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 91e3c4735bc..b1033ef9533 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -4149,7 +4149,7 @@ impl<'a> LinkerContext<'a> { pub fn break_output_into_pieces( &self, _alloc: *const Bump, - j: &mut StringJoiner, + j: &mut StringJoiner<'static>, count: u32, ) -> Result { let _trace = bun::perf::trace("Bundler.breakOutputIntoPieces"); diff --git a/src/bundler/linker_context/MetafileBuilder.rs b/src/bundler/linker_context/MetafileBuilder.rs index 85501db6ec8..f7ecff60a64 100644 --- a/src/bundler/linker_context/MetafileBuilder.rs +++ b/src/bundler/linker_context/MetafileBuilder.rs @@ -410,6 +410,11 @@ pub fn generate(c: &mut LinkerContext, chunks: &mut [Chunk]) -> Result // Break output into pieces and resolve chunk references to final paths let alloc = c.arena(); + // SAFETY: every borrowed node in `j` points into `chunk.metafile_chunk_json`, + // parse-graph data (import-record kind labels), or `'static` literals, all of + // which outlive `intermediate` — it is consumed by `code()` below while `chunks` + // and `c` are still alive. + let mut j = unsafe { j.detach_lifetime() }; let mut intermediate = c.break_output_into_pieces( alloc, &mut j, diff --git a/src/bundler/linker_context/postProcessCSSChunk.rs b/src/bundler/linker_context/postProcessCSSChunk.rs index 54c558b0bfd..b19a7b2e4d8 100644 --- a/src/bundler/linker_context/postProcessCSSChunk.rs +++ b/src/bundler/linker_context/postProcessCSSChunk.rs @@ -142,6 +142,13 @@ pub fn post_process_css_chunk( // SAFETY: `worker.arena` set by `Worker::create`, outlives the worker step. let alloc = worker.arena(); + // SAFETY: every borrowed node in `j` points into `chunk.compile_results_for_chunk` + // (filled in place before post-processing, never reassigned afterwards), graph + // source paths (`Path<'static>`), or `'static` literals; `watcher.input` is + // `chunk.unique_key` (`&'static`). All of these outlive the joiner stored in + // `chunk.intermediate_output`, which is only read while the chunk and the linker + // graph are alive. + let mut j = unsafe { j.detach_lifetime() }; chunk.intermediate_output = bun_core::handle_oom(c.break_output_into_pieces(alloc, &mut j, ctx.chunks.len() as u32)); // TODO: meta contents diff --git a/src/bundler/linker_context/postProcessHTMLChunk.rs b/src/bundler/linker_context/postProcessHTMLChunk.rs index 47d2dd835f8..94d121bf165 100644 --- a/src/bundler/linker_context/postProcessHTMLChunk.rs +++ b/src/bundler/linker_context/postProcessHTMLChunk.rs @@ -31,6 +31,12 @@ pub fn post_process_html_chunk( // SAFETY: `worker.arena` is set by `Worker::create` and outlives the worker step. let alloc = worker.arena(); + // SAFETY: every borrowed node in `j` points into `chunk.compile_results_for_chunk` + // (filled in place before post-processing, never reassigned afterwards); + // `watcher.input` is `chunk.unique_key` (`&'static`). Both outlive the joiner + // stored in `chunk.intermediate_output`, which is only read while the chunk and + // the linker graph are alive. + let mut j = unsafe { j.detach_lifetime() }; chunk.intermediate_output = bun_core::handle_oom(c.break_output_into_pieces( alloc, &mut j, diff --git a/src/bundler/linker_context/postProcessJSChunk.rs b/src/bundler/linker_context/postProcessJSChunk.rs index 8148d71b907..fa7b7530581 100644 --- a/src/bundler/linker_context/postProcessJSChunk.rs +++ b/src/bundler/linker_context/postProcessJSChunk.rs @@ -804,9 +804,9 @@ pub fn post_process_js_chunk( // PERF(port): worker.arena is an arena in Zig let _ = js_printer::quote_for_json(input.pretty, &mut buf, true); // fmt::Result into Vec is infallible // bun.handleOom dropped — Rust aborts on OOM - let str = buf.slice(); // worker.arena is an arena - j.push_static(str); - line_offset.advance(str); + let quoted = buf.take_slice(); + line_offset.advance("ed); + j.push_owned(quoted.into_boxed_slice()); } // { // let str = b"\n react_refresh: "; @@ -842,6 +842,15 @@ pub fn post_process_js_chunk( line_offset.advance(b"\n"); } + // SAFETY: every borrowed node in `j` points into `chunk.compile_results_for_chunk` + // (slots pre-sized in generateChunksInParallel, filled in place by the print + // workers before post-processing, never reassigned afterwards), `c.options` + // banner/footer (`&'static`), graph/parse-graph data (hashbangs, source paths, + // HMR runtime), or `'static` literals; `watcher.input` is `chunk.unique_key` + // (`&'static`). All of these outlive the joiner stored in + // `chunk.intermediate_output`, which is only read while the chunk and the + // linker graph are alive. + let mut j = unsafe { j.detach_lifetime() }; chunk.intermediate_output = c .break_output_into_pieces(worker_arena, &mut j, ctx.chunks.len() as u32) .unwrap_or_else(|_| panic!("Unhandled out of memory error in breakOutputIntoPieces()")); diff --git a/src/runtime/bake/dev_server/source_map_store.rs b/src/runtime/bake/dev_server/source_map_store.rs index 765927d1fb1..469fbff2513 100644 --- a/src/runtime/bake/dev_server/source_map_store.rs +++ b/src/runtime/bake/dev_server/source_map_store.rs @@ -280,10 +280,10 @@ impl Entry { .map_err(|_| EncodeSourceMapPathError::OutOfMemory) } - fn join_vlq( - &self, + fn join_vlq<'a>( + &'a self, kind: ChunkKind, - j: &mut StringJoiner, + j: &mut StringJoiner<'a>, side: Side, ) -> Result<(), bun_core::Error> { let _ = side; diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 6766106f8b7..70d9d1f677b 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -3596,13 +3596,22 @@ impl BlobExt for Blob { // or resizes this buffer before `done()`. joiner.push_cloned(buf.byte_slice()); } else { - joiner.push_static(buf.byte_slice()); + // SAFETY: the prescan above proved no remaining + // part can run user JS, so this buffer (rooted + // via `_keep`/`arg`) stays attached and valid + // until `joiner.done()` below. + joiner.push(unsafe { + bun_ptr::detach_lifetime(buf.byte_slice()) + }); } continue; } jsc::JSType::Array | jsc::JSType::DerivedArray => { - could_have_non_ascii = true; - break; + let sliced = item.to_slice_clone(global)?; + could_have_non_ascii = + could_have_non_ascii || sliced.is_allocated(); + joiner.push_cloned(sliced.slice()); + continue; } jsc::JSType::DOMWrapper => { if let Some(blob) = item.as_class_ref::() { @@ -3613,22 +3622,31 @@ impl BlobExt for Blob { if parts_can_run_js { joiner.push_cloned(blob.shared_view()); } else { - joiner.push_static(blob.shared_view()); + // SAFETY: the prescan above proved no + // remaining part can run user JS, so this + // Blob (rooted via `_keep`/`arg`) keeps its + // Store alive until `joiner.done()` below. + joiner.push(unsafe { + bun_ptr::detach_lifetime(blob.shared_view()) + }); } continue; } else { - let sliced = current.to_slice_clone(global)?; + let sliced = item.to_slice_clone(global)?; could_have_non_ascii = could_have_non_ascii || sliced.is_allocated(); joiner.push_cloned(sliced.slice()); + continue; } } - _ => {} + _ => { + let sliced = item.to_slice_clone(global)?; + could_have_non_ascii = + could_have_non_ascii || sliced.is_allocated(); + joiner.push_cloned(sliced.slice()); + } } } - - // `reserve(iter.len)` above guarantees no realloc here. - stack.push(item); } } @@ -3662,7 +3680,11 @@ impl BlobExt for Blob { | jsc::JSType::BigUint64Array | jsc::JSType::DataView => { let buf = current.as_array_buffer(global).unwrap(); - joiner.push_static(buf.slice()); + // SAFETY: this arm is only reached when the typed array is the + // top-level value (the walk stack is empty), so no user JS runs + // between this push and `joiner.done()` below; `_keep`/`arg` keeps + // the buffer alive for that span. + joiner.push(unsafe { bun_ptr::detach_lifetime(buf.slice()) }); could_have_non_ascii = true; } @@ -3953,7 +3975,7 @@ where /// `*jsc.JSGlobalObject`), so they are stored as plain references rather than /// raw pointers. struct FormDataContext<'a> { - joiner: StringJoiner, + joiner: StringJoiner<'a>, boundary: &'a [u8], // borrowed; outlives the joiner failed: bool, global_this: &'a JSGlobalObject, @@ -3986,7 +4008,7 @@ impl FormDataContext<'_> { /// (Zig: `joiner.push(slice, slice.allocator.get())`); an owned slice /// (UTF-16 / non-ASCII Latin-1 conversion) transfers its allocation to the /// joiner. When `escape` is set, `"`/CR/LF are percent-encoded into a copy. - fn push_string_slice(joiner: &mut StringJoiner, slice: ZigStringSlice, escape: bool) { + fn push_string_slice(joiner: &mut StringJoiner<'_>, slice: ZigStringSlice, escape: bool) { if escape { if let Some(escaped) = escape_form_data_name(slice.slice()) { joiner.push_owned(escaped); @@ -3997,7 +4019,9 @@ impl FormDataContext<'_> { // `into_vec` moves the buffer out of an `Owned` slice without copying. joiner.push_owned(slice.into_vec().into_boxed_slice()); } else if matches!(slice, ZigStringSlice::Static(..)) { - joiner.push_static(slice.slice()); + // SAFETY: `Static` bytes are owned by the `DOMFormData` being serialized + // (never freed), which outlives `joiner.done()` in `from_dom_form_data`. + joiner.push(unsafe { bun_ptr::detach_lifetime(slice.slice()) }); } else { // WTF-backed slices release their pin on drop — copy rather than // borrow past it. (`ZigString::to_slice` never produces these.) @@ -4043,7 +4067,10 @@ impl FormDataContext<'_> { b"application/octet-stream" }; joiner.push_static(b"Content-Type: "); - joiner.push_static(content_type); + // SAFETY: either a `'static` literal or borrowed from the entry's Blob, + // which the `DOMFormData` keeps alive past `joiner.done()` in + // `from_dom_form_data`. + joiner.push(unsafe { bun_ptr::detach_lifetime(content_type) }); joiner.push_static(b"\r\n\r\n"); if blob.store.get().is_some() { @@ -4095,10 +4122,10 @@ impl FormDataContext<'_> { } } store::Data::Bytes(_) => { - // Borrowed: the blob's store is kept alive by the - // `DOMFormData` entry until after `joiner.done()` - // (Zig used `pushStatic` here too). - joiner.push_static(blob.shared_view()); + // SAFETY: borrowed from the blob's store, which the + // `DOMFormData` entry keeps alive until after + // `joiner.done()` (Zig used `pushStatic` here too). + joiner.push(unsafe { bun_ptr::detach_lifetime(blob.shared_view()) }); } } } diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs index a8b6f145881..47fab753ea5 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -1278,11 +1278,11 @@ pub fn parse_json( // After all chunks are computed, they are joined together in a second pass. // This rewrites the first mapping in each chunk to be relative to the end // state of the previous chunk. -pub fn append_source_map_chunk( - j: &mut bun_core::string_joiner::StringJoiner, +pub fn append_source_map_chunk<'a>( + j: &mut bun_core::string_joiner::StringJoiner<'a>, prev_end_state_: SourceMapState, start_state_: SourceMapState, - source_map_: &[u8], + source_map_: &'a [u8], ) -> Result<(), bun_core::Error> { // TODO(port): narrow error set let mut prev_end_state = prev_end_state_; diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index 7e5073a6e99..d113a799071 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -103,6 +103,15 @@ test("new Blob", () => { expect(blob.type).toBe(""); }); +test("new Blob stringifies non-Blob object parts in order", async () => { + const url = new URL("https://example.com/path"); + expect(await new Blob([url]).text()).toBe("https://example.com/path"); + expect(await new Blob(["a", url, "b"]).text()).toBe("ahttps://example.com/pathb"); + expect(await new Blob(["a", {}, "b"]).text()).toBe("a[object Object]b"); + expect(await new Blob(["a", {}, "b", { toString: () => "X" }]).text()).toBe("a[object Object]bX"); + expect(await new Blob(["a", ["x", "y"], "b"]).text()).toBe("ax,yb"); +}); + test("blob: can be fetched", async () => { const blob = new Blob(["Bun", "Foo"]); const url = URL.createObjectURL(blob);