Skip to content

bundler: apply the jsx option to entry points that come from files: - #38609

Open
robobun wants to merge 4 commits into
mainfrom
farm/c87e74ea/files-entry-jsx-option
Open

robobun wants to merge 4 commits into
mainfrom
farm/c87e74ea/files-entry-jsx-option

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.build({ entrypoints: ["/v/entry.jsx"], files: { "/v/entry.jsx": ... }, jsx: { runtime: "classic", factory: "myH" } }) ignores the jsx option for the entry point: the file is transpiled with the default automatic React runtime, so the build either fails with ResolveMessage: Could not resolve: "react/jsx-dev-runtime" or, when react happens to be installed, silently bundles react. jsx.importSource and jsx.development are dropped the same way.
  • The same file honors the option when it is imported from another in-memory file, or when the entry point is on disk.
  • Cause: FileMap::result_for_key (src/bundler/bundle_v2.rs:1039) fabricates the bun_resolver::Result for an in-memory file with ..Default::default(), so Result.jsx is jsx::Pragma::default(). enqueue_entry_points_normal (bundle_v2.rs:3078) hands that result to enqueue_entry_item, and ParseTask::init (src/bundler/ParseTask.rs:261) parses with resolve_result.jsx; enqueue_entry_item only patches jsx.development afterwards.
  • The two import paths did not show the bug because they overwrite the task's pragma from the transpiler options after ParseTask::init (enqueue_parse_task, bundle_v2.rs:3622, and the FileMap branch of resolve_import_records, bundle_v2.rs:6239).

Fix

  • FileMap::resolve takes the pragma of the transpiler the file is bundled with and result_for_key stores it in the Result it builds. The three callers pass transpiler.options.jsx for their target.
  • This is what the resolver does for on-disk files: it seeds Result.jsx from its options (src/resolver/resolver.rs:1200) before merging the nearest tsconfig. The consumers (enqueue_entry_item, ParseTask::init) are correct to trust Result.jsx, since for disk files it holds the per-file tsconfig-merged value, so the fix belongs where the in-memory result is built rather than in another consumer-side override. The jsx.development handling in enqueue_entry_item is unchanged and now applies on top of the configured pragma, exactly as it does for disk entry points. For Bun.build the transpiler options are the merged result of the jsx option, the root tsconfig and NODE_ENV, which is the value the import paths already used, so entry points and imports now agree.
  • The existing overrides on the import paths are left in place; they now assign the same value the result already carries.
  • Verified with test/bundler/bundler_files.test.ts: the strengthened "in-memory file with JSX" test and two new entry point tests (classic factory/fragment, automatic importSource with development true and false) fail on the released build and pass with the fix; a fourth new test pins the plugin-declined import path (run_resolver), which is the third caller of FileMap::resolve.
  • Also ran test/bundler/bundler_jsx.test.ts, test/bundler/bun-build-api.test.ts and test/bundler/html-import-manifest.test.ts with the debug build: green.

Background

  • files: (the FileMap) lets Bun.build bundle sources that are not on disk. When a specifier matches a key of the map, the bundler skips the resolver and fabricates the bun_resolver::Result itself in FileMap::resolve; the file's contents are read from the map later, in ParseTask.
  • bun_resolver::Result is the resolver's per-file answer. Besides the path it carries per-file transpile settings, among them the JSX pragma (jsx: runtime, factory, fragment, import source, development flag), because the nearest tsconfig.json can change them per directory. ParseTask::init copies these settings into the parse task, and the parser uses the task's copy only.
  • An entry point from files: goes through enqueue_entry_item, the same function disk entry points use, while an import of a files: entry goes through resolve_import_records (or run_resolver when an onResolve plugin declined it), which have their own code for virtual files. That is why only the entry point was affected.

