From dad1a27f2e918994c68bce9474babf5cedaefcbb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:09:50 +0000 Subject: [PATCH 1/3] dev server: retry a failed import with the import kind of its record When an import does not resolve, the dev server watches its directory and resolves the import again on each change. The retry used ImportKind::Stmt for every record. The resolver removes a ?query or #fragment, and uses the CSS extension order, only for a CSS import kind. So the page did not reload when the missing file of a CSS url() or @import, or of an HTML asset or stylesheet link, with a ?query or #fragment was created. An @import "./other" beside an other.ts resolved in the retry and rebuilt the stylesheet on each change. track_resolution_failure now takes the kind that the bundler resolved with. DirectoryWatchStore stores it in Dep.import_kind, and the retry passes it to the resolver. A bare specifier keeps the ./ prefix, so the retry stays on the relative path. A CSS specifier that starts with ? is stored as written, because ./?v=2 would resolve to the directory. --- src/bundler/bundle_v2.rs | 2 + src/bundler/lib.rs | 2 +- src/runtime/bake/DevServer.rs | 1 + src/runtime/bake/dev_server/mod.rs | 65 +++++++++++++++++++++--------- 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 711a1185e8bc..a29e550e9ade 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2701,6 +2701,7 @@ pub mod bv2_impl { dev.track_resolution_failure( &import_record.source_file, &import_record.specifier, + import_record.kind, target.bake_graph(), self.graph.input_files.items_loader() [import_record.importer_source_index as usize], @@ -6796,6 +6797,7 @@ pub mod bv2_impl { dev.track_resolution_failure( source.path.text, import_record.path.text, + import_record.kind, ctx.target.bake_graph(), // use the source file target not the altered one loader, ) diff --git a/src/bundler/lib.rs b/src/bundler/lib.rs index 71d8a382b6b4..43f19df6e436 100644 --- a/src/bundler/lib.rs +++ b/src/bundler/lib.rs @@ -359,7 +359,7 @@ bun_dispatch::link_interface! { fn finalize_bundle(bv2: *mut bundle_v2::BundleV2<'_>, result: *mut bundle_v2::DevServerOutput<'_>) -> Result<(), crate::Error>; fn handle_parse_task_failure(err: crate::Error, graph: bake_types::Graph, abs_path: &[u8], log: *const bun_ast::Log, bv2: *mut bundle_v2::BundleV2<'_>) -> Result<(), crate::Error>; fn put_or_overwrite_asset(path: *const (), contents: &[u8], content_hash: u64) -> Result<(), crate::Error>; - fn track_resolution_failure(import_source: &[u8], specifier: &[u8], renderer: bake_types::Graph, loader: bun_ast::Loader) -> Result<(), crate::Error>; + fn track_resolution_failure(import_source: &[u8], specifier: &[u8], kind: bun_ast::ImportKind, renderer: bake_types::Graph, loader: bun_ast::Loader) -> Result<(), crate::Error>; fn is_file_cached(abs_path: &[u8], side: bake_types::Graph) -> Option; fn asset_hash(abs_path: &[u8]) -> Option; fn current_bundle_start_data() -> *mut (); diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index c04ca806cee0..9373b705dd2c 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -5052,6 +5052,7 @@ impl DevServer { self.directory_watchers.track_resolution_failure( abs_path, paths::basename(abs_path), + bun_ast::ImportKind::Stmt, bake::Graph::Client, Loader::Html, )?; diff --git a/src/runtime/bake/dev_server/mod.rs b/src/runtime/bake/dev_server/mod.rs index 9159a31bd93d..9d28369f2ba9 100644 --- a/src/runtime/bake/dev_server/mod.rs +++ b/src/runtime/bake/dev_server/mod.rs @@ -442,26 +442,40 @@ impl HotReloadEvent { while let Some(index) = it { // Note: reshaped for borrowck — re-index per iteration instead of // holding `dep` ref across resolver call + appendFile + freeDependencyIndex. - let (source_file_path, specifier, next) = { + let (source_file_path, specifier, import_kind, next) = { let dep = &dev.directory_watchers.dependencies[index as usize]; - (dep.source_file_path, &raw const *dep.specifier, dep.next) + ( + dep.source_file_path, + &raw const *dep.specifier, + dep.import_kind, + dep.next, + ) }; it = next; - // `specifier` points into the dep's owned `Box<[u8]>`, which is - // not mutated until after `resolve` returns. - // SAFETY: see `Dep` doc — neither slice is mutated mid-resolve. + // SAFETY: `specifier` points into the dep's owned `Box<[u8]>`. + // Nothing mutates or frees it before `free_dependency_index` below. + let specifier: &[u8] = unsafe { &*specifier }; + // SAFETY: see `Dep` doc. Neither slice is mutated mid-resolve. let resolved = unsafe { dev.server_transpiler.assume_init_mut() } .resolver .resolve( bun_paths::resolve_path::dirname::( source_file_path.slice(), ), - unsafe { &*specifier }, - bun_ast::ImportKind::Stmt, + specifier, + import_kind, ) .is_ok(); + bun_core::scoped_log!( + DevServer, + "DirectoryWatchStore retry({}, {}) = {}", + bun_core::fmt::quote(specifier), + bstr::BStr::new(import_kind.label()), + resolved, + ); + if resolved { // this resolution result is not preserved as passing it // into BundleV2 is too complicated. the resolution is @@ -1095,6 +1109,9 @@ pub(crate) mod directory_watch_store { pub(crate) source_file_path: bun_ptr::RawSlice, /// The specifier that failed. Allocated memory. pub(crate) specifier: Box<[u8]>, + /// The import kind the bundler resolved `specifier` with. The retry + /// uses it too: the resolver has rules that depend on the kind. + pub(crate) import_kind: bun_ast::ImportKind, } impl Default for Dep { fn default() -> Self { @@ -1102,6 +1119,7 @@ pub(crate) mod directory_watch_store { next: None, source_file_path: bun_ptr::RawSlice::EMPTY, specifier: Box::default(), + import_kind: bun_ast::ImportKind::Stmt, } } } @@ -1140,10 +1158,10 @@ bun_bundler::link_impl_DevServerHandle! { let blob = crate::webcore::blob::Any::from_owned_slice(contents.to_vec()); (*this).put_or_overwrite_asset(path, blob, content_hash).map_err(Into::into) }, - track_resolution_failure(import_source, specifier, renderer, loader) => { + track_resolution_failure(import_source, specifier, kind, renderer, loader) => { (*this) .directory_watchers - .track_resolution_failure(import_source, specifier, renderer, loader) + .track_resolution_failure(import_source, specifier, kind, renderer, loader) .map_err(Into::into) }, is_file_cached(abs_path, side) => { @@ -1219,6 +1237,7 @@ impl DirectoryWatchStore { &mut self, import_source: &[u8], specifier: &[u8], + kind: bun_ast::ImportKind, renderer: Graph, loader: bun_ast::Loader, ) -> Result<(), bun_alloc::AllocError> { @@ -1283,7 +1302,7 @@ impl DirectoryWatchStore { } }; - match self.insert(dir, owned_file_path, specifier) { + match self.insert(dir, owned_file_path, specifier, kind) { Ok(()) => Ok(()), Err(DirectoryWatchInsertError::Ignore) => Ok(()), // ignoring watch errors. Err(DirectoryWatchInsertError::OutOfMemory) => Err(bun_alloc::AllocError), @@ -1297,6 +1316,7 @@ impl DirectoryWatchStore { dir_name_to_watch: &[u8], file_path: bun_ptr::RawSlice, specifier: &[u8], + kind: bun_ast::ImportKind, ) -> Result<(), DirectoryWatchInsertError> { debug_assert!(!specifier.is_empty()); // TODO: watch the parent dir too. @@ -1324,15 +1344,20 @@ impl DirectoryWatchStore { let gop_index = gop.index; let found_existing = gop.found_existing; - let specifier_cloned: Box<[u8]> = - if specifier[0] == b'.' || bun_paths::is_absolute(specifier) { - Box::<[u8]>::from(specifier) - } else { - let mut v = Vec::with_capacity(2 + specifier.len()); - v.extend_from_slice(b"./"); - v.extend_from_slice(specifier); - v.into_boxed_slice() - }; + // The `./` prefix keeps the retry on the relative path, which is all that a + // directory watch can see. For a CSS kind the resolver removes a `?query`, + // but not one that starts the specifier: `./?v=2` would resolve to the directory. + let specifier_cloned: Box<[u8]> = if specifier[0] == b'.' + || bun_paths::is_absolute(specifier) + || (kind.is_from_css() && specifier[0] == b'?') + { + Box::<[u8]>::from(specifier) + } else { + let mut v = Vec::with_capacity(2 + specifier.len()); + v.extend_from_slice(b"./"); + v.extend_from_slice(specifier); + v.into_boxed_slice() + }; // errdefer free(specifier_cloned) — handled by Drop on `?` paths. if found_existing { @@ -1341,6 +1366,7 @@ impl DirectoryWatchStore { next: prev_first, source_file_path: file_path, specifier: specifier_cloned, + import_kind: kind, }); self.watches.values_mut()[gop_index].first_dep = dep; return Ok(()); @@ -1434,6 +1460,7 @@ impl DirectoryWatchStore { next: None, source_file_path: file_path, specifier: specifier_cloned, + import_kind: kind, }); self.watches.values_mut()[gop_index] = directory_watch_store::Entry { dir: fd, From 4d4ca9d1190c7e2194497651673900f257effa79 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:09:50 +0000 Subject: [PATCH 2/3] test: the dev server reloads when the file of a css or html reference is created css: url() with a query, a fragment, or both, bare and after a plugin declines it, @import with a suffix, with a condition, and without an extension beside a file that a JS import finds, and composes with a query. One test has url(?v=2) and url(bun) beside a reference that recovers: an unrelated change must send nothing. One test uses a framework route. html: img src with a query or a fragment, project-relative, a bare stylesheet href, and one page with an asset and a script. --- test/bake/dev/css.test.ts | 190 ++++++++++++++++++++++++++++++++++++- test/bake/dev/html.test.ts | 94 ++++++++++++++++++ 2 files changed, 283 insertions(+), 1 deletion(-) diff --git a/test/bake/dev/css.test.ts b/test/bake/dev/css.test.ts index 50fbc625e0d1..4d53dd6596c1 100644 --- a/test/bake/dev/css.test.ts +++ b/test/bake/dev/css.test.ts @@ -1,7 +1,7 @@ // CSS tests concern bundling bugs with CSS files import { expect } from "bun:test"; import assert from "node:assert"; -import { devTest, emptyHtmlFile, imageFixtures } from "../bake-harness"; +import { devTest, emptyHtmlFile, imageFixtures, minimalFramework } from "../bake-harness"; devTest("css file with syntax error does not kill old styles", { files: { @@ -646,6 +646,194 @@ devTest("css import before create", { await dev.fetch("/").expect.toContain("HELLO"); }, }); +// The resolver has rules that only apply to a CSS import kind: it removes a +// ?query or #fragment, and it tries "./other" only as "./other.css". The retry +// for a missing file uses the import kind that the bundler used. +const retriedWithCssKind: { + name: string; + /** The stylesheet that index.html links. */ + stylesheet?: string; + css: string; + error: string; + create: [file: string, contents: string]; + /** A file that a JS import of the same specifier resolves to. */ + decoy?: [file: string, contents: string]; + plugin?: boolean; + /** Text in the stylesheet that the server sends. The test client drops `@layer` and hashes class names. */ + served?: string[]; +}[] = [ + { + name: "url with query", + css: `body { background-image: url(./bun.png?v=2); }`, + error: 'styles.css:1:26: error: Could not resolve: "./bun.png?v=2"', + create: ["bun.png", imageFixtures.bun], + }, + { + name: "url with fragment", + css: `body { background-image: url(./bun.png#icon); }`, + error: 'styles.css:1:26: error: Could not resolve: "./bun.png#icon"', + create: ["bun.png", imageFixtures.bun], + }, + { + name: "bare url with empty query and fragment", + css: `body { background-image: url(bun.png?#iefix); }`, + error: 'styles.css:1:26: error: Could not resolve: "bun.png?#iefix". Maybe you need to "bun install"?', + create: ["bun.png", imageFixtures.bun], + }, + { + name: "url with query after a plugin declines it", + css: `body { background-image: url(./bun.png?v=2); }`, + error: 'styles.css:1:26: error: Could not resolve: "./bun.png?v=2"', + create: ["bun.png", imageFixtures.bun], + plugin: true, + }, + { + name: "@import with query and fragment", + css: `@import "./other.css?v=3#c";`, + error: 'styles.css:1:1: error: Could not resolve: "./other.css?v=3#c"', + create: ["other.css", `body { color: red; }`], + }, + { + name: "@import with fragment", + css: `@import "./other.css#frag";`, + error: 'styles.css:1:1: error: Could not resolve: "./other.css#frag"', + create: ["other.css", `body { color: red; }`], + }, + { + name: "conditional @import with query", + css: `@import "./other.css?v=2" layer(base);`, + error: 'styles.css:1:1: error: Could not resolve: "./other.css?v=2"', + create: ["other.css", `body { color: red; }`], + served: ["@layer base", "color: red"], + }, + { + name: "@import without extension", + css: `@import "./other";`, + error: 'styles.css:1:1: error: Could not resolve: "./other"', + create: ["other.css", `body { color: red; }`], + decoy: ["other.ts", `export {};`], + }, + { + name: "composes with query", + stylesheet: "styles.module.css", + css: `.a { composes: b from "./other.module.css?v=2"; }`, + error: 'styles.module.css:1:22: error: Could not resolve: "./other.module.css?v=2"', + create: ["other.module.css", `.b { color: red; }`], + served: ["color: red"], + }, + { + name: "bare composes with query", + stylesheet: "styles.module.css", + css: `.a { composes: b from "other.module.css?v=2"; }`, + error: 'styles.module.css:1:22: error: Could not resolve: "other.module.css?v=2". Maybe you need to "bun install"?', + create: ["other.module.css", `.b { color: red; }`], + served: ["color: red"], + }, +]; +for (const { name, stylesheet = "styles.css", css, error, create, decoy, plugin, served } of retriedWithCssKind) { + devTest(`css ${name} before create`, { + files: { + "index.html": emptyHtmlFile({ + styles: [stylesheet], + body: ` +
HELLO
+ `, + }), + [stylesheet]: css, + ...(decoy && { [decoy[0]]: decoy[1] }), + ...(plugin && { + "bunfig.toml": ` + [serve.static] + plugins = ["./decline-plugin.ts"] + `, + "decline-plugin.ts": ` + export default { + name: "decline-plugin", + setup(build) { + build.onResolve({ filter: /bun\\.png/ }, () => undefined); + }, + }; + `, + }), + }, + async test(dev) { + await using c = await dev.client("/", { errors: [error] }); + // A change in the watched directory that does not create the file sends nothing. + await c.expectNoWebSocketActivity(async () => { + await dev.write("unrelated.txt", "x", { errors: null }); + await dev.delete("unrelated.txt", { errors: null }); + }); + await c.expectReload(async () => { + await dev.write(create[0], create[1]); + }); + if (served) { + const href = (await (await dev.fetch("/")).text()).match(/href="(\/_bun\/asset\/[^"]+\.css)"/)?.[1]; + assert(href); + const text = await (await dev.fetch(href)).text(); + for (const expected of served) expect(text).toContain(expected); + } else if (create[0] === "bun.png") { + const backgroundImage = await c.style("body").backgroundImage; + assert(backgroundImage); + // The printed url() keeps a fragment. The page does not send it when it loads the image. + await dev.fetch(extractCssUrl(backgroundImage).replace(/#.*$/, "")).expectFile(imageFixtures.bun); + } else { + await c.style("body").color.expect.toBe("red"); + } + await dev.fetch("/").expect.toContain("HELLO"); + }, + }); +} +devTest("css retry does not rebuild on an unrelated change", { + files: { + "index.html": emptyHtmlFile({ + styles: ["query-only.css", "builtin.css", "styles.css"], + body: ` +
HELLO
+ `, + }), + // "./" resolves to this file, so "./?v=2" without its query would resolve. + "index.css": `/* index */`, + "query-only.css": `body { background-image: url(?v=2); }`, + // The resolver of the server resolves "bun". The retry must ask for "./bun". + "builtin.css": `div { background-image: url(bun); }`, + "styles.css": `p { background-image: url(./bun.png?v=2); }`, + }, + async test(dev) { + const queryOnly = 'query-only.css:1:26: error: Could not resolve: "?v=2". Maybe you need to "bun install"?'; + const builtin = 'builtin.css:1:25: error: Browser build cannot url() Bun builtin: "bun"'; + const missing = 'styles.css:1:23: error: Could not resolve: "./bun.png?v=2"'; + await using c = await dev.client("/", { errors: [builtin, queryOnly, missing] }); + await c.expectNoWebSocketActivity(async () => { + await dev.write("unrelated.txt", "x", { errors: null }); + await dev.delete("unrelated.txt", { errors: null }); + }); + await dev.write("bun.png", imageFixtures.bun, { errors: [builtin, queryOnly] }); + }, +}); +devTest("framework route stylesheet url with query before create", { + framework: minimalFramework, + files: { + "routes/index.ts": ` + import "../one.css"; + export default (req, meta) => Response.json(meta.styles); + `, + "one.css": `.one { color: red; }`, + }, + async test(dev) { + const before: string[] = await dev.fetch("/").json(); + expect(before).toHaveLength(1); + expect((await dev.fetch(before[0])).status).toBe(200); + + await dev.write("one.css", `.one { background-image: url(./bun.png?v=2); }`, { errors: null }); + await dev.write("bun.png", imageFixtures.bun, { errors: null }); + + const after: string[] = await dev.fetch("/").json(); + expect(after).toHaveLength(1); + const stylesheet = await dev.fetch(after[0]); + expect(await stylesheet.text()).toContain("background-image"); + expect(stylesheet.status).toBe(200); + }, +}); devTest("css import before create project relative", { files: { "html/index.html": emptyHtmlFile({ diff --git a/test/bake/dev/html.test.ts b/test/bake/dev/html.test.ts index 978cfedcc886..b394445006c7 100644 --- a/test/bake/dev/html.test.ts +++ b/test/bake/dev/html.test.ts @@ -154,6 +154,100 @@ devTest("import then create", { await c.expectMessage("data"); }, }); +// An asset attribute and a stylesheet link resolve as CSS import kinds, so the +// resolver removes a ?query or #fragment. The retry for a missing file uses +// the import kind that the bundler used. +const retriedWithCssKind: { + name: string; + tag: string; + error: string; + dir?: string; + file: string; + contents: string; +}[] = [ + { + name: "img src with query", + tag: ``, + error: 'index.html: error: Could not resolve: "./image.png?v=2"', + file: "image.png", + contents: "FIRST", + }, + { + name: "img src with fragment", + tag: ``, + error: 'index.html: error: Could not resolve: "./image.png#frag"', + file: "image.png", + contents: "FIRST", + }, + { + name: "project-relative img src with query", + tag: ``, + error: 'index.html: error: Could not resolve: "/assets/image.png?v=2"', + dir: "assets", + file: "assets/image.png", + contents: "FIRST", + }, + { + name: "bare stylesheet href with query", + tag: ``, + error: 'index.html: error: Could not resolve: "styles.css?v=2". Maybe you need to "bun install"?', + file: "styles.css", + contents: "body { color: red; }", + }, +]; +for (const { name, tag, error, dir, file, contents } of retriedWithCssKind) { + devTest(`html ${name} before create`, { + files: { + "index.html": ` + + ${tag} + + `, + }, + async test(dev) { + if (dir) dev.mkdir(dir); // (See DevServer.zig "BUN-10968") + await using c = await dev.client("/", { errors: [error] }); + // A change in the watched directory that does not create the file sends nothing. + await c.expectNoWebSocketActivity(async () => { + await dev.write((dir ? dir + "/" : "") + "unrelated.txt", "x", { errors: null }); + await dev.delete((dir ? dir + "/" : "") + "unrelated.txt", { errors: null }); + }); + await c.expectReload(async () => { + await dev.write(file, contents); + }); + if (file.endsWith(".png")) { + const url: string = await c.js`document.querySelector("img").src`; + await dev.fetch(url).expect.toBe(contents); + } else { + await c.style("body").color.expect.toBe("red"); + } + }, + }); +} +// One html file has records of more than one import kind. The asset needs the +// CSS rules, and the script needs the JS extensions. +devTest("html asset and script before create", { + files: { + "index.html": ` + + + + + `, + }, + async test(dev) { + const image = 'index.html: error: Could not resolve: "./image.png?v=2"'; + const script = 'index.html: error: Could not resolve: "./app"'; + await using c = await dev.client("/", { errors: [image, script] }); + await dev.write("image.png", "FIRST", { errors: [script] }); + await c.expectReload(async () => { + await dev.write("app.ts", `console.log("app ran");`); + }); + await c.expectMessage("app ran"); + const url: string = await c.js`document.querySelector("img").src`; + await dev.fetch(url).expect.toBe("FIRST"); + }, +}); devTest("external links", { files: { "index.html": ` From 1792fc10f27455a077afc5a72d005b88250f9ff2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:31:02 +0000 Subject: [PATCH 3/3] dev server: do not track a css specifier that starts with a query With the "./" prefix the resolver removes the query of "?v=2" and resolves the directory, so the retry rebuilt the stylesheet on each change. track_resolution_failure now returns early for such a specifier, and insert is the same as before. The retry takes the specifier reference inside a block that ends before the dep is freed. Each unsafe block has the comment for its own invariant. --- src/runtime/bake/dev_server/mod.rs | 75 +++++++++++++++--------------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/src/runtime/bake/dev_server/mod.rs b/src/runtime/bake/dev_server/mod.rs index 9d28369f2ba9..1bf63928e8e7 100644 --- a/src/runtime/bake/dev_server/mod.rs +++ b/src/runtime/bake/dev_server/mod.rs @@ -453,28 +453,29 @@ impl HotReloadEvent { }; it = next; - // SAFETY: `specifier` points into the dep's owned `Box<[u8]>`. - // Nothing mutates or frees it before `free_dependency_index` below. - let specifier: &[u8] = unsafe { &*specifier }; - // SAFETY: see `Dep` doc. Neither slice is mutated mid-resolve. - let resolved = unsafe { dev.server_transpiler.assume_init_mut() } - .resolver - .resolve( - bun_paths::resolve_path::dirname::( - source_file_path.slice(), - ), - specifier, - import_kind, - ) - .is_ok(); - - bun_core::scoped_log!( - DevServer, - "DirectoryWatchStore retry({}, {}) = {}", - bun_core::fmt::quote(specifier), - bstr::BStr::new(import_kind.label()), - resolved, - ); + let resolved = { + // SAFETY: the dep owns this slice, and nothing frees it in this block. + let specifier: &[u8] = unsafe { &*specifier }; + // SAFETY: server_transpiler is initialized before any event fires. + let resolved = unsafe { dev.server_transpiler.assume_init_mut() } + .resolver + .resolve( + bun_paths::resolve_path::dirname::( + source_file_path.slice(), + ), + specifier, + import_kind, + ) + .is_ok(); + bun_core::scoped_log!( + DevServer, + "DirectoryWatchStore retry({}, {}) = {}", + bun_core::fmt::quote(specifier), + bstr::BStr::new(import_kind.label()), + resolved, + ); + resolved + }; if resolved { // this resolution result is not preserved as passing it @@ -1109,8 +1110,7 @@ pub(crate) mod directory_watch_store { pub(crate) source_file_path: bun_ptr::RawSlice, /// The specifier that failed. Allocated memory. pub(crate) specifier: Box<[u8]>, - /// The import kind the bundler resolved `specifier` with. The retry - /// uses it too: the resolver has rules that depend on the kind. + /// The import kind the bundler resolved `specifier` with. The retry resolves with it too. pub(crate) import_kind: bun_ast::ImportKind, } impl Default for Dep { @@ -1249,6 +1249,10 @@ impl DirectoryWatchStore { if !bun_paths::is_absolute(import_source) { return Ok(()); } + // With the "./" prefix the resolver removes the query of "?v=2" and resolves the directory. + if kind.is_from_css() && specifier[0] == b'?' { + return Ok(()); + } match loader { Loader::Tsx | Loader::Ts | Loader::Jsx | Loader::Js => { @@ -1344,20 +1348,15 @@ impl DirectoryWatchStore { let gop_index = gop.index; let found_existing = gop.found_existing; - // The `./` prefix keeps the retry on the relative path, which is all that a - // directory watch can see. For a CSS kind the resolver removes a `?query`, - // but not one that starts the specifier: `./?v=2` would resolve to the directory. - let specifier_cloned: Box<[u8]> = if specifier[0] == b'.' - || bun_paths::is_absolute(specifier) - || (kind.is_from_css() && specifier[0] == b'?') - { - Box::<[u8]>::from(specifier) - } else { - let mut v = Vec::with_capacity(2 + specifier.len()); - v.extend_from_slice(b"./"); - v.extend_from_slice(specifier); - v.into_boxed_slice() - }; + let specifier_cloned: Box<[u8]> = + if specifier[0] == b'.' || bun_paths::is_absolute(specifier) { + Box::<[u8]>::from(specifier) + } else { + let mut v = Vec::with_capacity(2 + specifier.len()); + v.extend_from_slice(b"./"); + v.extend_from_slice(specifier); + v.into_boxed_slice() + }; // errdefer free(specifier_cloned) — handled by Drop on `?` paths. if found_existing {