Skip to content

bundler: drop import attributes from import() calls that code splitting points at a chunk - #38461

Open
robobun wants to merge 4 commits into
mainfrom
farm/3c88bfb3/splitting-chunk-import-attributes
Open

bundler: drop import attributes from import() calls that code splitting points at a chunk#38461
robobun wants to merge 4 commits into
mainfrom
farm/3c88bfb3/splitting-chunk-import-attributes

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun build --splitting keeps the import attributes a user wrote on a dynamic import when it rewrites that import to point at a JavaScript chunk:
    // entry.ts
    const { default: data } = await import("./data.json", { with: { type: "json" } });
    // out/entry.js
    var { default: data } = await import("./data-jn2krqnp.js", { with: { type: "json" } });
    The chunk is JavaScript, so loading the bundle fails: SyntaxError: JSON parse error: Unrecognized token '/' under bun (any --target), ERR_IMPORT_ATTRIBUTE_TYPE_INCOMPATIBLE under node. Same for type: "text", "toml", "file", the legacy assert: form, and options passed as a variable (import("./data.json", options) prints import("./data-HASH.js", options)). Without the attribute the same program builds to import("./data-jn2krqnp.js") and works. Reproduces on 1.4.0 and main; esbuild drops the attribute in this situation.
  • Cause: computeCrossChunkDependencies.rs (walk, the "Rewrite external dynamic imports" loop) repoints the import record at the chunk by replacing path.text and invalidating source_index, but the options object lives on the E::Import AST node, not on the record (transpose_import in src/js_parser/p.rs stores it there). The printer's external import() branch in print_require_or_import_expr (src/js_printer/lib.rs) then prints that object after whatever path the record holds.

Fix

  • New ImportRecordFlags::POINTS_TO_JS_CHUNK (bit 10 was unused). The linker sets it where it repoints the record, when the target chunk's content is JavaScript; the printer's external import() branch skips the options object when it is set.
  • The gate matters because the target is not always a JavaScript chunk: a dynamically imported .css or .html file is an entry point whose chunk is the .css/.html output itself (computeChunks.rs, the entry_point_chunk_indices loop), so the rewrite produces import("./styles-HASH.css", ...). A with { type: "css" } written on that import still describes what is loaded (in browsers it is what makes importing a stylesheet work), so those records are left exactly as on main. If such an import is later given a JavaScript chunk, the gate strips the attribute there automatically.
  • The linker also clears record.loader for the records it flags. It held the loader for the original file (from the attribute, or from resolution), and the record now names a JavaScript chunk. Nothing reads it for an import() record on main today; bundler: keep sqlite imports external when the loader comes from the extension or loader map #38275 adds a printer branch that would, and import statements already print with { type } from this field, so the record should not carry a stale value.
  • Why this is correct: the options described the file the user imported. The bundler has already applied that loader when it built the chunk (the chunk exports the parsed JSON/text/TOML, or the asset path for type: "file", as an ES module), so re-applying it to the chunk is never right, and the printer cannot tell a chunk import from a genuinely external import() without the flag. The flag is set only on this rewrite, so external dynamic imports keep their options (they still load the file the user named), and non-splitting builds are untouched (an inlined import() never reaches this branch). Dropping the object cannot drop side effects: the parser only attaches an import record to an import() whose options expression is side-effect free (e_import in src/js_parser/visit/visit_expr.rs).
  • Not changed: one file imported with two different attribute loaders (type: "text" and type: "json" on the same path) is still bundled once and both imports now receive that one chunk; that is bundler: bundle a file once per requested loader instead of once per path #37476's subject. The metafile's inputs entry for a cross-chunk import() is also unchanged.
  • Tests in test/bundler/bundler_splitting.test.ts. Fail on the released binary, pass with this change:
    • DynamicImportWithAttributeToChunk (target bun, the report's repro), DynamicImportAttributesToChunkMinified
    • DynamicImportAttributesToChunkAllLoaders: attributes that select the loader on their own (assert json on a .notjson file, text on a .md file, toml on an extensionless file, file)
    • DynamicImportOptionsVariableToChunk: options held in a variable
    • DynamicImportAttributesToChunkFromSharedChunk: the import() sits in a module shared by two entry points, so the rewrite runs in a shared chunk
    • ConditionalDynamicImportExternalAndChunk: one options object shared by both arms of import(cond ? "external-data" : "./data.json", ...); printed as import("external-data", { with: ... }) : import("./data-HASH.js")
  • Guards that pass before and after: ExternalDynamicImportKeepsAttributes (--external import keeps its options) and DynamicImportToCssChunkKeepsAttribute (import("./styles.css", { with: { type: "css" } }) still prints the attribute with the .css path; fails without the content gate).
  • Also run with the debug build: esbuild/splitting, bundler_compile_splitting, bundler_bun, bundler_html, esbuild/default, bundler_edgecase, transpiler/transpiler.test.js, test/js/bun/import-attributes; cargo fmt --all. Manually: the report's repro under bun and, built with --target node, under node; --compile --splitting (the embedded import() loses the attribute too; the standalone loader ignored it, so that case already ran); a file imported both statically and dynamically still yields one module instance.

Background

  • Import record: the bundler's entry for one import site. During linking, source_index points at the bundled module it resolved to; a record without one is printed back out as an import of its path. Under --splitting, every dynamically imported file becomes an entry point with its own chunk, and computeCrossChunkDependencies repoints each cross-chunk import() record at that chunk (its path.text becomes the chunk's placeholder key, replaced with the final file name when chunks are written) so the printer emits it through the external import() path. This PR adds one more piece of state to that rewrite.
  • Chunk content: each output chunk is Content::Javascript, Content::Css or Content::Html. A JavaScript entry point gets a JavaScript chunk (plus a secondary CSS chunk for any stylesheets it imports); a .css or .html entry point's primary chunk is the stylesheet or document itself, and that is the chunk a dynamic import of such a file is pointed at.
  • Import attributes: the { with: { type: "json" } } second argument of import(). At runtime, bun, node and browsers use it to pick how the target file is loaded (bun accepts its loader names there, such as text and toml). At build time, the parser uses it to set the loader the bundler parses that file with; the file then becomes an ordinary ES module in the graph.
  • ImportRecord::loader: the loader attached to an import site. The printer uses it to re-emit with { type } on import statements that stay external for --target bun, so the runtime loads the file the way the build intended.
