Skip to content

bundler: emit the JS chunk for an import()ed stylesheet that is also an entry point - #38455

Open
robobun wants to merge 3 commits into
farm/2d1f3508/css-dynamic-import-js-chunkfrom
farm/9a07ccbc/css-user-entry-dynamic-import
Open

bundler: emit the JS chunk for an import()ed stylesheet that is also an entry point#38455
robobun wants to merge 3 commits into
farm/2d1f3508/css-dynamic-import-js-chunkfrom
farm/9a07ccbc/css-user-entry-dynamic-import

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

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 todo test, 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 an import() of it. Everything here is --splitting only; import() of a stylesheet without splitting is #38307's.

Problem

  • With --splitting, a stylesheet that the user passes as an entry point and that is also import()ed gets no JS chunk, so the import() is rewritten to the stylesheet's .css output (import("./styles.module.css")), and with --css-chunking to chunk 0, which is the importing entry point itself (entry.js ends up with import("./entry.js")).
  • Cause: entry point kinds are exclusive. LinkerGraph::load (src/bundler/LinkerGraph.rs, loop over dynamic_import_entry_points) skips a file that is already UserSpecified, so such a stylesheet never becomes a DynamicImport entry 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 in compute_chunks (src/bundler/linker_context/computeChunks.rs, CSS branch of the entry point loop) and the walk of the stylesheet's parts in mark_file_live_step (src/bundler/LinkerContext.rs). The stylesheet therefore keeps only its CSS chunk, which is what entry_point_chunk_index names and what compute_cross_chunk_dependencies rewrites the import() to. With --css-chunking that 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, leaving entry_point_chunk_index at the 0 LinkerGraph::load zero-fills it with.

