From e0a28138cefe132943d6d4f354a1a574c065a76b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:44:46 +0000 Subject: [PATCH 01/12] dev server: give an HTML import record the graph path in a combined rebuild When an html file and a file it references are entry points of one rebuild, resolve_import_records finds the target in path_to_source_index_map. For an html importer that branch set only source_index and left the record path as HTMLScanner wrote it. generate_javascript_code_for_html_file prints path.pretty as the module id, so the next page load failed with 'Failed to load bundled module'. process_edge_attachment looks a failed target up by path.text, so the html -> script edge was freed when the script failed in that rebuild, and the repaired script never came back to the page. The branch now stores the input file's path for every dev server importer. html importers keep source_index. --- src/bundler/bundle_v2.rs | 7 ++- test/bake/dev/html.test.ts | 119 +++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 832bd7b9d169..7f517545b152 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -6797,9 +6797,14 @@ pub mod bv2_impl { && self.dev_server.is_none(); if let Some(id) = self.path_to_source_index_map(target).get(path.text) { - if self.dev_server.is_some() && loader != Loader::Html { + if self.dev_server.is_some() { + // The dev server finds a file by `path.text` and prints `path.pretty` as + // the module id. An html record still holds the specifier as written. import_record.path = self.graph.input_files.items_source()[id as usize].path; + if loader == Loader::Html { + import_record.source_index = Index::init(id); + } } else { import_record.source_index = Index::init(id); } diff --git a/test/bake/dev/html.test.ts b/test/bake/dev/html.test.ts index 978cfedcc886..62833e8c7448 100644 --- a/test/bake/dev/html.test.ts +++ b/test/bake/dev/html.test.ts @@ -409,3 +409,122 @@ devTest("editing a file imported from outside the project root hot-reloads", { await c.expectMessage("three"); }, }); +// One watch batch that holds an html file and a file it references makes both +// entry points of one rebuild. https://github.com/oven-sh/bun/issues/20877 +devTest("html file and its scripts rebuilt together load by their module ids", { + files: { + "index.html": emptyHtmlFile({ + scripts: ["script.ts", "./other.ts"], + body: "

Hello

", + }), + "script.ts": ` + console.log("v1"); + `, + "other.ts": ` + console.log("other v1"); + `, + }, + async test(dev) { + await dev.fetch("/").expect.toInclude("

Hello

"); + { + await using _batch = await dev.batchChanges(); + await dev.patch("index.html", { find: "Hello", replace: "World" }); + await dev.write("script.ts", `console.log("v2");`); + await dev.write("other.ts", `console.log("other v2");`); + } + // previously: Failed to load bundled module '/script.ts' + await using c = await dev.client("/"); + await c.expectMessage("v2", "other v2"); + await dev.fetch("/").expect.toInclude("

World

"); + }, +}); +devTest("script that fails in the same rebuild as its html file comes back once fixed", { + files: { + "index.html": emptyHtmlFile({ + scripts: ["./first.ts"], + body: "

Hello

", + }), + "first.ts": ` + import { value } from "./dep.ts"; + console.log("first " + value); + `, + "dep.ts": ` + export const value = "dep"; + `, + "second.ts": ` + console.log("second"); + `, + }, + async test(dev) { + await dev.fetch("/").expect.toInclude("

Hello

"); + { + await using _batch = await dev.batchChanges(); + await dev.write( + "index.html", + emptyHtmlFile({ + scripts: ["./first.ts", "./second.ts"], + body: "

World

", + }), + ); + await dev.write( + "first.ts", + ` + import { value } from "./dep.ts"; + import "./missing.ts"; + console.log("first " + value); + `, + ); + } + await dev.write( + "first.ts", + ` + import { value } from "./dep.ts"; + console.log("first fixed " + value); + `, + ); + // previously: the script bundle of the page held second.ts only + await using c = await dev.client("/"); + await c.expectMessage("first fixed dep", "second"); + await dev.fetch("/").expect.toInclude("

World

"); + }, +}); +devTest("stylesheet that fails in the same rebuild as its html file comes back once fixed", { + files: { + "index.html": emptyHtmlFile({ + styles: ["./styles.css"], + body: "

Hello

", + }), + "styles.css": ` + body { + color: red; + } + `, + }, + async test(dev) { + await dev.fetch("/").expect.toInclude("

Hello

"); + { + await using _batch = await dev.batchChanges(); + await dev.patch("index.html", { find: "Hello", replace: "World" }); + await dev.write( + "styles.css", + ` + body { + color: blue; + }} + `, + ); + } + await dev.write( + "styles.css", + ` + body { + color: blue; + } + `, + ); + // previously: the page had no stylesheet + await using c = await dev.client("/"); + await c.style("body").color.expect.toBe("#00f"); + await dev.fetch("/").expect.toInclude("

World