Earlier version of this PR

The first push set the flag (then named POINTS_TO_CHUNK) on every rewritten record, which also stripped with { type: "css" } from an import() pointed at a .css output. Review caught that; the second push gates the flag on the target chunk being JavaScript, renames it, and adds the loader, variable, shared chunk, conditional and stylesheet tests.


[review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: 6 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_splitting.test.ts
bun test v1.4.0 (55d788c4f)

test/bundler/bundler_splitting.test.ts:
(pass) bundler > splitting/DynamicImportCSSFile [1021.44ms]
(pass) bundler > splitting/DynamicImportMultipleCSSImports [663.84ms]
(pass) bundler > splitting/StaticAndDynamicCSSImports [667.79ms]
(pass) bundler > splitting/NestedDynamicImportWithCSS [771.66ms]
(pass) bundler > splitting/SharedCSSBetweenChunks [680.02ms]
(pass) bundler > splitting/DynamicImportChainWithCSS [626.21ms]
(pass) bundler > splitting/ConditionalDynamicImportWithCSS [619.09ms]
(pass) bundler > splitting/MultipleEntryPointsWithSharedCSS [877.56ms]
(pass) bundler > splitting/DynamicImportWithOnlyCSSNoJS [606.00ms]
(pass) bundler > splitting/CircularDynamicImportsWithCSS [687.96ms]
341 |     },
342 |     splitting: true,
343 |     outdir: "/out",
344 |     target: "bun",
345 |     onAfterBundle(api) {
346 |       expect(api.readFile("/out/entry.js")).toMatch(/import\("\.\/data-[a-z0-9]+\.js"\)/);
                                                  ^
error: expe
... (truncated)

release without fix: 6 FAILED
bun test v1.4.0-canary.1 (b7a043103)

test/bundler/bundler_splitting.test.ts:
(pass) bundler > splitting/DynamicImportCSSFile [45.23ms]
(pass) bundler > splitting/DynamicImportMultipleCSSImports [44.51ms]
(pass) bundler > splitting/StaticAndDynamicCSSImports [37.15ms]
(pass) bundler > splitting/NestedDynamicImportWithCSS [41.38ms]
(pass) bundler > splitting/SharedCSSBetweenChunks [46.47ms]
(pass) bundler > splitting/DynamicImportChainWithCSS [45.79ms]
(pass) bundler > splitting/ConditionalDynamicImportWithCSS [34.38ms]
(pass) bundler > splitting/MultipleEntryPointsWithSharedCSS [41.76ms]
(pass) bundler > splitting/DynamicImportWithOnlyCSSNoJS [28.79ms]
(pass) bundler > splitting/CircularDynamicImportsWithCSS [36.94ms]
341 |     },
342 |     splitting: true,
343 |     outdir: "/out",
344 |     target: "bun",
345 |     onAfterBundle(api) {
346 |       expect(api.readFile("/out/entry.js")).toMatch(/import\("\.\/data-[a-z0-9]+\.js"\)/);
                                                  ^
error: expect(received).toMatch(expected)

Expected substring or pattern: /import\("\.\/data-[a-z0-9]+\.js"\)/
Received: "// @bun\nimport {\n  __require\n} from \"./entry-2k8z266m.js\";
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_splitting.test.ts
bun test v1.4.0 (55d788c4f)

test/bundler/bundler_splitting.test.ts:
(pass) bundler > splitting/DynamicImportCSSFile [1344.40ms]
(pass) bundler > splitting/DynamicImportMultipleCSSImports [750.83ms]
(pass) bundler > splitting/StaticAndDynamicCSSImports [646.47ms]
(pass) bundler > splitting/NestedDynamicImportWithCSS [694.21ms]
(pass) bundler > splitting/SharedCSSBetweenChunks [657.40ms]
(pass) bundler > splitting/DynamicImportChainWithCSS [826.98ms]
(pass) bundler > splitting/ConditionalDynamicImportWithCSS [732.57ms]
(pass) bundler > splitting/MultipleEntryPointsWithSharedCSS [906.33ms]
(pass) bundler > splitting/DynamicImportWithOnlyCSSNoJS [656.95ms]
(pass) bundler > splitting/CircularDynamicImportsWithCSS [731.20ms]
(pass) bundler > splitting/DynamicImportWithAttributeToChunk [616.08ms]
(pass) bundler > splitting/DynamicImportAttributesToChunkAllLoaders [910.99ms]
(pass) bundler > splitting/DynamicImportOptionsVariableToChunk [459.60ms]
(pass) bundler > splitting/DynamicImportAttributesToChunk
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     55d788c4fb
  features     baseline

22 deps, 123 codegen, 1176 objects in 877ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] gen ErrorCode+*.h
[2/1238] install /workspace/bun
bun install v1.4.0-canary.1 (b7a043103)