FileMap::result_for_key built the synthetic resolver Result with a
default jsx pragma. The entry point path (enqueue_entry_item ->
ParseTask::init) parses with whatever the Result carries, the same way
it picks up the tsconfig-merged pragma for on-disk files, so an
in-memory entry point was always transpiled with the default automatic
react runtime regardless of Bun.build({ jsx }). Imports of in-memory
files were unaffected because both import paths overwrite the task's
jsx from the transpiler options afterwards.

FileMap::resolve now takes the transpiler's pragma and stamps it into
the Result it fabricates, so every consumer of a FileMap result sees the
configured pragma.
@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: 7 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: d16c3c30-0646-4b69-ac44-97b6f54a7700

📥 Commits

Reviewing files that changed from the base of the PR and between 2f5c180 and 957e723.

📒 Files selected for processing (2)
  • src/bundler/bundle_v2.rs
  • test/bundler/bundler_files.test.ts

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

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed.

  • Reproduced on bun 1.4.0 with Bun.build({ entrypoints: ["/v/entry.jsx"], files: { "/v/entry.jsx": "console.log(<div/>)" }, jsx: { runtime: "classic", factory: "myH" } }): fails with Could not resolve: "react/jsx-dev-runtime"; the same file as an in-memory import, or as a disk entry point, emits myH(...).
  • Fix: FileMap::resolve stamps the transpiler's jsx pragma into the synthetic resolver result (src/bundler/bundle_v2.rs).
  • Proof: test/bundler/bundler_files.test.ts, 3 tests fail on the released build (USE_SYSTEM_BUN=1 bun test test/bundler/bundler_files.test.ts), all 26 pass with bun bd test.
  • CI: the new tests pass on every lane. The one red lane (ubuntu 25.04 x64) is test/js/bun/s3/s3.test.ts timing out on R2 large uploads, which this change does not touch; reported separately.

@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 — targeted fix that seeds Result.jsx for in-memory files the same way the disk resolver does.

What was reviewed:

  • Traced all three FileMap::resolve callers (enqueue_entry_points_normal, run_resolver, resolve_import_records) — each now passes its target transpiler's options.jsx, and Pragma is Clone.
  • Checked the new unsafe { &(*transpiler).options.jsx } in run_resolver — it is a shared read covered by the existing SAFETY note on the transpiler raw pointer, and the borrow ends inside the call (pragma is cloned).
  • Confirmed enqueue_entry_item only patches .development after ParseTask::init, so factory/fragment/runtime/import_source now flow through unchanged for in-memory entry points, matching disk entry points.
  • Tests cover classic factory/fragment, automatic importSource with both development states, and the plugin-declined run_resolver path; the strengthened existing test would have caught the original bug.
Extended reasoning...

Overview

The PR fixes a bug where Bun.build({ files: {...}, jsx: {...} }) ignored the jsx option for entry points that come from the in-memory files map. FileMap::result_for_key fabricated a bun_resolver::Result with ..Default::default(), so Result.jsx was Pragma::default(), and ParseTask::init parsed with that default. Imports of in-memory files were unaffected because their code paths overwrite task.jsx from transpiler options after ParseTask::init.

The fix adds a jsx: &Pragma parameter to FileMap::resolve/result_for_key and clones it into the fabricated Result. The three callers (entry-point enqueue, run_resolver for plugin-declined imports, and resolve_import_records) each pass their target transpiler's options.jsx. This mirrors what the real resolver does for on-disk files (seeding Result.jsx from resolver options at src/resolver/resolver.rs:1200), so the fix is placed at the layer that owns the invariant rather than adding another consumer-side override.

Security risks

None. This threads an already-computed configuration value (transpiler.options.jsx) into a struct that previously received a default. No untrusted input parsing, no new I/O, no new unsafe primitives — the one added unsafe block is a shared deref of an existing raw pointer under an existing SAFETY justification, used only to read and clone a config struct.

Level of scrutiny