"); + }, +}); From 4f0ccf36771efd73ef29d3a02a86c374630824a2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:16:40 +0000 Subject: [PATCH 02/12] test: assert that the combined rebuild failed before the repair The two failure tests now wait for the build error in the dev server output. A page request would show the failure too, but on a release build it bundles the whole route again, which restores the edge and hides the bug. --- test/bake/dev/html.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/bake/dev/html.test.ts b/test/bake/dev/html.test.ts index 62833e8c7448..244b1791507b 100644 --- a/test/bake/dev/html.test.ts +++ b/test/bake/dev/html.test.ts @@ -475,6 +475,8 @@ devTest("script that fails in the same rebuild as its html file comes back once `, ); } + // Not a page request: on a release build that bundles the whole route again. + await dev.output.waitForLine(/Could not resolve: "\.\/missing\.ts"/); await dev.write( "first.ts", ` @@ -514,6 +516,8 @@ devTest("stylesheet that fails in the same rebuild as its html file comes back o `, ); } + // Not a page request: on a release build that bundles the whole route again. + await dev.output.waitForLine(/Unexpected end of input/); await dev.write( "styles.css", ` From 70cfd602208a51c8b8c9fbb3c28f8a1bf61970d1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:19:01 +0000 Subject: [PATCH 03/12] bundle_v2: shorten the comment on the dev server map-hit branch --- src/bundler/bundle_v2.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 7f517545b152..fb1484d20c15 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -6798,10 +6798,9 @@ pub mod bv2_impl { if let Some(id) = self.path_to_source_index_map(target).get(path.text) { if self.dev_server.is_some() { - // The dev server finds a file by `path.text` and prints `path.pretty` as - // the module id. An html record still holds the specifier as written. import_record.path = self.graph.input_files.items_source()[id as usize].path; + // HTML chunk printing reads the loader and the asset key through it. if loader == Loader::Html { import_record.source_index = Index::init(id); } From c354e7bb66e842defd69526b6f2e1cd6ec1eef5d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:19:25 +0000 Subject: [PATCH 04/12] dev server: give each resolved HTML import record the path of its file An HTML import record can keep the specifier as written while its source_index names the resolved file. That happens when the target is already in the bundle (the map-hit branch of resolve_import_records) and when an onResolve plugin matches the import (run_resolver and on_resolve). The module id of a script is printed from path.pretty, and a stylesheet that failed to build is looked up by path.text. So the page failed to load the script, or lost the stylesheet after a repair. finish_from_bake_dev_server now gives every resolved record of an HTML file the path of its input file, before anything reads it. JS files get the same in convertStmtsForChunkForDevServer. The map-hit branch goes back to what main has, because the new step covers it. Two new tests cover a script and a stylesheet that an onResolve plugin resolves. --- src/bundler/bundle_v2.rs | 12 ++++--- test/bake/dev/html.test.ts | 66 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index a3b311f11e6f..6e3ef3c010f6 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -6034,6 +6034,12 @@ pub mod bv2_impl { Index::init(u32::try_from(index).expect("int cast")), (), )?; + // A plugin or an earlier import can resolve a record and leave its path as written. + for record in import_records.as_mut_slice() { + if !record.path.is_disabled && record.source_index.is_valid() { + record.path = sources[record.source_index.get() as usize].path; + } + } } else { js_files.push(Index::init(u32::try_from(index).expect("int cast"))); @@ -7303,13 +7309,9 @@ pub mod bv2_impl { && self.dev_server.is_none(); if let Some(id) = self.path_to_source_index_map(target).get(path.text) { - if self.dev_server.is_some() { + if self.dev_server.is_some() && loader != Loader::Html { import_record.path = self.graph.input_files.items_source()[id as usize].path; - // HTML chunk printing reads the loader and the asset key through it. - if loader == Loader::Html { - import_record.source_index = Index::init(id); - } } else { import_record.source_index = Index::init(id); } diff --git a/test/bake/dev/html.test.ts b/test/bake/dev/html.test.ts index 1b4561aa08c3..ee4f5d8d2530 100644 --- a/test/bake/dev/html.test.ts +++ b/test/bake/dev/html.test.ts @@ -639,3 +639,69 @@ devTest("stylesheet that fails in the same rebuild as its html file comes back o await dev.fetch("/").expect.toInclude("

World

"); }, }); +devTest("html script that an onResolve plugin resolves loads by its module id", { + files: { + "bunfig.toml": ` + [serve.static] + plugins = ["./plugin.ts"] + `, + "plugin.ts": ` + import path from "node:path"; + export default { + name: "resolve-scripts", + setup(build) { + build.onResolve({ filter: /declined\\.ts$/ }, () => undefined); + build.onResolve({ filter: /answered\\.ts$/ }, args => ({ + path: path.resolve(path.dirname(args.importer), args.path), + })); + }, + }; + `, + "index.html": emptyHtmlFile({ + scripts: ["./declined.ts", "./answered.ts"], + }), + "declined.ts": ` + console.log("declined"); + `, + "answered.ts": ` + console.log("answered"); + `, + }, + async test(dev) { + // previously: Failed to load bundled module './declined.ts' + await using c = await dev.client("/"); + await c.expectMessage("declined", "answered"); + }, +}); +devTest("stylesheet that an onResolve plugin resolves comes back after a failed first build", { + files: { + "bunfig.toml": ` + [serve.static] + plugins = ["./plugin.ts"] + `, + "plugin.ts": ` + export default { + name: "resolve-styles", + setup(build) { + build.onResolve({ filter: /styles\\.css$/ }, () => undefined); + }, + }; + `, + "index.html": emptyHtmlFile({ + styles: ["./styles.css"], + body: "

Hello

", + }), + "styles.css": ` + body { + color: red; + }} + `, + }, + async test(dev) { + expect((await dev.fetch("/")).status).toBe(500); + await dev.write("styles.css", `body { color: red; }`); + // previously: the page had no stylesheet + await using c = await dev.client("/"); + await c.style("body").color.expect.toBe("red"); + }, +}); From 60b81e9a1b32c7688927228c413a80603a6af5eb Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:20:33 +0000 Subject: [PATCH 05/12] [autofix.ci] apply automated fixes --- src/bundler/bundle_v2.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 6e3ef3c010f6..69ff0c2f2920 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -6037,7 +6037,8 @@ pub mod bv2_impl { // A plugin or an earlier import can resolve a record and leave its path as written. for record in import_records.as_mut_slice() { if !record.path.is_disabled && record.source_index.is_valid() { - record.path = sources[record.source_index.get() as usize].path; + record.path = + sources[record.source_index.get() as usize].path; } } } else { From f9066f4cbb8b1bf0f916306ba71079831669fec5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:51:12 +0000 Subject: [PATCH 06/12] dev server: give each resolved import record the path of its file The loop in `finish_from_bake_dev_server` that copies the path of the source onto a resolved import record now runs for every file that parsed, not only for an HTML file. It copies the path only when the record names a script or a stylesheet. - A script or a stylesheet that reaches a stylesheet through an `onResolve` plugin now has the path of the file on its record. The stylesheet comes back after a failed build, and an edit to it reaches the page. - A record that names any other kind of file keeps its path, because the page prints that path. A link to an asset keeps its `/_bun/asset/` URL when a stylesheet of the same rebuild uses the same file. --- src/bundler/bundle_v2.rs | 20 ++- .../bake/dev_server/incremental_graph.rs | 2 +- test/bake/dev/css.test.ts | 86 +++++++++++ test/bake/dev/html.test.ts | 139 +++++++++++++++++- 4 files changed, 235 insertions(+), 12 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 69ff0c2f2920..9e80a1305c91 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -5988,6 +5988,19 @@ pub mod bv2_impl { // // Actual empty files will contain a part exporting an empty object. if part_list.len() != 0 { + // Resolution can set `source_index` and leave the path as written (a plugin, or a + // target already in the bundle): https://github.com/oven-sh/bun/issues/20877 + // The path of any other kind of target is printed, for example an asset URL. + for record in import_records.as_mut_slice() { + if !record.source_index.is_valid() { + continue; + } + let resolved = record.source_index.get() as usize; + let loader = loaders[resolved]; + if loader.is_javascript_like() || loader.is_css() { + record.path = sources[resolved].path; + } + } if maybe_css.is_some() { // CSS has restrictions on what files can be imported. // This means the file can become an error after @@ -6034,13 +6047,6 @@ pub mod bv2_impl { Index::init(u32::try_from(index).expect("int cast")), (), )?; - // A plugin or an earlier import can resolve a record and leave its path as written. - for record in import_records.as_mut_slice() { - if !record.path.is_disabled && record.source_index.is_valid() { - record.path = - sources[record.source_index.get() as usize].path; - } - } } else { js_files.push(Index::init(u32::try_from(index).expect("int cast"))); diff --git a/src/runtime/bake/dev_server/incremental_graph.rs b/src/runtime/bake/dev_server/incremental_graph.rs index b9f7d1bcee5b..3eb39b9d070b 100644 --- a/src/runtime/bake/dev_server/incremental_graph.rs +++ b/src/runtime/bake/dev_server/incremental_graph.rs @@ -914,7 +914,7 @@ impl IncrementalGraph { // takes `&mut ctx`. let (flags, src, key) = { let ir = &ctx.import_records[index.get() as usize].as_slice()[i]; - // If an `onResolve` plugin resolved the import, `ir.path` is still the specifier. + // The path of a record that names neither a script nor a stylesheet can still be the specifier. let path = if ir.source_index.is_valid() { &ctx.sources[ir.source_index.get() as usize].path } else { diff --git a/test/bake/dev/css.test.ts b/test/bake/dev/css.test.ts index 9ce744ae8636..6e80370742f2 100644 --- a/test/bake/dev/css.test.ts +++ b/test/bake/dev/css.test.ts @@ -810,6 +810,92 @@ devTest("css import before create project relative", { await dev.fetch("/").expect.toContain("HELLO"); }, }); +// Two `[serve.static]` plugins: one that declines an import, and one that answers with the path. +const resolvePlugin = { + "bunfig.toml": ` + [serve.static] + plugins = ["./plugin.ts"] + `, + "plugin.ts": ` + import path from "node:path"; + export default { + name: "resolve-some", + setup(build) { + build.onResolve({ filter: /declined\\.\\w+$/ }, () => undefined); + build.onResolve({ filter: /answered\\.\\w+$/ }, args => ({ + path: path.resolve(path.dirname(args.importer), args.path), + })); + }, + }; + `, +}; +devTest("stylesheet that a script imports through an onResolve plugin comes back after a failed first build", { + files: { + ...resolvePlugin, + "index.html": emptyHtmlFile({ + scripts: ["./app.ts"], + body: "

Hello

", + }), + "app.ts": ` + import "./declined.css"; + import "./answered.css"; + console.log("app"); + `, + "declined.css": ` + h1 { + color: red; + }} + `, + "answered.css": ` + body { + color: red; + }} + `, + }, + async test(dev) { + expect((await dev.fetch("/")).status).toBe(500); + await dev.write("declined.css", `h1 { color: red; }`, { errors: null }); + await dev.write("answered.css", `body { color: blue; }`); + // previously: the page had no stylesheet + await using c = await dev.client("/"); + await c.expectMessage("app"); + await c.style("h1").color.expect.toBe("red"); + await c.style("body").color.expect.toBe("#00f"); + }, +}); +devTest("stylesheet imported through an onResolve plugin follows its edits", { + files: { + ...resolvePlugin, + "index.html": emptyHtmlFile({ + styles: ["./main.css"], + body: "

Hello

", + }), + "main.css": ` + @import "./declined.css"; + @import "./answered.css"; + `, + "declined.css": ` + h1 { + color: red; + } + `, + "answered.css": ` + body { + color: red; + } + `, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.style("h1").color.expect.toBe("red"); + await c.style("body").color.expect.toBe("red"); + // previously: the edit did not reach the page + await dev.write("declined.css", `h1 { color: blue; }`); + await c.style("h1").color.expect.toBe("#00f"); + await dev.write("answered.css", `body { color: blue; }`); + await c.style("body").color.expect.toBe("#00f"); + }, +}); function extractCssUrl(backgroundImage: string): string { const url = backgroundImage.match(/url\((['"])(.*?)\1\)/); diff --git a/test/bake/dev/html.test.ts b/test/bake/dev/html.test.ts index ee4f5d8d2530..cd8aee449e84 100644 --- a/test/bake/dev/html.test.ts +++ b/test/bake/dev/html.test.ts @@ -517,7 +517,9 @@ devTest("editing a file imported from outside the project root hot-reloads", { }, }); // One watch batch that holds an html file and a file it references makes both -// entry points of one rebuild. https://github.com/oven-sh/bun/issues/20877 +// entry points of one rebuild. https://github.com/oven-sh/bun/issues/22263 +// The client connects after the rebuild, because a client that is connected gets +// the html file as a hot update. https://github.com/oven-sh/bun/issues/31908 devTest("html file and its scripts rebuilt together load by their module ids", { files: { "index.html": emptyHtmlFile({ @@ -582,7 +584,7 @@ devTest("script that fails in the same rebuild as its html file comes back once `, ); } - // Not a page request: on a release build that bundles the whole route again. + // Not a page request: that bundles the whole route again. await dev.output.waitForLine(/Could not resolve: "\.\/missing\.ts"/); await dev.write( "first.ts", @@ -591,7 +593,7 @@ devTest("script that fails in the same rebuild as its html file comes back once console.log("first fixed " + value); `, ); - // previously: the script bundle of the page held second.ts only + // previously: Failed to load bundled module './first.ts' await using c = await dev.client("/"); await c.expectMessage("first fixed dep", "second"); await dev.fetch("/").expect.toInclude("

World

"); @@ -623,7 +625,7 @@ devTest("stylesheet that fails in the same rebuild as its html file comes back o `, ); } - // Not a page request: on a release build that bundles the whole route again. + // Not a page request: that bundles the whole route again. await dev.output.waitForLine(/Unexpected end of input/); await dev.write( "styles.css", @@ -705,3 +707,132 @@ devTest("stylesheet that an onResolve plugin resolves comes back after a failed await c.style("body").color.expect.toBe("red"); }, }); +devTest("html link to a file that a stylesheet of the same rebuild also uses keeps its asset URL", { + files: { + "index.html": `

Hello

`, + "styles.css": ` + body { + background: url(./shared.txt); + color: red; + } + `, + "shared.txt": "SHARED", + }, + async test(dev) { + const iconHref = async () => (await dev.fetch("/").text()).match(/

World

`, + { dedent: false }, + ); + await dev.write( + "styles.css", + ` + body { + background: url(./shared.txt); + color: blue; + } + `, + ); + } + const href = await iconHref(); + expect(href).toStartWith("/_bun/asset/"); + await dev.fetch(href!).expect.toBe("SHARED"); + await dev.fetch("/").expect.toInclude("

World

"); + }, +}); +devTest("html file and a saved script that start to use the same script in one rebuild", { + files: { + "index.html": emptyHtmlFile({ + scripts: ["./first.ts"], + body: "

Hello

", + }), + "first.ts": ` + import "./dep.ts"; + console.log("first"); + `, + "dep.ts": ` + console.log("dep v1"); + `, + "second.ts": ` + console.log("second"); + `, + }, + async test(dev) { + await dev.fetch("/").expect.toInclude("

Hello

"); + { + await using _batch = await dev.batchChanges(); + // The padding makes dep.ts finish first, so second.ts is in the rebuild before index.html resolves it. + await dev.write( + "index.html", + emptyHtmlFile({ scripts: ["./first.ts", "./second.ts"], body: "

World

" }) + + ``, + { dedent: false }, + ); + await dev.write( + "dep.ts", + ` + import "./second.ts"; + console.log("dep v2"); + `, + ); + } + // previously: Failed to load bundled module './second.ts' + await using c = await dev.client("/"); + await c.expectMessage("second", "dep v2", "first"); + }, +}); +// Vim saves a file this way: the script is gone for a moment, the rebuild of the html file fails, +// and the next rebuild holds both files. https://github.com/oven-sh/bun/issues/20877 +devTest("script that is removed and written again loads by its module id", { + skip: [ + "win32", // unlinkSync is having weird behavior + ], + files: { + "index.html": emptyHtmlFile({ + scripts: ["./main.js"], + body: "

Hello

", + }), + "main.js": ` + console.log("v1"); + `, + }, + async test(dev) { + await dev.fetch("/").expect.toInclude("

Hello

"); + await dev.delete("main.js", { errors: null }); + await dev.output.waitForLine(/Could not resolve: "\.\/main\.js"/); + await dev.write("main.js", `console.log("v2");`); + // previously: Failed to load bundled module './main.js' + await using c = await dev.client("/"); + await c.expectMessage("v2"); + }, +}); +devTest("html routes that share a script and are first bundled together", { + files: { + "a.html": emptyHtmlFile({ scripts: ["./ab.ts", "./ac.ts"] }), + "b.html": emptyHtmlFile({ scripts: ["./ab.ts", "./bc.ts"] }), + "c.html": emptyHtmlFile({ scripts: ["./ac.ts", "./bc.ts"] }), + "ab.ts": `console.log("ab");`, + "ac.ts": `console.log("ac");`, + "bc.ts": `console.log("bc");`, + }, + async test(dev) { + // The first request starts a bundle. The two other routes wait and are bundled together, + // and each pair of routes shares one script. + const responses = await Promise.all([dev.fetch("/a"), dev.fetch("/b"), dev.fetch("/c")]); + expect(responses.map(response => response.status)).toEqual([200, 200, 200]); + for (const [route, messages] of [ + ["/a", ["ab", "ac"]], + ["/b", ["ab", "bc"]], + ["/c", ["ac", "bc"]], + ] as const) { + // previously: Failed to load bundled module './bc.ts' + await using c = await dev.client(route); + await c.expectMessage(...messages); + } + }, +}); From 2583b013c02c9c8ee5165ca500dc98737263d759 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:09:30 +0000 Subject: [PATCH 07/12] dev server: put each comment of the record path loop on the line it explains --- src/bundler/bundle_v2.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 9e80a1305c91..bedc1b175a2f 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -5988,15 +5988,14 @@ pub mod bv2_impl { // // Actual empty files will contain a part exporting an empty object. if part_list.len() != 0 { - // Resolution can set `source_index` and leave the path as written (a plugin, or a - // target already in the bundle): https://github.com/oven-sh/bun/issues/20877 - // The path of any other kind of target is printed, for example an asset URL. + // Resolution through a plugin, or to a target already in the bundle, leaves the path as written: https://github.com/oven-sh/bun/issues/20877 for record in import_records.as_mut_slice() { if !record.source_index.is_valid() { continue; } let resolved = record.source_index.get() as usize; let loader = loaders[resolved]; + // An html file or a stylesheet prints the path of any other target, such as an asset URL. if loader.is_javascript_like() || loader.is_css() { record.path = sources[resolved].path; } From 63d1b677380a68ac3ab002e566ed8fdf9aaa6b0a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:32:25 +0000 Subject: [PATCH 08/12] test(bake): hold the html rebuild with a plugin, not with padding The two tests that need a file to be in the rebuild before index.html resolves it no longer pad index.html. A plugin holds the load of the second index.html until the other file has resolved its imports, and each test waits for the line that the plugin prints. --- test/bake/dev/html.test.ts | 58 +++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/test/bake/dev/html.test.ts b/test/bake/dev/html.test.ts index cd8aee449e84..3179be0658ff 100644 --- a/test/bake/dev/html.test.ts +++ b/test/bake/dev/html.test.ts @@ -707,8 +707,36 @@ devTest("stylesheet that an onResolve plugin resolves comes back after a failed await c.style("body").color.expect.toBe("red"); }, }); +// A `[serve.static]` plugin that holds the index.html that says "World" until another file of the +// same rebuild has resolved its imports. The signal is the load of `marker`, which that file imports. +const holdHtmlRebuildUntilLoaded = (marker: string) => ({ + "bunfig.toml": ` + [serve.static] + plugins = ["./plugin.ts"] + `, + "plugin.ts": ` + export default { + name: "hold-html-rebuild", + setup(build) { + const markerLoaded = Promise.withResolvers(); + build.onLoad({ filter: /${marker.replace(".", "[.]")}$/ }, () => { + markerLoaded.resolve(); + }); + build.onLoad({ filter: /index[.]html$/ }, async args => { + const contents = await Bun.file(args.path).text(); + if (contents.includes("World")) { + await markerLoaded.promise; + console.log("index.html waited for ${marker}"); + } + return { contents, loader: "html" }; + }); + }, + }; + `, +}); devTest("html link to a file that a stylesheet of the same rebuild also uses keeps its asset URL", { files: { + ...holdHtmlRebuildUntilLoaded("marker.css"), "index.html": `

Hello

`, "styles.css": ` body { @@ -716,6 +744,11 @@ devTest("html link to a file that a stylesheet of the same rebuild also uses kee color: red; } `, + "marker.css": ` + h1 { + color: red; + } + `, "shared.txt": "SHARED", }, async test(dev) { @@ -723,15 +756,11 @@ devTest("html link to a file that a stylesheet of the same rebuild also uses kee expect(await iconHref()).toStartWith("/_bun/asset/"); { await using _batch = await dev.batchChanges(); - // The padding makes the stylesheet finish first, so shared.txt is in the rebuild before index.html resolves it. - await dev.write( - "index.html", - `

World

`, - { dedent: false }, - ); + await dev.patch("index.html", { find: "Hello", replace: "World" }); await dev.write( "styles.css", ` + @import "./marker.css"; body { background: url(./shared.txt); color: blue; @@ -739,6 +768,8 @@ devTest("html link to a file that a stylesheet of the same rebuild also uses kee `, ); } + // The stylesheet put shared.txt in the rebuild before index.html resolved it. + await dev.output.waitForLine(/index\.html waited for marker\.css/); const href = await iconHref(); expect(href).toStartWith("/_bun/asset/"); await dev.fetch(href!).expect.toBe("SHARED"); @@ -747,6 +778,7 @@ devTest("html link to a file that a stylesheet of the same rebuild also uses kee }); devTest("html file and a saved script that start to use the same script in one rebuild", { files: { + ...holdHtmlRebuildUntilLoaded("marker.ts"), "index.html": emptyHtmlFile({ scripts: ["./first.ts"], body: "

Hello

", @@ -761,26 +793,26 @@ devTest("html file and a saved script that start to use the same script in one r "second.ts": ` console.log("second"); `, + "marker.ts": ` + export {}; + `, }, async test(dev) { await dev.fetch("/").expect.toInclude("

Hello

"); { await using _batch = await dev.batchChanges(); - // The padding makes dep.ts finish first, so second.ts is in the rebuild before index.html resolves it. - await dev.write( - "index.html", - emptyHtmlFile({ scripts: ["./first.ts", "./second.ts"], body: "

World

" }) + - ``, - { dedent: false }, - ); + await dev.write("index.html", emptyHtmlFile({ scripts: ["./first.ts", "./second.ts"], body: "

World

" })); await dev.write( "dep.ts", ` import "./second.ts"; + import "./marker.ts"; console.log("dep v2"); `, ); } + // dep.ts put second.ts in the rebuild before index.html resolved it. + await dev.output.waitForLine(/index\.html waited for marker\.ts/); // previously: Failed to load bundled module './second.ts' await using c = await dev.client("/"); await c.expectMessage("second", "dep v2", "first"); From e34562faae90bb59011765ae28e548e6a13994cb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:23:40 +0000 Subject: [PATCH 09/12] ci: retrigger From 2d107326727cc5b22af4510c7025b9a506de29e0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:48:28 +0000 Subject: [PATCH 10/12] dev server: leave the import record of a stylesheet that is not a file as written The loop in `finish_from_bake_dev_server` gave each resolved stylesheet record the path of its source. The dev server files the build errors of a module under `path.text`, and the graph key of a path in a plugin namespace is `path.pretty`. A page with a build error in a stylesheet of a plugin namespace then answered 500 on the first load and 200 on the second. The loop now copies the path only for a script or a stylesheet file. Tests: - a guard for the build errors of a module in a plugin namespace - an html script that a plugin puts in a namespace - a stylesheet under a plugin whose name has "tailwind" in it - the rename save and the html and script save with the page open (release builds only, because of #31908) - a script that imports a json file through a plugin, which only the linker names by its file The rename test replays the save of #20877 with a rename and no longer skips Windows. --- src/bundler/bundle_v2.rs | 7 +- .../bake/dev_server/incremental_graph.rs | 2 +- test/bake/dev/css.test.ts | 125 ++++++++++++++++ test/bake/dev/html.test.ts | 137 +++++++++++++++++- 4 files changed, 260 insertions(+), 11 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index cb043e5fba50..1da6804f5101 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -5860,15 +5860,18 @@ pub mod bv2_impl { // // Actual empty files will contain a part exporting an empty object. if part_list.len() != 0 { - // Resolution through a plugin, or to a target already in the bundle, leaves the path as written: https://github.com/oven-sh/bun/issues/20877 + // Resolution through a plugin, or to a target already in the bundle, leaves the path as written, and the dev server names the module by that path: https://github.com/oven-sh/bun/issues/20877 for record in import_records.as_mut_slice() { if !record.source_index.is_valid() { continue; } let resolved = record.source_index.get() as usize; let loader = loaders[resolved]; + // The dev server files the build errors of a stylesheet that is not a file under `path.text`, which only the path as written can match. + let is_stylesheet_file = + loader.is_css() && sources[resolved].path.is_file(); // An html file or a stylesheet prints the path of any other target, such as an asset URL. - if loader.is_javascript_like() || loader.is_css() { + if loader.is_javascript_like() || is_stylesheet_file { record.path = sources[resolved].path; } } diff --git a/src/runtime/bake/dev_server/incremental_graph.rs b/src/runtime/bake/dev_server/incremental_graph.rs index a0f6d49a1168..92318c603e41 100644 --- a/src/runtime/bake/dev_server/incremental_graph.rs +++ b/src/runtime/bake/dev_server/incremental_graph.rs @@ -920,7 +920,7 @@ impl IncrementalGraph { // takes `&mut ctx`. let (flags, src, key) = { let ir = &ctx.import_records[index.get() as usize].as_slice()[i]; - // The path of a record that names neither a script nor a stylesheet can still be the specifier. + // The path of a record that names neither a script nor a stylesheet file can still be the specifier. let path = if ir.source_index.is_valid() { &ctx.sources[ir.source_index.get() as usize].path } else { diff --git a/test/bake/dev/css.test.ts b/test/bake/dev/css.test.ts index 6e80370742f2..0f93d1c257bf 100644 --- a/test/bake/dev/css.test.ts +++ b/test/bake/dev/css.test.ts @@ -896,6 +896,131 @@ devTest("stylesheet imported through an onResolve plugin follows its edits", { await c.style("body").color.expect.toBe("#00f"); }, }); +// The dev server makes the stylesheets of a plugin whose name has "tailwind" in it entry points of every +// bundle. The plugin has the shape of bun-plugin-tailwind: one onLoad for css files, and no onResolve. +devTest("stylesheet of a tailwind plugin keeps its build error and comes back once fixed", { + files: { + "bunfig.toml": ` + [serve.static] + plugins = ["./tailwind-like.ts"] + `, + "tailwind-like.ts": ` + export default { + name: "tailwind-like", + setup(build) { + build.onLoad({ filter: /\\.css$/ }, async args => ({ + contents: "/*! tailwindcss */\\n" + (await Bun.file(args.path).text()), + loader: "css", + })); + }, + }; + `, + "index.html": emptyHtmlFile({ + styles: ["./styles.css"], + body: "

Hello

", + }), + "styles.css": ` + h1 { + color: red; + } + `, + }, + async test(dev) { + await dev.fetch("/").expect.toInclude("

Hello

"); + await dev.write("styles.css", `h1 { color: red; }}`); + expect((await dev.fetch("/")).status).toBe(500); + // previously: 200, and the page had no stylesheet from here on + expect((await dev.fetch("/")).status).toBe(500); + await dev.write("styles.css", `h1 { color: blue; }`); + await using c = await dev.client("/"); + await c.style("h1").color.expect.toBe("#00f"); + }, +}); +// Build errors of a module that a plugin loads from its own namespace. The plugin keeps the import as +// written as the path of the module. "NAME" stands for a name that is different in each case. +const virtualModuleErrors: Record = { + "a syntax error in a stylesheet": { + importer: "css", + text: `@import "NAME.css";`, + onLoad: `() => ({ contents: "h1 { color: red; }}", loader: "css" })`, + }, + "a syntax error in a stylesheet that a script imports": { + importer: "ts", + text: `import "NAME.css";`, + onLoad: `() => ({ contents: "h1 { color: red; }}", loader: "css" })`, + }, + "a syntax error in a stylesheet that the html file links": { + importer: "html", + text: emptyHtmlFile({ styles: ["NAME.css"] }), + onLoad: `() => ({ contents: "h1 { color: red; }}", loader: "css" })`, + }, + "a url() in a stylesheet that does not resolve": { + importer: "css", + text: `@import "NAME.css";`, + onLoad: `() => ({ contents: 'h1 { background: url("./missing.png"); }', loader: "css" })`, + }, + "a url() in a stylesheet that the plugin declines and that does not resolve": { + importer: "css", + text: `@import "NAME.css";`, + onLoad: `() => ({ contents: 'h1 { background: url("./declined.png"); }', loader: "css" })`, + }, + "a stylesheet that imports a script": { + importer: "css", + text: `@import "NAME.css";`, + onLoad: `args => + args.path.endsWith(".ts") + ? { contents: "export {};", loader: "ts" } + : { contents: '@import "' + args.path.replace(/css$/, "ts") + '";', loader: "css" }`, + }, + "a syntax error in a toml file that a stylesheet uses": { + importer: "css", + text: `h1 { background: url("NAME.toml"); }`, + onLoad: `() => ({ contents: "key = ", loader: "toml" })`, + }, +}; +// The second load bundles the route again. It answers 500 only if the build error is on a module that the +// page still imports, and the dev server files the error of a module that is not a file under its path as written. +devTest("page keeps the build error of a module that a plugin loads from a namespace", { + files: { + "bunfig.toml": ` + [serve.static] + plugins = ["./virtual-modules.ts"] + `, + "virtual-modules.ts": ` + const onLoad = [${Object.values(virtualModuleErrors) + .map(({ onLoad }) => onLoad) + .join(", ")}]; + export default { + name: "virtual-modules", + setup(build) { + build.onResolve({ filter: /^virtual\\./ }, args => ({ path: args.path, namespace: "virtual" })); + build.onResolve({ filter: /^\\.\\/declined\\./ }, () => undefined); + build.onLoad({ filter: /.*/, namespace: "virtual" }, args => onLoad[args.path.split(".")[1]](args)); + }, + }; + `, + ...Object.fromEntries( + Object.values(virtualModuleErrors).flatMap(({ importer, text }, n) => { + text = text.replace("NAME", `virtual.${n}`); + if (importer === "html") return [[`page-${n}.html`, text]]; + return [ + [ + `page-${n}.html`, + emptyHtmlFile({ [importer === "css" ? "styles" : "scripts"]: [`./entry-${n}.${importer}`] }), + ], + [`entry-${n}.${importer}`, text], + ]; + }), + ), + }, + async test(dev) { + const statuses: Record = {}; + for (const [n, name] of Object.keys(virtualModuleErrors).entries()) { + statuses[name] = [(await dev.fetch(`/page-${n}`)).status, (await dev.fetch(`/page-${n}`)).status]; + } + expect(statuses).toEqual(Object.fromEntries(Object.keys(virtualModuleErrors).map(name => [name, [500, 500]]))); + }, +}); function extractCssUrl(backgroundImage: string): string { const url = backgroundImage.match(/url\((['"])(.*?)\1\)/); diff --git a/test/bake/dev/html.test.ts b/test/bake/dev/html.test.ts index 3179be0658ff..1d0073ba2580 100644 --- a/test/bake/dev/html.test.ts +++ b/test/bake/dev/html.test.ts @@ -1,6 +1,8 @@ // HTML tests are tests relating to HTML files themselves. import { expect } from "bun:test"; -import { devTest, emptyHtmlFile } from "../bake-harness"; +import { isDebug } from "harness"; +import { renameSync } from "node:fs"; +import { type Dev, devTest, emptyHtmlFile } from "../bake-harness"; devTest("html file is watched", { files: { @@ -675,6 +677,74 @@ devTest("html script that an onResolve plugin resolves loads by its module id", await c.expectMessage("declined", "answered"); }, }); +devTest("html script that an onResolve plugin puts in a namespace loads by its module id", { + files: { + "bunfig.toml": ` + [serve.static] + plugins = ["./plugin.ts"] + `, + // Only onLoad says that "plain" is a script. "virtual:prefixed.ts" keeps its prefix in its path. + "plugin.ts": ` + export default { + name: "virtual-scripts", + setup(build) { + build.onResolve({ filter: /^(plain|virtual:prefixed\\.ts)$/ }, args => ({ + path: args.path, + namespace: "virtual", + })); + build.onLoad({ filter: /.*/, namespace: "virtual" }, args => ({ + contents: "console.log(" + JSON.stringify(args.path) + ");", + loader: "ts", + })); + }, + }; + `, + "index.html": emptyHtmlFile({ + scripts: ["plain", "virtual:prefixed.ts"], + }), + }, + async test(dev) { + // previously: Failed to load bundled module 'plain' + await using c = await dev.client("/"); + await c.expectMessage("plain", "virtual:prefixed.ts"); + }, +}); +// Only the linker gives these records the path of their file: the target is neither a script nor a stylesheet. +devTest("script import of a json file that an onResolve plugin resolves loads by its module id", { + files: { + "bunfig.toml": ` + [serve.static] + plugins = ["./plugin.ts"] + `, + "plugin.ts": ` + import path from "node:path"; + export default { + name: "resolve-json", + setup(build) { + build.onResolve({ filter: /declined\\.json$/ }, () => undefined); + build.onResolve({ filter: /answered\\.json$/ }, args => ({ + path: path.resolve(path.dirname(args.importer), args.path), + })); + }, + }; + `, + "index.html": emptyHtmlFile({ + scripts: ["./index.ts"], + }), + "index.ts": ` + import declined from "./declined.json"; + import answered from "./answered.json"; + console.log(declined.name); + console.log(answered.name); + `, + "declined.json": `{ "name": "declined" }`, + "answered.json": `{ "name": "answered" }`, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage("declined", "answered"); + }, +}); devTest("stylesheet that an onResolve plugin resolves comes back after a failed first build", { files: { "bunfig.toml": ` @@ -818,12 +888,14 @@ devTest("html file and a saved script that start to use the same script in one r await c.expectMessage("second", "dep v2", "first"); }, }); -// Vim saves a file this way: the script is gone for a moment, the rebuild of the html file fails, -// and the next rebuild holds both files. https://github.com/oven-sh/bun/issues/20877 -devTest("script that is removed and written again loads by its module id", { - skip: [ - "win32", // unlinkSync is having weird behavior - ], +// Vim saves a file this way: it renames the script to a backup and writes a new file. The script is gone +// for a moment, the rebuild of the html file fails, and the next rebuild holds both files. +// https://github.com/oven-sh/bun/issues/20877 +const renameToBackup = async (dev: Dev, file: string) => { + await using _batch = await dev.batchChanges({ errors: null }); + renameSync(dev.join(file), dev.join(file + "~")); +}; +devTest("script that is renamed away and written again loads by its module id", { files: { "index.html": emptyHtmlFile({ scripts: ["./main.js"], @@ -835,7 +907,7 @@ devTest("script that is removed and written again loads by its module id", { }, async test(dev) { await dev.fetch("/").expect.toInclude("

Hello

"); - await dev.delete("main.js", { errors: null }); + await renameToBackup(dev, "main.js"); await dev.output.waitForLine(/Could not resolve: "\.\/main\.js"/); await dev.write("main.js", `console.log("v2");`); // previously: Failed to load bundled module './main.js' @@ -843,6 +915,55 @@ devTest("script that is removed and written again loads by its module id", { await c.expectMessage("v2"); }, }); +// The rename save, and the save of an html file together with its script, with the page open. A debug build +// stops at `ASSERTION FAILED` in `replaceModules` of the HMR runtime, because the open page gets the html file +// as a hot update: https://github.com/oven-sh/bun/issues/31908 +if (!isDebug) { + devTest("script that is renamed away and written again reloads the open page", { + files: { + "index.html": emptyHtmlFile({ + scripts: ["./main.js"], + body: "

Hello

", + }), + "main.js": ` + console.log("v1"); + `, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage("v1"); + await renameToBackup(dev, "main.js"); + await c.expectReload(async () => { + await dev.write("main.js", `console.log("v2");`, { errors: null }); + }); + // previously: Failed to load bundled module './main.js' + await c.expectMessage("v2"); + }, + }); + devTest("html file and its script saved together reload the open page", { + files: { + "index.html": emptyHtmlFile({ + scripts: ["script.ts"], + body: "

Hello

", + }), + "script.ts": ` + console.log("v1"); + `, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage("v1"); + await c.expectReload(async () => { + await using _batch = await dev.batchChanges(); + await dev.patch("index.html", { find: "Hello", replace: "World" }); + await dev.write("script.ts", `console.log("v2");`); + }); + // previously: Failed to load bundled module '/script.ts' + await c.expectMessage("v2"); + expect(await c.elemText("h1")).toBe("World"); + }, + }); +} devTest("html routes that share a script and are first bundled together", { files: { "a.html": emptyHtmlFile({ scripts: ["./ab.ts", "./ac.ts"] }), From 78a1a3c06516e6e0c580f63af98b2022c98f5af6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:39:07 +0000 Subject: [PATCH 11/12] test: report the two open-page dev server tests as todo on a debug build The two tests were inside `if (!isDebug)`, so a debug build did not register them and `bun bd test` showed nothing for them. The bake harness now takes "debug" as a `skip` target, which registers a test as `test.todo` on a debug build of bun. The two tests use it. `--todo` runs them on a debug build, where they stop at the assertion of #31908. --- test/bake/bake-harness.ts | 5 +- test/bake/dev/html.test.ts | 93 +++++++++++++++++++------------------- 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/test/bake/bake-harness.ts b/test/bake/bake-harness.ts index c5ba2f53efaf..2203a84aee00 100644 --- a/test/bake/bake-harness.ts +++ b/test/bake/bake-harness.ts @@ -134,7 +134,8 @@ export interface DevServerTest { */ cwd?: string; - skip?: ("win32" | "darwin" | "linux" | "ci")[]; + /** Register the test as `test.todo` on these platforms, in CI, or on a debug build of bun. */ + skip?: ("win32" | "darwin" | "linux" | "ci" | "debug")[]; /** * Only run this test. */ @@ -1929,7 +1930,7 @@ export function indexHtmlScript(htmlFiles: string[]) { ].join("\n"); } -const skipTargets = [process.platform, isCI ? "ci" : null].filter(Boolean); +const skipTargets = [process.platform, isCI ? "ci" : null, isDebugBuild ? "debug" : null].filter(Boolean); function testImpl( description: string, diff --git a/test/bake/dev/html.test.ts b/test/bake/dev/html.test.ts index 1d0073ba2580..de3da8a277ca 100644 --- a/test/bake/dev/html.test.ts +++ b/test/bake/dev/html.test.ts @@ -1,6 +1,5 @@ // HTML tests are tests relating to HTML files themselves. import { expect } from "bun:test"; -import { isDebug } from "harness"; import { renameSync } from "node:fs"; import { type Dev, devTest, emptyHtmlFile } from "../bake-harness"; @@ -918,52 +917,52 @@ devTest("script that is renamed away and written again loads by its module id", // The rename save, and the save of an html file together with its script, with the page open. A debug build // stops at `ASSERTION FAILED` in `replaceModules` of the HMR runtime, because the open page gets the html file // as a hot update: https://github.com/oven-sh/bun/issues/31908 -if (!isDebug) { - devTest("script that is renamed away and written again reloads the open page", { - files: { - "index.html": emptyHtmlFile({ - scripts: ["./main.js"], - body: "

Hello

", - }), - "main.js": ` - console.log("v1"); - `, - }, - async test(dev) { - await using c = await dev.client("/"); - await c.expectMessage("v1"); - await renameToBackup(dev, "main.js"); - await c.expectReload(async () => { - await dev.write("main.js", `console.log("v2");`, { errors: null }); - }); - // previously: Failed to load bundled module './main.js' - await c.expectMessage("v2"); - }, - }); - devTest("html file and its script saved together reload the open page", { - files: { - "index.html": emptyHtmlFile({ - scripts: ["script.ts"], - body: "

Hello

", - }), - "script.ts": ` - console.log("v1"); - `, - }, - async test(dev) { - await using c = await dev.client("/"); - await c.expectMessage("v1"); - await c.expectReload(async () => { - await using _batch = await dev.batchChanges(); - await dev.patch("index.html", { find: "Hello", replace: "World" }); - await dev.write("script.ts", `console.log("v2");`); - }); - // previously: Failed to load bundled module '/script.ts' - await c.expectMessage("v2"); - expect(await c.elemText("h1")).toBe("World"); - }, - }); -} +devTest("script that is renamed away and written again reloads the open page", { + skip: ["debug"], + files: { + "index.html": emptyHtmlFile({ + scripts: ["./main.js"], + body: "

Hello

", + }), + "main.js": ` + console.log("v1"); + `, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage("v1"); + await renameToBackup(dev, "main.js"); + await c.expectReload(async () => { + await dev.write("main.js", `console.log("v2");`, { errors: null }); + }); + // previously: Failed to load bundled module './main.js' + await c.expectMessage("v2"); + }, +}); +devTest("html file and its script saved together reload the open page", { + skip: ["debug"], + files: { + "index.html": emptyHtmlFile({ + scripts: ["script.ts"], + body: "

Hello

", + }), + "script.ts": ` + console.log("v1"); + `, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage("v1"); + await c.expectReload(async () => { + await using _batch = await dev.batchChanges(); + await dev.patch("index.html", { find: "Hello", replace: "World" }); + await dev.write("script.ts", `console.log("v2");`); + }); + // previously: Failed to load bundled module '/script.ts' + await c.expectMessage("v2"); + expect(await c.elemText("h1")).toBe("World"); + }, +}); devTest("html routes that share a script and are first bundled together", { files: { "a.html": emptyHtmlFile({ scripts: ["./ab.ts", "./ac.ts"] }), From 0117921fb52b129dfd042658b05381e81083f4f6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:46:55 +0000 Subject: [PATCH 12/12] test: assert that the plugin of each dev server plugin test ran The tests for an import that an `onResolve` plugin declines or answers could pass with no plugin loaded, because the default resolver finds the same files. Each callback now prints its specifier, and the test asserts the calls. The test for a plugin whose name has "tailwind" in it asserts that the served stylesheet has the banner of the plugin, which is what the dev server looks for. --- test/bake/dev/css.test.ts | 27 +++++++++++++++++++++------ test/bake/dev/html.test.ts | 35 ++++++++++++++++++++++++++--------- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/test/bake/dev/css.test.ts b/test/bake/dev/css.test.ts index 0f93d1c257bf..c2ea79ac2c95 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 { type Dev, devTest, emptyHtmlFile, imageFixtures } from "../bake-harness"; devTest("css file with syntax error does not kill old styles", { files: { @@ -811,6 +811,7 @@ devTest("css import before create project relative", { }, }); // Two `[serve.static]` plugins: one that declines an import, and one that answers with the path. +// Each callback prints "onResolve ". const resolvePlugin = { "bunfig.toml": ` [serve.static] @@ -821,14 +822,23 @@ const resolvePlugin = { export default { name: "resolve-some", setup(build) { - build.onResolve({ filter: /declined\\.\\w+$/ }, () => undefined); - build.onResolve({ filter: /answered\\.\\w+$/ }, args => ({ - path: path.resolve(path.dirname(args.importer), args.path), - })); + build.onResolve({ filter: /declined\\.\\w+$/ }, args => { + console.log("onResolve " + args.path); + }); + build.onResolve({ filter: /answered\\.\\w+$/ }, args => { + console.log("onResolve " + args.path); + return { path: path.resolve(path.dirname(args.importer), args.path) }; + }); }, }; `, }; +// The next `count` calls that `resolvePlugin` printed, sorted. +const onResolveCalls = async (dev: Dev, count: number) => { + const calls: string[] = []; + while (calls.length < count) calls.push((await dev.output.waitForLine(/^onResolve (.+)$/))[1]); + return calls.sort(); +}; devTest("stylesheet that a script imports through an onResolve plugin comes back after a failed first build", { files: { ...resolvePlugin, @@ -854,6 +864,7 @@ devTest("stylesheet that a script imports through an onResolve plugin comes back }, async test(dev) { expect((await dev.fetch("/")).status).toBe(500); + expect(await onResolveCalls(dev, 2)).toEqual(["./answered.css", "./declined.css"]); await dev.write("declined.css", `h1 { color: red; }`, { errors: null }); await dev.write("answered.css", `body { color: blue; }`); // previously: the page had no stylesheet @@ -889,6 +900,7 @@ devTest("stylesheet imported through an onResolve plugin follows its edits", { await using c = await dev.client("/"); await c.style("h1").color.expect.toBe("red"); await c.style("body").color.expect.toBe("red"); + expect(await onResolveCalls(dev, 2)).toEqual(["./answered.css", "./declined.css"]); // previously: the edit did not reach the page await dev.write("declined.css", `h1 { color: blue; }`); await c.style("h1").color.expect.toBe("#00f"); @@ -926,7 +938,10 @@ devTest("stylesheet of a tailwind plugin keeps its build error and comes back on `, }, async test(dev) { - await dev.fetch("/").expect.toInclude("

Hello

"); + // The dev server takes a stylesheet for a tailwind file when the first bytes of its output have "tailwind" in them. + const href = (await dev.fetch("/").text()).match(/World"); }, }); +// Each `onResolve` callback of a test plugin prints "onResolve ". These are the next `count` calls, sorted. +const onResolveCalls = async (dev: Dev, count: number) => { + const calls: string[] = []; + while (calls.length < count) calls.push((await dev.output.waitForLine(/^onResolve (.+)$/))[1]); + return calls.sort(); +}; devTest("html script that an onResolve plugin resolves loads by its module id", { files: { "bunfig.toml": ` @@ -653,10 +659,13 @@ devTest("html script that an onResolve plugin resolves loads by its module id", export default { name: "resolve-scripts", setup(build) { - build.onResolve({ filter: /declined\\.ts$/ }, () => undefined); - build.onResolve({ filter: /answered\\.ts$/ }, args => ({ - path: path.resolve(path.dirname(args.importer), args.path), - })); + build.onResolve({ filter: /declined\\.ts$/ }, args => { + console.log("onResolve " + args.path); + }); + build.onResolve({ filter: /answered\\.ts$/ }, args => { + console.log("onResolve " + args.path); + return { path: path.resolve(path.dirname(args.importer), args.path) }; + }); }, }; `, @@ -674,6 +683,7 @@ devTest("html script that an onResolve plugin resolves loads by its module id", // previously: Failed to load bundled module './declined.ts' await using c = await dev.client("/"); await c.expectMessage("declined", "answered"); + expect(await onResolveCalls(dev, 2)).toEqual(["./answered.ts", "./declined.ts"]); }, }); devTest("html script that an onResolve plugin puts in a namespace loads by its module id", { @@ -720,10 +730,13 @@ devTest("script import of a json file that an onResolve plugin resolves loads by export default { name: "resolve-json", setup(build) { - build.onResolve({ filter: /declined\\.json$/ }, () => undefined); - build.onResolve({ filter: /answered\\.json$/ }, args => ({ - path: path.resolve(path.dirname(args.importer), args.path), - })); + build.onResolve({ filter: /declined\\.json$/ }, args => { + console.log("onResolve " + args.path); + }); + build.onResolve({ filter: /answered\\.json$/ }, args => { + console.log("onResolve " + args.path); + return { path: path.resolve(path.dirname(args.importer), args.path) }; + }); }, }; `, @@ -742,6 +755,7 @@ devTest("script import of a json file that an onResolve plugin resolves loads by async test(dev) { await using c = await dev.client("/"); await c.expectMessage("declined", "answered"); + expect(await onResolveCalls(dev, 2)).toEqual(["./answered.json", "./declined.json"]); }, }); devTest("stylesheet that an onResolve plugin resolves comes back after a failed first build", { @@ -754,7 +768,9 @@ devTest("stylesheet that an onResolve plugin resolves comes back after a failed export default { name: "resolve-styles", setup(build) { - build.onResolve({ filter: /styles\\.css$/ }, () => undefined); + build.onResolve({ filter: /styles\\.css$/ }, args => { + console.log("onResolve " + args.path); + }); }, }; `, @@ -770,6 +786,7 @@ devTest("stylesheet that an onResolve plugin resolves comes back after a failed }, async test(dev) { expect((await dev.fetch("/")).status).toBe(500); + expect(await onResolveCalls(dev, 1)).toEqual(["./styles.css"]); await dev.write("styles.css", `body { color: red; }`); // previously: the page had no stylesheet await using c = await dev.client("/");