bundler: emit the JS chunk for an import()ed stylesheet that is also an entry point - #38455
Conversation
…an entry point With code splitting, a file that is import()ed becomes a DynamicImport entry point unless the user already passed it as an entry point, in which case it stays UserSpecified. The stylesheet JS chunk was keyed on the DynamicImport kind, so a stylesheet that was both got only its CSS chunk and the import() was rewritten to the .css output (or, with --css-chunking, to chunk 0). Record every import() target in a LinkerGraph bitset and key the JS chunk and the tree-shaking walk on that instead. The stylesheet's JS chunk is named and classified as a dynamic import chunk in both cases, so the user-specified stylesheet keeps its entry-named CSS output and gains a hashed JS chunk, matching esbuild.
|
Updated 1:01 PM PT - Aug 14th, 2026
❌ @robobun, your commit 7f20045 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 38455That installs a local version of the PR into your bun-38455 --bun |
|
Status: reproduced on the release (1.4.0) and on the #38319 build with CI on 7f20045: 177 of 179 jobs green. The two red jobs are both halves of the darwin 14 aarch64 lane, failing |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes core bundler chunk computation and output classification across several files (and is stacked on #38319), a maintainer look would still be worthwhile.
Checked: Chunk::entry_point_kind is behavior-preserving at all four call sites for non-stylesheet chunks (Kind::None.output_kind() → Chunk; HTML/CSS content falls through to the file's kind); the dynamically_imported_files bitset is equivalent to the old kind == DynamicImport test everywhere except the target case; ctx.entry_point_kinds is still used at other sites in mark_file_live_step, so not dead; the Send/Sync SAFETY note is updated for the new bitset.
Extended reasoning...
Overview
This PR fixes an edge case in the bundler where a stylesheet that is both a user-specified entry point and an import() target gets no JS chunk, leaving the import() rewritten to the .css output (or, with --css-chunking, to chunk 0). It adds a dynamically_imported_files bitset to LinkerGraph (populated before the UserSpecified skip in load), swaps two kind == DynamicImport gates to test that bitset instead, and introduces Chunk::entry_point_kind to classify a stylesheet's JS chunk as DynamicImport regardless of the file's kind. Four call sites (naming template, output kind on both in-memory and outdir paths, duplicate-path error hint) are refactored to use the helper. Four new tests cover both entry-point orders, both output paths, and --css-chunking.
Security risks
None. This is bundler chunking/naming logic; no untrusted input parsing, auth, crypto, or filesystem-traversal changes.
Level of scrutiny
Medium-high. compute_chunks, tree-shaking liveness, and output classification are critical bundler paths where a wrong condition silently produces broken bundles. The change is small and mechanical, but it alters user-visible output (kind: "chunk" instead of "entry-point" for the stylesheet's JS chunk, chunk-template naming instead of entry-template), and Chunk::entry_point_kind unconditionally returns DynamicImport for any JS chunk whose source has a CSS AST — I verified the only such chunks are created in the CSS branch of the entry loop and only when dynamically_imported_files.is_set, so the invariant holds, but it's the kind of coupling a maintainer should confirm.
Other factors
- Stacked PR: base is #38319's branch. The diff shown is just this PR's delta, but merge order and the noted textual conflicts with #38307/#38341 need a human to sequence.
- Behavior preservation: I walked each refactored site — non-entry chunks return
Kind::None→output_kind() == Chunk(matches the old else arm); HTML/CSS chunks fall through to the file's kind; JS entry points without a CSS AST are unchanged. The only delta is the intended one. - Bitset equivalence without code splitting:
dynamic_import_entry_pointsis empty, so the bitset stays zero and both changed gates behave as before. - Tests: the four new cases pin the rewrite target, output kinds on both code paths, metafile
exports/cssBundle, CLI summary, and runtime behavior; they usetempDir,bunEnv, drain pipes concurrently, and assert stderr/exit in the right order. TheInMemorytest lists the stylesheet first to exercise entry point id 0. - No bugs surfaced by the multi-agent review.
|
On the coupling noted in the review: the only place that creates a JS chunk whose entry point is a stylesheet is the CSS branch of the entry point loop in |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. It's a focused but non-trivial bundler change — new LinkerGraph state, a new per-chunk entry_point_kind helper replacing four direct reads, and a user-visible reclassification of a stylesheet's JS output from entry-point to chunk — stacked on #38319 with noted merge-order interactions with #38307/#38341, so a human look is still worthwhile.
What was reviewed:
Chunk::entry_point_kindagainst every call site: non-entry-point →None/Chunk, CSS/HTML chunks fall through to the file's kind, only JS-chunk-with-CSS-source diverges (guarded by thedebug_assert!on the bitset).- Remaining
items_entry_point_kind()reads elsewhere are per-file (!= Nonechecks) and don't need the helper. - The bitset is filled before the
UserSpecifiedskip and listed in the Send/Sync SAFETY comment as read-only after fan-out. onAfterApiBundleis a realexpectBundledhook; the four new tests cover outdir, in-memory, and CLI paths in both entry orders, andUserSpecifiedCSSEntryPointHasNoJSChunkstill pins the negative case.
Extended reasoning...
Overview
Stacked on #38319. Adds LinkerGraph.dynamically_imported_files: BitSet (populated in load() from every import() target before the UserSpecified early-continue) and Chunk::entry_point_kind(), which returns DynamicImport for a JS chunk whose entry-point source has a CSS AST and the file's stored kind otherwise. Two condition sites (mark_file_live_step in LinkerContext.rs, the CSS branch of the entry-point loop in computeChunks.rs) switch from kind == DynamicImport to the bitset. Four per-chunk reads of the file's kind (naming template in computeChunks.rs, output-kind in generateChunksInParallel.rs and writeOutputFilesToDisk.rs, the duplicate-path error hint) switch to the helper. Four new tests plus one updated comment in bundler_splitting.test.ts.
Security risks
None. No untrusted-input parsing, no path handling changes, no auth/crypto. The change is internal chunk-classification bookkeeping.
Level of scrutiny
Medium-high. The bundler's chunk classification drives user-visible BuildArtifact.kind, output filenames (entry vs chunk template), the CLI summary, and — per the PR description — the --compile path's choice of executable entry. The helper is small and I traced it against every replaced site (non-entry-point returns None → output_kind() gives Chunk, matching the old else branch; CSS/HTML content skips the stylesheet check so their kinds are unchanged; the debug_assert! pins the invariant that a JS-chunk-with-CSS-source only exists behind the bitset gate). I also grepped for other items_entry_point_kind() reads: the remaining ones in LinkerContext.rs and convertStmtsForChunk.rs are per-file != None checks where the helper's distinction is irrelevant.
Other factors
- Stacked on #38319 (not yet landed); the PR description calls out merge-order interactions with #38307 (same
returninmark_file_live_step) and #38341 — a maintainer should confirm the sequencing plan. - CI shows a failure the author attributes to a base-branch clippy issue unrelated to this diff; latest push (7f20045) addressed the comment-cop feedback but no CI result for it is visible yet.
- Test coverage is thorough: outdir (
itBundled+onAfterApiBundle), in-memory (Bun.buildwith stylesheet at entry id 0), CLI with--css-chunkingin both entry orders, and the existing negative test still pins that a non-import()ed stylesheet entry stays CSS-only. - Given the cross-PR coupling and the user-visible
kindreclassification, this is better signed off by a bundler maintainer than auto-approved.
Stacked on #38319 (this PR's base is its branch); only the commits after it are this PR. #38319 adds the JS chunk for an import()ed stylesheet and leaves the case below as a
todotest, which this PR turns into a passing one. Like #38319, the case was found by reading the code rather than reported by a user; it needs--splitting, the stylesheet in the entry point list and animport()of it. Everything here is--splittingonly;import()of a stylesheet without splitting is #38307's.Problem
--splitting, a stylesheet that the user passes as an entry point and that is alsoimport()ed gets no JS chunk, so theimport()is rewritten to the stylesheet's.cssoutput (import("./styles.module.css")), and with--css-chunkingto chunk 0, which is the importing entry point itself (entry.jsends up withimport("./entry.js")).LinkerGraph::load(src/bundler/LinkerGraph.rs, loop overdynamic_import_entry_points) skips a file that is alreadyUserSpecified, so such a stylesheet never becomes aDynamicImportentry point, and both places bundler: emit a JS chunk for a dynamically imported stylesheet #38319 added are keyed on that kind: the JS chunk creation incompute_chunks(src/bundler/linker_context/computeChunks.rs, CSS branch of the entry point loop) and the walk of the stylesheet's parts inmark_file_live_step(src/bundler/LinkerContext.rs). The stylesheet therefore keeps only its CSS chunk, which is whatentry_point_chunk_indexnames and whatcompute_cross_chunk_dependenciesrewrites theimport()to. With--css-chunkingthat CSS chunk is shared with the importer's CSS (same content hash) and so skipped by the "entry point also has a JS chunk" rule, leavingentry_point_chunk_indexat the 0LinkerGraph::loadzero-fills it with.Fix
LinkerGraphgets adynamically_imported_filesbitset, filled inloadfrom everyimport()target before theUserSpecifiedskip. The two sites above test it instead ofkind == DynamicImport; for a stylesheet that is not a user entry point the two conditions are identical, so bundler: emit a JS chunk for a dynamically imported stylesheet #38319's case is unchanged, and a user-specified stylesheet that is notimport()ed stays CSS-only (pinned by the existingUserSpecifiedCSSEntryPointHasNoJSChunk).compute_chunksrule already linksentry_point_chunk_indexto it and theimport()is rewritten to that chunk;find_imported_parts_in_js_orderfrom bundler: emit a JS chunk for a dynamically imported stylesheet #38319 fills the chunk for any stylesheet entry point, so nothing else is needed there.Chunk::entry_point_kind(src/bundler/Chunk.rs) returnsDynamicImportfor the JS chunk of a stylesheet (asserting in debug builds that the stylesheet is in the bitset, which is the only way such a chunk gets created) and the file's kind otherwise, and replaces the four per-chunk reads of the file's kind: the naming template choice incompute_chunks, the output kind ingenerate_chunks_in_parallel(in-memory builds) andwrite_output_files_to_disk(outdir builds), and the naming hint in the duplicate output path error. So the chunk isstyles.module-<hash>.jswith kindchunkwhether or not the stylesheet is also a user entry point, while the stylesheet's CSS output keeps its entry point name (styles.module.css) and its existing kind. For every other chunk the helper returns what the sites read before.styles.module.css(entry point name) plus astyles.module-<hash>.jschunk exportingdefaultand the class names, with theimport()pointing at the chunk; in esbuild the JS side of a stylesheet is a separate file that becomes aDynamicImportentry point on its own, and the bitset plusChunk::entry_point_kindis how Bun, where both sides share one source index, reaches the same result. Classifying the chunk aschunkrather thanentry-pointalso keepsBun.build'soutputs.filter(o => o.kind === "entry-point")and thecompilepath, which takes the firstentry-pointoutput as the executable's entry, from picking up a hashed chunk.test/bundler/bundler_splitting.test.ts:DynamicImportOfUserSpecifiedCSSEntryPoint(thetodofrom bundler: emit a JS chunk for a dynamically imported stylesheet #38319 made real: the rewrite, the CSS output, output kinds on the outdir path, the chunk'sexportsandcssBundlein the metafile, and the entry runs),DynamicImportOfUserSpecifiedCSSEntryPointInMemory(stylesheet listed first, in-memoryBun.build, output kinds on the other path and the chunk named by the chunk template), andDynamicImportOfUserSpecifiedCSSEntryPointWithCSSChunkingin both entry point orders through the CLI (the.cssand the chunk 0 rewrites from the report; also checks the summary lists the chunk as a chunk). All four fail on the base branch (no JS chunk is produced) and pass with this change.bundler_splitting,css/css-modules,bundler_loader,html-import-manifest,metafile,bundler_html,bundler_compile_splitting,esbuild/splitting,esbuild/css,bundler_naming,bun-build-api,bundler_edgecase,esbuild/loader,bundler_regressions(486 tests).cargo clippy -p bun_bundler --no-depsandcargo fmt --checkare clean.returninmark_file_live_stepwith awrap != Cjscheck; whichever lands second combines the two conditions (wrap != Cjs && !dynamically_imported_files.is_set(..)), as bundler: emit a JS chunk for a dynamically imported stylesheet #38319 already notes. bundler: list CSS entry points in the metafile and report copied asset sizes #38341 adds a line to the CSS chunk creation a few lines above the changed condition incompute_chunks; the two merge cleanly. The metafile assertions compare keys to each other instead of spelling out the././prefix that bundler: stop rendering "././" output paths from naming templates that start with [dir] #38366 is removing.bundler_splitting,bundler_loaderandcss/css-modulespass (91 tests), and a--splittingbuild whose stylesheet is an entry point that one entryimport()s and anotherrequire()s works in both entry point orders, with and without--css-chunking: the stylesheet's chunk becomesvar require_styles_module = __commonJS(...)plusexport default require_styles_module(), importing__commonJSfrom the shared runtime chunk, and the importer gets the usual.then(m => __toESM(m.default, 1)). bundler: emit the module.exports body of a require()d CSS file #38307's rule that a stylesheet entry point's bit stays off the runtime therefore still holds with the new chunk, which imports what it needs across chunks instead. That combined case is not a test here because it only passes once the whole family has landed; it belongs in whichever PR lands last.Background
--splitting, every user entry point and everyimport()target is an entry point and gets its own chunk;File.entry_point_kindrecordsUserSpecifiedorDynamicImport, one per file, and a file the user passed that is alsoimport()ed staysUserSpecified.entry_point_chunk_indexmaps an entry point file to the chunk animport()of it is rewritten to.export default {class map}and one export per class) under the same source index. esbuild keeps that JS in a separate file; Bun does not, which is why chunking and tree shaking have stylesheet-specific branches and why one file can need both a CSS chunk and a JS chunk.entry-point; other chunks use the chunk naming template and are reported aschunk. CSS chunks are always reported asasset, and the secondary JS and CSS chunks of an HTML entry point already use the chunk template (theHAS_HTML_CHUNKflag); the JS chunk of a stylesheet is the same situation.Repro, before and after
Before (on #38319, and on the release),
out/entry.jscontainsimport("./styles.module.css")and prints__esModule,default(bun's runtime loads the.cssfile as a module without the class map; a browser fails to load a stylesheet as a module);out2/entry.jscontainsimport("./entry.js")and prints an empty line, the keys of its own empty namespace.After, both builds contain
import("./styles.module-<hash>.js")and printdefault,foo. The first build writesentry.js,styles.module-<hash>.js(listed as a chunk),entry.cssandstyles.module.css; the second writesentry.js,styles.module-<hash>.jsand the single shared CSS output, which--css-chunkingalready named after the first entry point before this change.For comparison, esbuild writes
entry.js,styles.module-<hash>.js,entry.css,styles.module.css(and a second, hashed copy of the CSS for the chunk, which Bun points at the entry-named output instead), with the sameimport()rewrite.