Low-to-medium. The source change is ~10 effective lines: a new parameter threaded to three call sites plus a .clone() into the fabricated Result. Pragma derives Clone and is documented as being cloned per-resolve already, so this adds no new ownership concerns. I verified in enqueue_entry_item (bundle_v2.rs:2775-2790) that the only post-ParseTask::init mutation is .development, which now applies on top of the configured pragma exactly as it does for disk entry points. The two import-path callers already overwrote task.jsx afterwards, so for them the new value in Result.jsx is redundant but harmless (same value assigned twice), keeping the change minimal and behavior-preserving on those paths.

Other factors

Test coverage is thorough for the size of the fix: the existing "in-memory file with JSX" test is strengthened from toContain("Hello JSX") to asserting the exact classic-runtime output and not.toContain("jsxDEV"); three new tests pin classic factory/fragment on both entry and child, automatic importSource with development true and false, and the run_resolver (onResolve-declined) path with an assertion that the plugin actually ran. The PR description states these fail on the released build and pass with the fix, and that bundler_jsx.test.ts, bun-build-api.test.ts, and html-import-manifest.test.ts remain green. No prior reviewer comments to address; the automated bug-hunt found nothing.

Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.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.

LGTM — targeted fix that seeds the synthetic bun_resolver::Result with the transpiler's JSX pragma, mirroring what the on-disk resolver already does.

What was reviewed

  • Confirmed all three FileMap::resolve callers and all three result_for_key sites are updated; no other callers exist.
  • Checked the run_resolver unsafe shared read of (*transpiler).options.jsx — it falls under the existing SAFETY note directly above and does not alias any live &mut.
  • The strengthened "in-memory file with JSX" test was previously vacuous (only checked for the literal text); it now asserts the exact factory call and rules out jsxDEV. New tests cover factory/fragment, importSource + development, and the plugin-declined run_resolver path.
Extended reasoning...

Overview

The PR threads a jsx: &Pragma parameter through FileMap::resolve → result_for_key in src/bundler/bundle_v2.rs and stores jsx.clone() on the fabricated bun_resolver::Result. The three call sites (enqueue_entry_points_normal, resolve_import_records, run_resolver) each pass their target's transpiler.options.jsx. Four tests are added/strengthened in test/bundler/bundler_files.test.ts.

Security risks

None. This is pure transpiler-config plumbing for the Bun.build files: option; no untrusted input parsing, no auth, no FFI surface change. The one unsafe block is a shared read through a raw *mut Transpiler that the surrounding function already launders for borrowck reasons, with a SAFETY comment pointing at the existing justification a few lines above.

Level of scrutiny

Low-to-medium. The change is small (~20 net lines of Rust), mechanical, and placed at the layer that owns the invariant: the real resolver seeds Result.jsx from its options for on-disk files, and this makes the in-memory path do the same rather than adding another consumer-side override. The two import paths already overwrote the pragma after ParseTask::init, so their behavior is unchanged (the new value now matches what they overwrite it with); only the entry-point path changes behavior, from Pragma::default() to the configured pragma.

Other factors

  • Grep confirms exactly three file_map.resolve( callers and three result_for_key( sites, all updated in the diff.
  • Pragma: Clone is already relied on at bundle_v2.rs:6234, so jsx.clone() is not new surface.
  • Tests assert exact transpiled output (myFactory("div", ...), import source string) and negative conditions (not.toContain("jsxDEV")), covering classic and automatic runtimes plus dev/prod. The plugin-declined test guards resolveCalled so it can't vacuously pass. Author reports the new tests fail under USE_SYSTEM_BUN=1 and pass on the debug build; adjacent suites (bundler_jsx, bun-build-api, html-import-manifest) also pass.
  • The comment-cop bot flagged verbose doc comments on earlier commits; those were removed in 957e723 and both threads are resolved.
  • No CODEOWNERS entry covers src/bundler/ or test/bundler/.

This branch has not been deployed

No deployments
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