Checked 107 installs across 153 packages (no changes) [28.00ms]
[3/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (b7a043103)

Checked 1 install across 2 packages (no changes) [3.00ms]
[4/1238] fetch tinycc
[tinycc] up to date
[5/1237] gen bindgenv2
[6/1237] fetch zlib
[zlib] up to date
[7/1237] gen .bind.ts → GeneratedBindings.cpp
[8/1237] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[9/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[10/1237] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (b7a043103)

Checked 129 installs across 147 packages (no changes) [20.00ms]
... (truncated)
diff hotspot
src/ast/import_record.rs                           |   4 +
 .../computeCrossChunkDependencies.rs               |  20 +-
 src/js_printer/lib.rs                              |   4 +-
 test/bundler/bundler_splitting.test.ts             | 205 +++++++++++++++++++++
 4 files changed, 226 insertions(+), 7 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/ast/import_record.rs                                      3      4      0
…bundler/linker_context/computeCrossChunkDependencies.rs      4      8      0
src/js_printer/lib.rs                                         4      1      0
test/bundler/bundler_splitting.test.ts                        2      4      0

…ng points at a chunk

With --splitting, a dynamic import of a bundled file is rewritten to import
the chunk built for it. The options object the user wrote on the import()
(for example { with: { type: "json" } }) was printed unchanged after the
chunk path, so the runtime applied that loader to a JavaScript chunk and
failed to load it.

Mark the record when the linker points it at a chunk and have the printer
leave the options out for such records. The record's loader is cleared at
the same point, since it described the original file too.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 31 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ef93241b-8242-4d2e-8138-620d3fc09a52

📥 Commits

Reviewing files that changed from the base of the PR and between 27419b4 and 55d788c.

📒 Files selected for processing (4)
  • src/ast/import_record.rs
  • src/bundler/linker_context/computeCrossChunkDependencies.rs
  • src/js_printer/lib.rs
  • test/bundler/bundler_splitting.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:05 AM PT - Aug 14th, 2026

@robobun, your commit 1f0ac82 has 1 failures in Build #95914 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38461

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

bun-38461 --bun

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. CI for the current head (55d788c, build 96248) has every completed lane green (177 of 179 jobs; the remaining two are darwin 14 aarch64 lanes that have been queued for hours); the only entries in its failure list are tests that passed on retry, none of them bundler-related.

Reproduced on 1.4.0 with the two-file repro from the report (bun build ./entry.ts --target bun --splitting --outdir out prints import("./data-HASH.js", { with: { type: "json" } }) and bun out/entry.js fails with a JSON parse error; --target node output fails under node with ERR_IMPORT_ATTRIBUTE_TYPE_INCOMPATIBLE). With this branch the output is import("./data-HASH.js") and both runtimes print 42.

Six new tests in test/bundler/bundler_splitting.test.ts (json repro on target bun, minified, attribute-selected loaders, options in a variable, import() inside a shared chunk, conditional import() with an external arm) fail on the released binary and pass with the debug build. ExternalDynamicImportKeepsAttributes and DynamicImportToCssChunkKeepsAttribute pin that external imports and imports pointed at a .css output keep their attributes.

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

LGTM — surgical fix that drops stale import attributes when code splitting repoints an import() at a JS chunk.

What was reviewed:

  • Verified bit 10 in ImportRecordFlags was unused (gap between WAS_ORIGINALLY_REQUIRE at 9 and IS_EXTERNAL_WITHOUT_SIDE_EFFECTS at 11).
  • Confirmed the printer check sits in the "External import()" branch of print_require_or_import_expr, the only path a chunk-repointed record reaches.
  • Checked that genuinely external import() calls are unaffected (flag is set only at the linker rewrite site); the ExternalDynamicImportKeepsAttributes test pins this.
  • Tests cover json/text/toml, both with and assert forms, and minified output, each asserting on printed output and running the bundle.
Extended reasoning...

Overview

This PR fixes a bug where bun build --splitting kept the user's import attributes (e.g. { with: { type: "json" } }) on a dynamic import() after rewriting it to point at a JavaScript chunk, causing the runtime to try loading the JS chunk as JSON/text/TOML and fail. The fix touches four files: a new POINTS_TO_CHUNK bitflag in src/ast/import_record.rs (bit 10, previously unused), setting that flag and clearing record.loader at the rewrite site in src/bundler/linker_context/computeCrossChunkDependencies.rs, a two-line guard in src/js_printer/lib.rs to skip printing the options object when the flag is set, and four new itBundled tests in test/bundler/bundler_splitting.test.ts.

Security risks

None. This is bundler output correctness — it changes what JavaScript is emitted for a dynamic import that the bundler already resolved and inlined into a chunk. No untrusted input parsing, no auth/crypto/permissions.

Level of scrutiny

Low-to-medium. The production change is ~10 lines and follows an established pattern (bitflag on ImportRecord, checked in the printer — the file already has several such flags). The fix is placed at the correct layer: the linker owns the rewrite and is the only place that knows the record now names a chunk; the printer can't otherwise distinguish a chunk-repointed import from a genuinely external one. Clearing record.loader alongside is defensive hygiene (nothing reads it for import() records today, but it would be stale). The PR description addresses the side-effect question: the parser only attaches an import record to an import() whose options expression is side-effect-free, so dropping the printed options object cannot change runtime semantics.

Other factors

Test coverage is thorough for the change size: three failing-before tests covering json (via both with and legacy assert), text, toml, and minified output — each asserts the printed import() has no second argument via regex and runs the output end-to-end. A fourth test pins that --external dynamic imports still keep their attributes (passes before and after). The PR description also lists the broader bundler/transpiler suites that were run with the debug build. esbuild's behavior (dropping the attribute in this case) is cited as the reference. No outstanding reviewer comments; only bot noise on the timeline.

…cript

A dynamically imported .css or .html entry point is pointed at its own
.css/.html output rather than at a JavaScript chunk, and the attributes
written on the import() still apply to that file, so leave those records
alone. Rename the flag to POINTS_TO_JS_CHUNK to match.

Cover the loaders an attribute selects on its own (json via assert on a
.notjson file, text on a .md file, toml on an extensionless file, file),
options held in a variable, an import() rewritten inside a shared chunk, a
conditional import() whose external arm keeps the shared options object, and
the stylesheet case keeping its attribute.
Comment thread src/ast/import_record.rs Outdated
Comment thread src/bundler/linker_context/computeCrossChunkDependencies.rs
Comment thread src/bundler/linker_context/computeCrossChunkDependencies.rs
Comment thread src/bundler/linker_context/computeCrossChunkDependencies.rs Outdated
Comment thread src/ast/import_record.rs Outdated
Comment thread src/ast/import_record.rs

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

LGTM — focused bundler fix with thorough test coverage.

What was reviewed:

  • Verified bit 10 was an unused gap in ImportRecordFlags: u16 (between WAS_ORIGINALLY_REQUIRE at 9 and IS_EXTERNAL_WITHOUT_SIDE_EFFECTS at 11).
  • Confirmed the printer gate sits in the external import() branch of print_require_or_import_expr, which is exactly where records with invalidated source_index land after the linker rewrite.
  • Checked the _chunks BackRef aliasing: reading other_chunk.content alongside the pre-existing unique_key read is disjoint from what walk mutates (import records, chunk_meta), and the field-list comments were updated to match.
  • Guard tests pin external imports and CSS-chunk imports keep their attributes; the is_javascript() gate is what makes the CSS case pass.
Extended reasoning...

Overview

Fixes bun build --splitting emitting stale import attributes on import() calls that the linker repoints at a JavaScript chunk (e.g. import("./data-HASH.js", { with: { type: "json" } }), which fails at runtime). Adds ImportRecordFlags::POINTS_TO_JS_CHUNK; the linker sets it (and clears record.loader) when the target chunk's content is JavaScript, and the printer's external import() branch skips the options object when the flag is present. ~20 lines of production code across three files, plus 8 new itBundled tests.

Security risks

None. This is bundler output shaping — no auth, crypto, path traversal, or untrusted-input parsing is touched. Dropping the options expression cannot drop side effects because (per the PR description, and consistent with the parser design) an import record is only attached to import() when its options expression is side-effect free.

Level of scrutiny

Medium. The bundler is production-critical, but this change is narrow and additive: one new flag bit in a pre-existing gap, one write site (the exact loop that already rewrites path.text and source_index for cross-chunk dynamic imports), one read site (the printer branch that already handled import_options). The is_javascript() gate correctly excludes CSS/HTML chunks, and there is a guard test (DynamicImportToCssChunkKeepsAttribute) that fails without the gate. External dynamic imports never enter the rewrite loop, so their attributes are untouched (also guard-tested).

Other factors

  • Test coverage is unusually thorough for a bundler fix: the report's repro, minified variant, all attribute-selected loaders (json via assert, text, toml, file), options-as-variable, import() inside a shared chunk, a conditional import with one external and one chunk arm, plus two negative guards. Each asserts both the printed output shape and runtime behavior.
  • The comment-cop bot flagged long comments on earlier commits; all were shortened or removed in be085b3/55d788c4 and the threads are resolved. The remaining two-line doc comment on the flag matches the style of neighboring flags.
  • The one CI failure (test/bake/deinitialization.test.ts segfault on Windows) is unrelated to bundler code splitting.
  • No CODEOWNERS on the touched paths.

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