diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 134386c5faf4..54e84d0f0165 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -633,7 +633,7 @@ impl<'a> LinkerContext<'a> { let source_index: u32 = unsafe { (*parse_graph).path_to_source_index_map(Target::Browser) } - .get(path_text) + .get_with_loader(path_text, Loader::Html) .unwrap_or_else(|| { panic!("Assertion failed: HTML import file not found in pathToSourceIndexMap"); }); diff --git a/src/bundler/PathToSourceIndexMap.rs b/src/bundler/PathToSourceIndexMap.rs index f0e42df43810..c4b2cb9f7d0b 100644 --- a/src/bundler/PathToSourceIndexMap.rs +++ b/src/bundler/PathToSourceIndexMap.rs @@ -1,6 +1,8 @@ use bun_collections::StringHashMap; +use enum_map::EnumMap; use crate::IndexStringMap::IndexInt; +use crate::options::Loader; /// Abstracts over the two structurally-identical `Path` ports (`bun_paths::fs::Path` /// and `bun_resolver::fs::Path`) so the bundler can key the map with either while @@ -18,50 +20,130 @@ impl PathLike for bun_paths::fs::Path<'_> { } } -/// The lifetime of the keys are not owned by this map. -/// -/// We assume it's arena allocated. +pub(crate) type GetOrPutResult<'a, V> = bun_collections::hash_map::GetOrPutResult<'a, V>; + +/// Keyed by resolved path plus the loader the import asked for, so `./x.json` +/// imported `with { type: "text" }` and imported plainly are two modules. #[derive(Default)] -pub struct PathToSourceIndexMap { - pub(crate) map: Map, +pub struct ModuleMap { + /// The first loader registered for each path. Nearly every path only ever has one. + by_path: StringHashMap>, + /// Further loaders for paths already in `by_path`. + by_loader: Option>>>, + /// The dev server's IncrementalGraph is keyed by path alone. + pub(crate) one_module_per_path: bool, } -pub type Map = StringHashMap; +#[derive(Default)] +struct FirstRegistered { + loader: Loader, + value: V, +} -/// std `HashMap::entry` doesn't expose -/// `found_existing` + value-ptr together, so we hand-roll a thin shim. -pub(crate) type GetOrPutResult<'a> = bun_collections::string_hash_map::GetOrPutResult<'a, IndexInt>; +pub type PathToSourceIndexMap = ModuleMap; + +impl ModuleMap { + /// The first module registered for `text`, whatever loader it was registered with. + pub(crate) fn get(&self, text: &[u8]) -> Option { + self.by_path.get(text).map(|first| first.value) + } -impl PathToSourceIndexMap { - pub(crate) fn get_path(&self, path: &impl PathLike) -> Option { + pub(crate) fn get_path(&self, path: &impl PathLike) -> Option { self.get(path.path_text()) } - pub(crate) fn get(&self, text: impl AsRef<[u8]>) -> Option { - self.map.get(text.as_ref()).copied() + pub(crate) fn get_with_loader(&self, text: &[u8], loader: Loader) -> Option { + let first = self.by_path.get(text)?; + if self.one_module_per_path || first.loader == loader { + return Some(first.value); + } + self.by_loader.as_ref()?[loader].get(text).copied() + } + + pub(crate) fn get_or_put( + &mut self, + text: &[u8], + loader: Loader, + ) -> Result, bun_alloc::AllocError> { + let one_module_per_path = self.one_module_per_path; + let first = self.by_path.get_or_put(text)?; + if !first.found_existing { + first.value_ptr.loader = loader; + } + if !first.found_existing || one_module_per_path || first.value_ptr.loader == loader { + return Ok(GetOrPutResult { + found_existing: first.found_existing, + value_ptr: &mut first.value_ptr.value, + }); + } + self.by_loader.get_or_insert_default()[loader].get_or_put(text) } - // Takes `&[u8]` (not `impl AsRef<[u8]>`) - // to avoid E0283 inference ambiguity at `.into()` call sites in bundle_v2. pub(crate) fn put( &mut self, text: &[u8], - value: IndexInt, + loader: Loader, + value: V, ) -> Result<(), bun_alloc::AllocError> { - // PERF: bun_collections::StringHashMap is keyed by `Box<[u8]>`, so we dupe here. - // Revisit once StringHashMap gains a borrowed-key variant. - self.map.put(text, value) + *self.get_or_put(text, loader)?.value_ptr = value; + Ok(()) } - pub(crate) fn get_or_put( - &mut self, - text: impl AsRef<[u8]>, - ) -> Result, bun_alloc::AllocError> { - // PERF: see note in `put` re: key duplication. - self.map.get_or_put(text.as_ref()) + /// Lookups of `text` that would have found `from` find `to` instead, whichever + /// loader `from` was registered under. + pub(crate) fn redirect(&mut self, text: &[u8], from: V, to: V) + where + V: PartialEq, + { + if let Some(first) = self.by_path.get_mut(text) { + if first.value == from { + first.value = to; + return; + } + } + if let Some(by_loader) = &mut self.by_loader { + for map in by_loader.values_mut() { + if let Some(value) = map.get_mut(text) { + if *value == from { + *value = to; + return; + } + } + } + } + } + + /// Forgets every module registered for `text`, under any loader. + pub fn remove(&mut self, text: &[u8]) -> bool { + let mut removed = self.by_path.remove(text).is_some(); + if let Some(by_loader) = &mut self.by_loader { + for map in by_loader.values_mut() { + removed |= map.remove(text).is_some(); + } + } + removed + } + + pub(crate) fn reserve(&mut self, additional: usize) { + self.by_path.reserve(additional); + } + + pub(crate) fn clear(&mut self) { + self.by_path.clear(); + self.by_loader = None; } - pub fn remove(&mut self, text: impl AsRef<[u8]>) -> bool { - self.map.remove(text.as_ref()).is_some() + /// `(path, value)` for every registered module, `by_path` entries first. + pub(crate) fn iter(&self) -> impl Iterator { + let first = self + .by_path + .iter() + .map(|(text, first)| (&**text, first.value)); + let others = self + .by_loader + .iter() + .flat_map(|by_loader| by_loader.values()) + .flat_map(|map| map.iter().map(|(text, value)| (&**text, *value))); + first.chain(others) } } diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 17ac4efe2b1d..dfe24f5b87f5 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -6,7 +6,7 @@ use core::ptr::NonNull; -use bun_collections::{ArrayHashMap, StringHashMap}; +use bun_collections::ArrayHashMap; use bun_core::ThreadLock; // `bake_types` / `dispatch` are canonically defined in `bv2_impl` below @@ -32,7 +32,7 @@ pub use bv2_impl::{ pub use crate::DeferredBatchTask::DeferredBatchTask; use crate::Graph::Graph; -use crate::PathToSourceIndexMap::PathToSourceIndexMap; +use crate::PathToSourceIndexMap::{ModuleMap, PathToSourceIndexMap}; use crate::barrel_imports::RequestedExports; use crate::cache::ExternalFreeFunction; use crate::options::{self, Target}; @@ -146,8 +146,8 @@ bun_core::declare_scope!(Bundle, visible); bun_core::declare_scope!(scan_counter, visible); /// Values are raw `*mut ParseTask` (arena-owned by `graph.heap`); the map only -/// dedups by path during a single `on_parse_task_complete` pass. -pub(crate) type ResolveQueue = StringHashMap<*mut ParseTask>; +/// dedups by path + loader during a single `on_parse_task_complete` pass. +pub(crate) type ResolveQueue = ModuleMap<*mut ParseTask>; pub struct BakeOptions<'a> { pub framework: bake::Framework, @@ -1053,6 +1053,8 @@ pub mod bv2_impl { pub(crate) import_record_index: u32, pub(crate) range: bun_ast::Range, pub(crate) original_target: Target, + /// Loader requested by the import's `with { type }` attribute, if any. + pub(crate) loader: Option, } /// Mirrors `JSBundler.Resolve.Value.success` payload. @@ -2255,6 +2257,12 @@ pub mod bv2_impl { ) { let file_map_result = _file_map_result; let mut path_primary = file_map_result.path_pair.primary; + let loader: Loader = import_record.loader.unwrap_or_else(|| { + // SAFETY: see `transpiler` note above. + Fs::Path::init(path_primary.text) + .loader(unsafe { &(*transpiler).options.loaders }) + .unwrap_or(Loader::File) + }); // reshaped for borrowck — `get_or_put` borrows `*self` mutably via // `self.graph`; capture the slot as `*mut u32` so subsequent `self.*` calls // type-check. SAFETY: `path_to_source_index_map(target)` is not mutated again @@ -2262,7 +2270,7 @@ pub mod bv2_impl { let (found_existing, value_ptr): (bool, *mut u32) = { let entry = self .path_to_source_index_map(target) - .get_or_put(path_primary.text) + .get_or_put(path_primary.text, loader) .expect("oom"); ( entry.found_existing, @@ -2270,20 +2278,6 @@ pub mod bv2_impl { ) }; if !found_existing { - let loader: Loader = 'brk: { - let record: &mut ImportRecord = - &mut self.graph.ast.items_import_records_mut() - [import_record.importer_source_index as usize] - .as_mut_slice() - [import_record.import_record_index as usize]; - if let Some(out_loader) = record.loader { - break 'brk out_loader; - } - // SAFETY: see `transpiler` note above. - break 'brk Fs::Path::init(path_primary.text) - .loader(unsafe { &(*transpiler).options.loaders }) - .unwrap_or(Loader::File); - }; // For virtual files, use the path text as-is (no relative path computation needed). path_primary.pretty = self.arena().alloc_slice_copy(path_primary.text); let mut tmp_source = bun_ast::Source { @@ -2503,9 +2497,18 @@ pub mod bv2_impl { path.assert_pretty_is_valid(); path.assert_file_path_is_absolute(); + let loader: Loader = import_record.loader.unwrap_or_else(|| { + // SAFETY: see `transpiler` note above. + path.loader(unsafe { &(*transpiler).options.loaders }) + .unwrap_or(Loader::File) + }); + // borrowck: get-then-put (instead of a single get-or-put) so the map // borrow doesn't span `enqueue_parse_task` (which needs `&mut self`). - if let Some(existing) = self.path_to_source_index_map(target).get(path.text) { + if let Some(existing) = self + .path_to_source_index_map(target) + .get_with_loader(path.text, loader) + { out_source_index = Some(Index::init(existing)); } else { path = self @@ -2518,19 +2521,6 @@ pub mod bv2_impl { if let Some(p) = resolve_result.path() { *p = path; } - let loader: Loader = 'brk: { - let record: &ImportRecord = &self.graph.ast.items_import_records() - [import_record.importer_source_index as usize] - .as_slice()[import_record.import_record_index as usize]; - if let Some(out_loader) = record.loader { - break 'brk out_loader; - } - // SAFETY: see `transpiler` note above. - break 'brk path - .loader(unsafe { &(*transpiler).options.loaders }) - .unwrap_or(Loader::File); - // HTML is only allowed at the entry point. - }; let mut tmp_source = bun_ast::Source { path: path_as_static(&path.dupe_alloc(self.arena()).expect("oom")), contents: std::borrow::Cow::Borrowed(&b""[..]), @@ -2545,7 +2535,7 @@ pub mod bv2_impl { ) .expect("oom"); self.path_to_source_index_map(target) - .put(path.text, idx) + .put(path.text, loader, idx) .expect("oom"); out_source_index = Some(Index::init(idx)); @@ -2581,11 +2571,11 @@ pub mod bv2_impl { _ => (Target::Browser, Target::ServerComponentsSsr), }; self.path_to_source_index_map(ta) - .put(&key_text, idx) + .put(&key_text, loader, idx) .expect("oom"); if separate_ssr { self.path_to_source_index_map(tb) - .put(&key_text, idx) + .put(&key_text, loader, idx) .expect("oom"); } } @@ -2635,7 +2625,7 @@ pub mod bv2_impl { // `pretty`. result.path_pair.primary = path; self.path_to_source_index_map(target) - .put(path_slice, source_index.get()) + .put(path_slice, loader, source_index.get()) .expect("oom"); let _ = self.graph.ast.append(JSAst::empty_in(self.graph.heap)); // OOM/capacity: fire-and-forget @@ -2700,10 +2690,13 @@ pub mod bv2_impl { }; path.assert_file_path_is_absolute(); + let loader = path + .loader(&self.transpiler.options.loaders) + .unwrap_or(Loader::File); // borrowck: get-then-put instead of a single get-or-put. if self .path_to_source_index_map(target) - .get(path.text) + .get_with_loader(path.text, loader) .is_some() { return Ok(None); @@ -2711,10 +2704,6 @@ pub mod bv2_impl { self.increment_scan_counter(); let source_index = Index::source(self.graph.input_files.len() as u32); - let loader = path - .loader(&self.transpiler.options.loaders) - .unwrap_or(Loader::File); - // SAFETY: `path_with_pretty_initialized` allocates into `self.graph.heap`, which // outlives the bundle pass; erase the arena lifetime back to the resolver's // `Path<'static>` alias so `path` doesn't keep `self` borrowed. @@ -2743,7 +2732,7 @@ pub mod bv2_impl { *p = path; } self.path_to_source_index_map(target) - .put(path.text, source_index.get()) + .put(path.text, loader, source_index.get()) .expect("oom"); let _ = self.graph.ast.append(JSAst::empty_in(self.graph.heap)); // OOM/capacity: fire-and-forget @@ -3272,7 +3261,7 @@ pub mod bv2_impl { // try this.graph.entry_points.append(arena, Index.runtime); let _ = self.graph.ast.append(JSAst::empty_in(self.graph.heap)); // OOM/capacity: fire-and-forget self.path_to_source_index_map(self.transpiler.options.target) - .put(&b"bun:wrap"[..], Index::RUNTIME.get()) + .put(b"bun:wrap", Loader::Js, Index::RUNTIME.get()) .expect("oom"); // SAFETY: arena (`self.graph.heap`) outlives the bundle pass; coerce the // `&mut ParseTask` to `*mut` immediately so the `&self` borrow from @@ -4746,6 +4735,10 @@ pub mod bv2_impl { } else { path.namespace = result_ns_static; } + let loader = resolve.import_record.loader.unwrap_or_else(|| { + path.loader(&this.transpiler.options.loaders) + .unwrap_or(Loader::File) + }); // SAFETY: `GetOrPutResult` borrows `&mut this` for its whole // lifetime, blocking the `free_list`/`graph` accesses below. @@ -4755,7 +4748,7 @@ pub mod bv2_impl { let (value_ptr, found_existing) = { let existing = this .path_to_source_index_map(resolve.import_record.original_target) - .get_or_put(path.text) + .get_or_put(path.text, loader) .expect("oom"); ( std::ptr::from_mut(existing.value_ptr), @@ -4784,9 +4777,6 @@ pub mod bv2_impl { unsafe { *value_ptr = source_index.get() }; out_source_index = Some(source_index); let _ = this.graph.ast.append(JSAst::empty_in(this.graph.heap)); // OOM/capacity: fire-and-forget - let loader = path - .loader(&this.transpiler.options.loaders) - .unwrap_or(Loader::File); this.graph .input_files @@ -5254,6 +5244,9 @@ pub mod bv2_impl { bake_entry_points: &bake_types::EntryPointList, ) -> Result { self.unique_key = generate_unique_key(); + for map in self.graph.build_graphs.values_mut() { + map.one_module_per_path = true; + } /* arena: help_catch_memory_issues — no-op (mimalloc TLH check) */ @@ -5657,6 +5650,7 @@ pub mod bv2_impl { import_record_index, range: import_record.range, original_target, + loader: import_record.loader, }, ); @@ -5700,6 +5694,7 @@ pub mod bv2_impl { import_record_index: 0, range: bun_ast::Range::NONE, original_target: target, + loader: None, }, ); @@ -6188,15 +6183,17 @@ pub mod bv2_impl { }); import_record.loader = Some(import_record_loader); - if let Some(id) = - self.path_to_source_index_map(target).get(path_primary.text) + if let Some(id) = self + .path_to_source_index_map(target) + .get_with_loader(path_primary.text, import_record_loader) { import_record.source_index = Index::init(id); continue; } - let resolve_entry = - resolve_queue.get_or_put(path_primary.text).expect("oom"); + let resolve_entry = resolve_queue + .get_or_put(path_primary.text, import_record_loader) + .expect("oom"); if resolve_entry.found_existing { // SAFETY: arena-allocated `ParseTask` stored in the queue; arena outlives the pass. import_record.path = @@ -6561,7 +6558,10 @@ pub mod bv2_impl { && target.is_server_side() && self.dev_server.is_none(); - if let Some(id) = self.path_to_source_index_map(target).get(path.text) { + if let Some(id) = self + .path_to_source_index_map(target) + .get_with_loader(path.text, import_record_loader) + { if self.dev_server.is_some() && loader != Loader::Html { import_record.path = self.graph.input_files.items_source()[id as usize].path; @@ -6575,7 +6575,9 @@ pub mod bv2_impl { import_record.kind = ImportKind::HtmlManifest; } - let resolve_entry = resolve_queue.get_or_put(path.text).expect("oom"); + let resolve_entry = resolve_queue + .get_or_put(path.text, import_record_loader) + .expect("oom"); if resolve_entry.found_existing { // SAFETY: arena-allocated `ParseTask` stored in the queue; arena outlives the pass. import_record.path = @@ -6655,7 +6657,6 @@ pub mod bv2_impl { }); let dev_server_is_none = self.dev_server.is_none(); for (key, value) in resolve_queue.iter() { - let value: *mut ParseTask = *value; // SAFETY: ParseTask was arena-allocated in `resolve_import_records`; // the arena outlives this loop. let value = unsafe { &mut *value }; @@ -6675,7 +6676,7 @@ pub mod bv2_impl { } else { self.graph.path_to_source_index_map(target) }; - let existing = map.get_or_put(key).expect("oom"); + let existing = map.get_or_put(key, loader).expect("oom"); ( existing.found_existing, std::ptr::from_mut::(existing.value_ptr), @@ -6837,7 +6838,13 @@ pub mod bv2_impl { // so borrowck sees it as disjoint from `self.graph.input_files` above. let path_to_source_index_map = &mut self.graph.build_graphs[ctx.target]; for (i, record) in import_records.as_mut_slice().iter_mut().enumerate() { - if let Some(source_index) = path_to_source_index_map.get_path(&record.path) { + // Set by `resolve_import_records` on every record it queued a module for. + let Some(loader) = record.loader else { + continue; + }; + if let Some(source_index) = + path_to_source_index_map.get_with_loader(record.path.text, loader) + { if save_import_record_source_index || input_file_loaders[source_index as usize].is_css() { @@ -6846,7 +6853,11 @@ pub mod bv2_impl { if let Some(compare) = get_redirect_id(ctx.redirect_import_record_index) { if compare == i as u32 { - let _ = path_to_source_index_map.put(ctx.source_path, source_index); // OOM-only Result + path_to_source_index_map.redirect( + ctx.source_path, + ctx.source_index.get(), + source_index, + ); } } } @@ -6938,9 +6949,11 @@ pub mod bv2_impl { let _ = self.graph.ast.append(ast_for_html_entrypoint); // OOM/capacity: fire-and-forget import_record.source_index = Index::init(fake_source_index.0); - let _ = self - .path_to_source_index_map(target) - .put(path_text, fake_source_index.0); // OOM-only Result + let _ = self.path_to_source_index_map(target).put( + path_text, + Loader::Html, + fake_source_index.0, + ); // OOM-only Result self.graph .html_imports .server_source_indices @@ -7321,8 +7334,11 @@ pub mod bv2_impl { this.graph .path_to_source_index_map(result_ast_target) - .put(source_path_text, reference_source_index) - .expect("oom"); + .redirect( + source_path_text, + result_source_index as IndexInt, + reference_source_index, + ); this.graph .server_component_boundaries diff --git a/test/bundler/bundler_html.test.ts b/test/bundler/bundler_html.test.ts index 12e5f19398a3..ad4c9cccf0f2 100644 --- a/test/bundler/bundler_html.test.ts +++ b/test/bundler/bundler_html.test.ts @@ -983,6 +983,44 @@ body { }, }); + // The HTML references manifest.json as an asset (file loader) while the script + // imports it as JSON. These are two modules; the script must not end up with + // the asset's output path in place of the parsed object. + itBundled("html/manifest-json-also-imported-as-json", { + outdir: "out/", + files: { + "/index.html": ` + + + + + + + + +`, + "/manifest.json": JSON.stringify({ name: "My App" }), + "/app.js": /* js */ ` + import manifest from "./manifest.json"; + console.log(manifest.name); + `, + }, + entryPoints: ["/index.html"], + onAfterBundle(api) { + const htmlContent = api.readFile("out/index.html"); + + const manifestMatch = htmlContent.match(/href="(?:\.\/|\/)?(manifest-[a-zA-Z0-9]+\.json)"/); + expect(manifestMatch).not.toBeNull(); + expect(api.readFile("out/" + manifestMatch![1])).toBe(JSON.stringify({ name: "My App" })); + + const scriptMatch = htmlContent.match(/src="(?:\.\/|\/)?([^"]+\.js)"/); + expect(scriptMatch).not.toBeNull(); + const js = api.readFile("out/" + scriptMatch![1]); + expect(js).toContain('"My App"'); + expect(js).not.toContain(manifestMatch![1]); + }, + }); + // Test that other non-JS/CSS file types referenced via URL imports are copied as assets itBundled("html/xml-asset", { outdir: "out/", diff --git a/test/bundler/bundler_loader.test.ts b/test/bundler/bundler_loader.test.ts index 72dfc0e353dd..7ab1c333378e 100644 --- a/test/bundler/bundler_loader.test.ts +++ b/test/bundler/bundler_loader.test.ts @@ -103,6 +103,93 @@ describe("bundler", async () => { }, }); + // A module is identified by its path and the loader the import asked for. One + // file imported with two different `type` attributes is two modules; the + // bundler used to merge them into whichever loader happened to get queued + // first. + describe("same file imported with different loaders", () => { + itBundled("bun/loader-same-file-different-loaders-across-modules", { + target: "bun", + files: { + "/entry.ts": /* js */ ` + import { asText } from "./as-text"; + import { asJson } from "./as-json"; + console.log(JSON.stringify({ text: asText.trim(), json: asJson })); + `, + "/as-text.ts": /* js */ ` + import data from "./data.json" with { type: "text" }; + export const asText = data; + `, + "/as-json.ts": /* js */ ` + import data from "./data.json"; + export const asJson = data; + `, + "/data.json": `{"a":1}`, + }, + run: { stdout: '{"text":"{\\"a\\":1}","json":{"a":1}}' }, + }); + + itBundled("bun/loader-same-file-different-loaders-in-one-module", { + target: "bun", + files: { + "/entry.ts": /* js */ ` + import asText from "./data.json" with { type: "text" }; + import asJson from "./data.json"; + console.log(JSON.stringify({ text: asText.trim(), json: asJson })); + `, + "/data.json": `{"a":1}`, + }, + run: { stdout: '{"text":"{\\"a\\":1}","json":{"a":1}}' }, + }); + + itBundled("bun/loader-same-file-different-loaders-dynamic-import", { + target: "bun", + files: { + "/entry.ts": /* js */ ` + import asJson from "./data.json"; + const { default: asText } = await import("./data.json", { with: { type: "text" } }); + console.log(JSON.stringify({ text: asText.trim(), json: asJson })); + `, + "/data.json": `{"a":1}`, + }, + run: { stdout: '{"text":"{\\"a\\":1}","json":{"a":1}}' }, + }); + + itBundled("bun/loader-entry-point-imports-itself-as-text", { + target: "bun", + files: { + "/entry.ts": /* js */ ` + import source from "./entry.ts" with { type: "text" }; + console.log(typeof source, source.includes('with { type: "text" }')); + `, + }, + run: { stdout: "string true" }, + }); + + // An explicit `type` that matches the loader the extension already selects + // is still the same module. + itBundled("bun/loader-same-file-same-loader-is-one-module", { + target: "bun", + files: { + "/entry.ts": /* js */ ` + import { a } from "./a"; + import { b } from "./b"; + console.log(a === b); + `, + "/a.ts": /* js */ ` + import data from "./data.json" with { type: "json" }; + export const a = data; + `, + "/b.ts": /* js */ ` + import data from "./data.json"; + export const b = data; + `, + "/data.json": `{"a":1}`, + }, + run: { stdout: "true" }, + }); + }); + itBundled("bun/loader-json-proto-key-is-own-property", { target: "bun", files: { diff --git a/test/bundler/bundler_plugin.test.ts b/test/bundler/bundler_plugin.test.ts index 0b9ff951d5dd..15e813b7c053 100644 --- a/test/bundler/bundler_plugin.test.ts +++ b/test/bundler/bundler_plugin.test.ts @@ -543,6 +543,58 @@ describe("bundler", () => { }, }; }); + // An onResolve callback that declines every import sends resolution through + // the plugin fallback path. The loader from the import attribute is still part + // of the module's identity there: the same file imported as text and as JSON + // is two modules. + itBundled("plugin/ResolveFallthroughSameFileDifferentLoaders", { + files: { + "index.ts": /* ts */ ` + import { asText } from "./as-text"; + import { asJson } from "./as-json"; + console.log(JSON.stringify({ text: asText.trim(), json: asJson })); + `, + "as-text.ts": /* ts */ ` + import data from "./data.json" with { type: "text" }; + export const asText = data; + `, + "as-json.ts": /* ts */ ` + import data from "./data.json"; + export const asJson = data; + `, + "data.json": `{"a":1}`, + }, + plugins(builder) { + builder.onResolve({ filter: /.*/ }, () => undefined); + }, + run: { stdout: '{"text":"{\\"a\\":1}","json":{"a":1}}' }, + }); + // Same thing when onResolve itself resolves the specifier to a file on disk: + // each importer's attribute still picks that importer's loader. + itBundled("plugin/ResolveSuccessSameFileDifferentLoaders", ({ root }) => { + return { + files: { + "index.ts": /* ts */ ` + import { asText } from "./as-text"; + import { asJson } from "./as-json"; + console.log(JSON.stringify({ text: asText.trim(), json: asJson })); + `, + "as-text.ts": /* ts */ ` + import data from "virtual:data" with { type: "text" }; + export const asText = data; + `, + "as-json.ts": /* ts */ ` + import data from "virtual:data"; + export const asJson = data; + `, + "data.json": `{"a":1}`, + }, + plugins(builder) { + builder.onResolve({ filter: /^virtual:data$/ }, () => ({ path: join(root, "data.json") })); + }, + run: { stdout: '{"text":"{\\"a\\":1}","json":{"a":1}}' }, + }; + }); itBundled("plugin/ManyFiles", ({ root }) => { const FILES = process.platform === "win32" ? 50 : 200; // windows is slower at this const create = (fn: (i: number) => string) => new Array(FILES).fill(0).map((_, i) => fn(i));