Fix

  • LinkerGraph gets a dynamically_imported_files bitset, filled in load from every import() target before the UserSpecified skip. The two sites above test it instead of kind == 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 not import()ed stays CSS-only (pinned by the existing UserSpecifiedCSSEntryPointHasNoJSChunk).
  • Because the stylesheet now has a JS chunk, the existing compute_chunks rule already links entry_point_chunk_index to it and the import() is rewritten to that chunk; find_imported_parts_in_js_order from 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.
  • New Chunk::entry_point_kind (src/bundler/Chunk.rs) returns DynamicImport for 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 in compute_chunks, the output kind in generate_chunks_in_parallel (in-memory builds) and write_output_files_to_disk (outdir builds), and the naming hint in the duplicate output path error. So the chunk is styles.module-<hash>.js with kind chunk whether 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.
  • Why this shape: esbuild gives the same input styles.module.css (entry point name) plus a styles.module-<hash>.js chunk exporting default and the class names, with the import() pointing at the chunk; in esbuild the JS side of a stylesheet is a separate file that becomes a DynamicImport entry point on its own, and the bitset plus Chunk::entry_point_kind is how Bun, where both sides share one source index, reaches the same result. Classifying the chunk as chunk rather than entry-point also keeps Bun.build's outputs.filter(o => o.kind === "entry-point") and the compile path, which takes the first entry-point output as the executable's entry, from picking up a hashed chunk.
  • Tests, in test/bundler/bundler_splitting.test.ts: DynamicImportOfUserSpecifiedCSSEntryPoint (the todo from 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's exports and cssBundle in the metafile, and the entry runs), DynamicImportOfUserSpecifiedCSSEntryPointInMemory (stylesheet listed first, in-memory Bun.build, output kinds on the other path and the chunk named by the chunk template), and DynamicImportOfUserSpecifiedCSSEntryPointWithCSSChunking in both entry point orders through the CLI (the .css and 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.
  • Also run with this change, all passing: 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-deps and cargo fmt --check are clean.
  • Sequencing: bundler: emit the module.exports body of a require()d CSS file #38307 replaces the same return in mark_file_live_step with a wrap != Cjs check; 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 in compute_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.
  • Verified together with bundler: pull the runtime into chunks that print a CSS file's namespace object #38286 and bundler: emit the module.exports body of a require()d CSS file #38307 (both merged into this branch locally, with that one hunk resolved as above): bundler_splitting, bundler_loader and css/css-modules pass (91 tests), and a --splitting build whose stylesheet is an entry point that one entry import()s and another require()s works in both entry point orders, with and without --css-chunking: the stylesheet's chunk becomes var require_styles_module = __commonJS(...) plus export default require_styles_module(), importing __commonJS from 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

  • Entry points and kinds: with --splitting, every user entry point and every import() target is an entry point and gets its own chunk; File.entry_point_kind records UserSpecified or DynamicImport, one per file, and a file the user passed that is also import()ed stays UserSpecified. entry_point_chunk_index maps an entry point file to the chunk an import() of it is rewritten to.
  • A stylesheet's JS side: a stylesheet imported from JS is parsed into a CSS AST plus a small JS AST (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.
  • Naming and kinds of chunks: an entry point's chunks are named with the entry naming template and reported as entry-point; other chunks use the chunk naming template and are reported as chunk. CSS chunks are always reported as asset, and the secondary JS and CSS chunks of an HTML entry point already use the chunk template (the HAS_HTML_CHUNK flag); the JS chunk of a stylesheet is the same situation.
Repro, before and after
printf '.foo { color: red }\n' > styles.module.css
printf 'import("./styles.module.css").then(m => console.log(Object.keys(m).join(",")));\n' > entry.js
bun build entry.js styles.module.css --outdir out --splitting --target bun && bun out/entry.js
bun build entry.js styles.module.css --outdir out2 --splitting --css-chunking --target bun && bun out2/entry.js

Before (on #38319, and on the release), out/entry.js contains import("./styles.module.css") and prints __esModule,default (bun's runtime loads the .css file as a module without the class map; a browser fails to load a stylesheet as a module); out2/entry.js contains import("./entry.js") and prints an empty line, the keys of its own empty namespace.

After, both builds contain import("./styles.module-<hash>.js") and print default,foo. The first build writes entry.js, styles.module-<hash>.js (listed as a chunk), entry.css and styles.module.css; the second writes entry.js, styles.module-<hash>.js and the single shared CSS output, which --css-chunking already 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 same import() rewrite.

…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.
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:01 PM PT - Aug 14th, 2026

@robobun, your commit 7f20045 has 2 failures in Build #96124 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38455

That installs a local version of the PR into your bun-38455 executable, so you can run:

bun-38455 --bun

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on the release (1.4.0) and on the #38319 build with bun build entry.js styles.module.css --outdir out --splitting (entry.js gets import("./styles.module.css"); with --css-chunking, import("./entry.js")). The fix is the commits after the base branch; the four new cases in test/bundler/bundler_splitting.test.ts fail on the base and pass with it, and the PR body records the check of this change together with #38286 and #38307.

CI on 7f20045: 177 of 179 jobs green. The two red jobs are both halves of the darwin 14 aarch64 lane, failing test/js/third_party/astro/astro-post.test.js (SIGABRT) and test/integration/vite-build/vite-build.test.ts (exit 134): abort() inside vite/rolldown running under the runtime, which does not go through the bundler this PR changes; main's darwin 14 aarch64 lanes passed on builds before this one, and both failures have been handed to main-branch triage. The failing cargo clippy check is large_enum_variant on src/sourcemap/lib.rs, which this PR does not touch: the base branch predates #38280, which removed that enum on main, and CI merges this PR into its base rather than into main. Both go away once #38319 lands and this PR is rebased onto main.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::Noneoutput_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_points is 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 use tempDir, bunEnv, drain pipes concurrently, and assert stderr/exit in the right order. The InMemory test lists the stylesheet first to exercise entry point id 0.
  • No bugs surfaced by the multi-agent review.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

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 compute_chunks, behind the dynamically_imported_files check, so Chunk::entry_point_kind returning DynamicImport for such a chunk follows from that. I am adding a debug_assert! on the bitset inside the helper so the invariant is checked rather than implied; it will go up with the next push along with anything else the self-review turns up.

Comment thread src/bundler/Chunk.rs Outdated
Comment thread src/bundler/LinkerGraph.rs Outdated
Comment thread src/bundler/LinkerGraph.rs
Comment thread src/bundler/LinkerGraph.rs Outdated
Comment thread src/bundler/linker_context/computeChunks.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_kind against 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 the debug_assert! on the bitset).
  • Remaining items_entry_point_kind() reads elsewhere are per-file (!= None checks) and don't need the helper.
  • The bitset is filled before the UserSpecified skip and listed in the Send/Sync SAFETY comment as read-only after fan-out.
  • onAfterApiBundle is a real expectBundled hook; the four new tests cover outdir, in-memory, and CLI paths in both entry orders, and UserSpecifiedCSSEntryPointHasNoJSChunk still 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 Noneoutput_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 return in mark_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.build with stylesheet at entry id 0), CLI with --css-chunking in 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 kind reclassification, this is better signed off by a bundler maintainer than auto-approved.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Nothing to change from this pass. For the record, 7f20045 only shortens comments relative to the reviewed 63c87c4 (it is what closed the comment-cop threads, now resolved); the CI run for it is the one in progress.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant