diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 6c390e509c65..a622920a50b2 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -5720,9 +5720,13 @@ pub mod bv2_impl { // `graph.ast.items(.import_records)[importer_source_index]` when // they complete. Without this, the graph entry stays at // JSAst.empty and the deferred plugin callback index-out-of- - // bounds crashes in BundleV2.onResolve / runResolver. The linker - // never runs because `transpiler.log.errors > 0` aborts the - // build before link time, so saving the AST is safe. + // bounds crashes in BundleV2.onResolve / runResolver. In a + // non-dev build the linker never runs after this error + // (`transpiler.log.errors > 0` aborts before link time). The + // dev server does link with failed files, but it filters them + // out by their empty `parts` list and never reads a failed + // file's import_records, so saving them is safe — unlike the + // `css` slot below. let result_heap = *result.ast.import_records.allocator(); this.graph.ast.items_import_records_mut()[source_index.0 as usize] = core::mem::replace( @@ -5730,9 +5734,19 @@ pub mod bv2_impl { bun_alloc::ArenaVec::new_in(result_heap), ); - // Move the CSS stylesheet onto the graph row so teardown can find - // and drop it — the `Success` arm that would normally do this is skipped. - this.graph.ast.items_css_mut()[source_index.0 as usize] = result.ast.css.take(); + // Drop the parsed stylesheet now — the `Success` arm that would + // normally move it onto the graph row is skipped. It must not be + // parked on the graph row either: the dev server proceeds with + // failed files and treats a populated `css` slot as a + // successfully parsed CSS file (CSS entry point discovery and + // import ordering in `finish_from_bake_dev_server`), so a parked + // stylesheet would produce a CSS chunk for a failed file while + // `graph.css_file_count` stays 0. + if let Some(css_ref) = result.ast.css.take() { + // SAFETY: live arena pointer, uniquely owned here (the graph + // row for this file stays `None`); dropped exactly once. + unsafe { core::ptr::drop_in_place(css_ref.as_ptr()) }; + } parse_result.value = parse_task::ResultValue::Err(parse_task::ResultError { err, diff --git a/test/bake/dev/css.test.ts b/test/bake/dev/css.test.ts index f2a59043eee5..6c106769de1f 100644 --- a/test/bake/dev/css.test.ts +++ b/test/bake/dev/css.test.ts @@ -206,6 +206,53 @@ devTest("syntax error crash", { expect((await dev.fetch("/")).status).toBe(500); }, }); +devTest("css url resolve error on hot reload is recoverable", { + files: { + "styles.css": ` + body { + color: red; + } + `, + "index.html": emptyHtmlFile({ + styles: ["styles.css"], + body: `hello world`, + }), + }, + async test(dev) { + { + await using c = await dev.client("/"); + await c.style("body").color.expect.toBe("red"); + // A CSS file that parses but fails import resolution must fail the + // rebuild with an error instead of being treated as a valid CSS chunk. + // previously: panic: assertion failed: !chunk.content.is_css() + await dev.write( + "styles.css", + ` + body { + background-image: url(./missing.png); + } + `, + { + errors: ['styles.css:2:21: error: Could not resolve: "./missing.png"'], + }, + ); + expect((await dev.fetch("/")).status).toBe(500); + } + // Recovery is checked without a connected client: when a failed CSS root + // recovers, the patch currently ships the HTML route as a JS module + // without the route-reload flag, which trips a client-side debug assert + // (tracked in https://github.com/oven-sh/bun/issues/31908). + await dev.write( + "styles.css", + ` + body { + color: blue; + } + `, + ); + expect((await dev.fetch("/")).status).toBe(200); + }, +}); devTest("circular css imports handle hot reload", { files: { "index.html": emptyHtmlFile({