From c82b0a4da871468a49f5d2e1220c706eeea8f0f0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 05:02:46 +0000 Subject: [PATCH 01/23] Bun.Transpiler: default autoImportJSX to true for the automatic runtime The automatic JSX transform replaces `
` with a call through a generated binding (`jsxDEV_7x81h0kn(...)`). That binding only exists when the matching `import { jsxDEV as jsxDEV_... } from '.../jsx-dev-runtime'` is emitted alongside it. `Bun.Transpiler` defaulted `autoImportJSX` to `false`, so the emitted code referenced a name that was never declared anywhere and threw `ReferenceError: Can't find variable: jsxDEV` at runtime. `Bun.build` already defaults this to `true`, as do esbuild, tsc, swc, and Babel for the automatic runtime (the auto import is what 'automatic' means). Users who want the old behavior can pass `autoImportJSX: false`. Fixes #7499 --- packages/bun-types/bun.d.ts | 8 +++ src/runtime/api/JSTranspiler.rs | 6 ++ test/bundler/transpiler/transpiler.test.js | 64 ++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index dd4a17aca696..750c28e75127 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -2462,6 +2462,14 @@ declare module "bun" { */ macro?: MacroMap; + /** + * When the automatic JSX runtime is in use, prepend + * `import { jsx, jsxs, Fragment } from "/jsx-runtime"` + * (or `jsxDEV` from `jsx-dev-runtime`) to the output so it can run + * standalone. Set `false` to suppress the import. + * + * @default true + */ autoImportJSX?: boolean; allowBunRuntime?: boolean; exports?: { diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index cdde6429ea73..e084d2181551 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -97,6 +97,12 @@ impl Default for Config { log: bun_ast::Log::default(), // overwritten at construction runtime: Runtime::Features { top_level_await: true, + // The automatic JSX runtime is only runnable when the + // `jsx`/`jsxs`/`jsxDEV`/`Fragment` bindings are imported, so + // emit that import by default (matches Bun.build and + // esbuild/tsc/babel). Users can still opt out via + // `autoImportJSX: false`. + auto_import_jsx: true, ..Default::default() }, tree_shaking: false, diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index d72bcc6fddcd..3069e828f49d 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2086,6 +2086,7 @@ export default <>hi "process.env.NODE_ENV": JSON.stringify("development"), }, logLevel: "error", + autoImportJSX: false, }); expect(bun.transformSync("console.log(
{}} points={() => {}}>
);")).toBe( @@ -2193,6 +2194,67 @@ console.log(
);`), } }); + // https://github.com/oven-sh/bun/issues/7499 + // The automatic JSX runtime replaces
with a call through a generated + // binding (e.g. `jsxDEV_7x81h0kn`) that only exists when the matching + // `import { jsxDEV as jsxDEV_... } from ".../jsx-dev-runtime"` is emitted + // alongside it. Bun.Transpiler used to default autoImportJSX to false, so the + // emitted code referenced a name that was never declared anywhere. + describe("autoImportJSX defaults to true for the automatic runtime", () => { + it("development", () => { + const out = new Bun.Transpiler({ + loader: "tsx", + define: { "process.env.NODE_ENV": JSON.stringify("development") }, + }).transformSync("export default function App() { return <>
hi
; }"); + expect(out).toMatch( + /^import { jsxDEV as (\w+), Fragment as (\w+) } from "react\/jsx-dev-runtime";\nexport default function App\(\) {\n return \1\(\2,/, + ); + }); + + it("production", () => { + const out = new Bun.Transpiler({ + loader: "tsx", + tsconfig: { compilerOptions: { jsx: "react-jsx" } }, + }).transformSync("export default
hi
;"); + expect(out).toMatch(/^import { jsx as (\w+) } from "react\/jsx-runtime";\nexport default \1\("div",/); + }); + + it("respects jsxImportSource", () => { + const out = new Bun.Transpiler({ + loader: "tsx", + tsconfig: { compilerOptions: { jsx: "react-jsx", jsxImportSource: "preact" } }, + }).transformSync("export default
hi
;"); + expect(out).toMatch(/^import { jsx as (\w+) } from "preact\/jsx-runtime";\nexport default \1\("div",/); + }); + + it("key-after-spread emits createElement with an import", () => { + const out = new Bun.Transpiler({ + loader: "tsx", + define: { "process.env.NODE_ENV": JSON.stringify("development") }, + logLevel: "error", + }).transformSync(`export default
;`); + expect(out).toMatch(/^import { createElement as (\w+) } from "react";\nexport default \1\("div",/); + }); + + it("does not affect the classic runtime", () => { + const out = new Bun.Transpiler({ + loader: "tsx", + tsconfig: { compilerOptions: { jsx: "react" } }, + }).transformSync("export default
hi
;"); + expect(out).toBe('export default React.createElement("div", null, "hi");\n'); + }); + + it("can still be disabled", () => { + const out = new Bun.Transpiler({ + loader: "tsx", + define: { "process.env.NODE_ENV": JSON.stringify("development") }, + autoImportJSX: false, + }).transformSync("export default
hi
;"); + expect(out).not.toContain("import"); + expect(out).toContain("jsxDEV"); + }); + }); + it("JSX bare key prop followed by key with a value does not crash", async () => { await using proc = Bun.spawn({ cmd: [ @@ -2203,6 +2265,7 @@ console.log(
);`), loader: "jsx", define: { "process.env.NODE_ENV": JSON.stringify("development") }, logLevel: "error", + autoImportJSX: false, }); process.stdout.write(t.transformSync('console.log(
);')); process.stdout.write(t.transformSync('console.log(
);')); @@ -2344,6 +2407,7 @@ console.log(
);`), define: { "process.env.NODE_ENV": JSON.stringify("development"), }, + autoImportJSX: false, }); expect(bun.transformSync("export var foo =
{...a}b
")).toBe( `export var foo = jsxDEV_7x81h0kn("div", { From 9046fc6d571e24bbc1db190f41b9bcd79d147f72 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 06:50:50 +0000 Subject: [PATCH 02/23] Address review: cover .transform()/.scan(), update docs, trim comment --- docs/runtime/transpiler.mdx | 6 ++++++ test/bundler/transpiler/transpiler.test.js | 25 +++++++++++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/docs/runtime/transpiler.mdx b/docs/runtime/transpiler.mdx index 02a3b3fccb08..993865c88678 100644 --- a/docs/runtime/transpiler.mdx +++ b/docs/runtime/transpiler.mdx @@ -34,6 +34,7 @@ const result = transpiler.transformSync(code); ```` ```ts output +import { jsxDEV as jsxDEV_7x81h0kn } from "react/jsx-dev-runtime"; import * as whatever from "./whatever.ts"; export function Home(props) { return jsxDEV_7x81h0kn("p", { @@ -216,6 +217,11 @@ interface TranspilerOptions { // Default: false trimUnusedImports?: boolean, + // Whether to prepend the automatic JSX runtime import + // (`import { jsx } from "react/jsx-runtime"`) when JSX is used + // Default: true + autoImportJSX?: boolean, + // Whether to enable a set of JSX optimizations // jsxOptimizationInline ..., diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 3069e828f49d..3f50162e56b4 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2195,11 +2195,6 @@ console.log(
);`), }); // https://github.com/oven-sh/bun/issues/7499 - // The automatic JSX runtime replaces
with a call through a generated - // binding (e.g. `jsxDEV_7x81h0kn`) that only exists when the matching - // `import { jsxDEV as jsxDEV_... } from ".../jsx-dev-runtime"` is emitted - // alongside it. Bun.Transpiler used to default autoImportJSX to false, so the - // emitted code referenced a name that was never declared anywhere. describe("autoImportJSX defaults to true for the automatic runtime", () => { it("development", () => { const out = new Bun.Transpiler({ @@ -2211,6 +2206,16 @@ console.log(
);`), ); }); + it("async .transform() also emits the import", async () => { + const out = await new Bun.Transpiler({ + loader: "tsx", + define: { "process.env.NODE_ENV": JSON.stringify("development") }, + }).transform("export default
hi
;"); + expect(out).toMatch( + /^import { jsxDEV as (\w+) } from "react\/jsx-dev-runtime";\nexport default \1\("div",/, + ); + }); + it("production", () => { const out = new Bun.Transpiler({ loader: "tsx", @@ -2253,6 +2258,16 @@ console.log(
);`), expect(out).not.toContain("import"); expect(out).toContain("jsxDEV"); }); + + it("surfaces the runtime import through .scan()", () => { + const opts = { loader: "tsx", define: { "process.env.NODE_ENV": JSON.stringify("development") } }; + + expect(new Bun.Transpiler(opts).scan("export default
;").imports).toEqual([ + { kind: "import-statement", path: "react/jsx-dev-runtime" }, + ]); + expect(new Bun.Transpiler({ ...opts, autoImportJSX: false }).scan("export default
;").imports).toEqual([]); + expect(new Bun.Transpiler(opts).scan("export const x = 1;").imports).toEqual([]); + }); }); it("JSX bare key prop followed by key with a value does not crash", async () => { From e8aff3d9eaf14fad03e565c546851fc10d22d3f2 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 06:53:02 +0000 Subject: [PATCH 03/23] [autofix.ci] apply automated fixes --- test/bundler/transpiler/transpiler.test.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 3f50162e56b4..873e89a7508b 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2211,9 +2211,7 @@ console.log(
);`), loader: "tsx", define: { "process.env.NODE_ENV": JSON.stringify("development") }, }).transform("export default
hi
;"); - expect(out).toMatch( - /^import { jsxDEV as (\w+) } from "react\/jsx-dev-runtime";\nexport default \1\("div",/, - ); + expect(out).toMatch(/^import { jsxDEV as (\w+) } from "react\/jsx-dev-runtime";\nexport default \1\("div",/); }); it("production", () => { From 70b10060ae3fc5654d81411b48b627c914e66827 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:31:08 +0000 Subject: [PATCH 04/23] Drop multi-line comment per review; add no-arg constructor coverage --- src/runtime/api/JSTranspiler.rs | 5 ----- test/bundler/transpiler/transpiler.test.js | 6 ++++++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index e084d2181551..ac20af05dffd 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -97,11 +97,6 @@ impl Default for Config { log: bun_ast::Log::default(), // overwritten at construction runtime: Runtime::Features { top_level_await: true, - // The automatic JSX runtime is only runnable when the - // `jsx`/`jsxs`/`jsxDEV`/`Fragment` bindings are imported, so - // emit that import by default (matches Bun.build and - // esbuild/tsc/babel). Users can still opt out via - // `autoImportJSX: false`. auto_import_jsx: true, ..Default::default() }, diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 873e89a7508b..e7582fe66905 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2196,6 +2196,12 @@ console.log(
);`), // https://github.com/oven-sh/bun/issues/7499 describe("autoImportJSX defaults to true for the automatic runtime", () => { + it("no-arg constructor", () => { + expect(new Bun.Transpiler().transformSync("export default
;")).toMatch( + /^import { jsxDEV as (\w+) } from "react\/jsx-dev-runtime";\nexport default \1\("div",/, + ); + }); + it("development", () => { const out = new Bun.Transpiler({ loader: "tsx", From d30e0de4cea22d18d3c847d8f643a9f8a4f6942f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:14:29 +0000 Subject: [PATCH 05/23] ci: retrigger From 68a560ddb5e6486da725fe17f7b711ebc482fd25 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:31:40 +0000 Subject: [PATCH 06/23] docs: clarify autoImportJSX only imports used bindings --- packages/bun-types/bun.d.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 750c28e75127..cf4cac1af180 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -2463,10 +2463,11 @@ declare module "bun" { macro?: MacroMap; /** - * When the automatic JSX runtime is in use, prepend - * `import { jsx, jsxs, Fragment } from "/jsx-runtime"` - * (or `jsxDEV` from `jsx-dev-runtime`) to the output so it can run - * standalone. Set `false` to suppress the import. + * When the automatic JSX runtime is in use, prepend an import for the + * bindings the transformed output actually uses (some of `jsx`, `jsxs`, + * `Fragment` from `"/jsx-runtime"`, or `jsxDEV` from + * `jsx-dev-runtime`) so the output can run standalone. Set `false` to + * suppress the import. * * @default true */ From c9e59569c6fc862bc48494c5acc135967999b2db Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:37:59 +0000 Subject: [PATCH 07/23] Clarify autoImportJSX JSDoc; make no-arg test NODE_ENV-agnostic --- test/bundler/transpiler/transpiler.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index e7582fe66905..63746639a5bd 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2198,7 +2198,7 @@ console.log(
);`), describe("autoImportJSX defaults to true for the automatic runtime", () => { it("no-arg constructor", () => { expect(new Bun.Transpiler().transformSync("export default
;")).toMatch( - /^import { jsxDEV as (\w+) } from "react\/jsx-dev-runtime";\nexport default \1\("div",/, + /^import \{ jsx(?:DEV)? as (\w+) \} from "react\/jsx-(?:dev-)?runtime";\nexport default \1\("div",/, ); }); From c3c071733218c5074a62835bbac2557cf37dc671 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:25:15 +0000 Subject: [PATCH 08/23] scanImports: mirror the auto-import gate so it agrees with scan() scanImports() on JSX unconditionally reported two require-call records (jsx-dev-runtime plus the classic source) regardless of the configured JSX runtime or the autoImportJSX flag, diverging from scan() which reports the single import-statement the transform actually emits. _scan_imports now uses the same gate as the full-parse auto-import path (jsx.parse && auto_import_jsx && runtime == Automatic), emits ImportKind::Stmt, and drops the phantom classic record. The JSTranspiler scanImports entry point now plumbs auto_import_jsx into the parser options so the gate has the user's value. --- src/js_parser/parse/parse_entry.rs | 23 ++++++--------- src/runtime/api/JSTranspiler.rs | 1 + test/bundler/transpiler/transpiler.test.js | 33 ++++++++++++++++++++++ 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index ed43c6032144..3273ed4685a4 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -480,28 +480,23 @@ impl<'a> Parser<'a> { } } - // Symbol use counts are unavailable - // So we say "did we parse any JSX?" - // if yes, just automatically add the import so that .bun knows to include the file. - if p.options.jsx.parse && p.needs_jsx_import { + // Symbol use counts are unavailable, so "any JSX parsed?" is the proxy. + // Mirror the full-parse auto-import gate so scanImports() and scan() + // agree on the injected JSX runtime import. + if p.options.jsx.parse + && p.needs_jsx_import + && p.options.features.auto_import_jsx + && p.options.jsx.runtime == options::JSX::Runtime::Automatic + { // `add_import_record` requires `&'a [u8]`, but borrowing // `p.options` would conflict with `&mut p`, so copy into the arena. let arena = p.arena; let import_source: &'a [u8] = arena.alloc_slice_copy(p.options.jsx.import_source()); - let classic_import_source: &'a [u8] = - arena.alloc_slice_copy(&p.options.jsx.classic_import_source); let _ = p.add_import_record( - bun_ast::ImportKind::Require, + bun_ast::ImportKind::Stmt, bun_ast::Loc { start: 0 }, import_source, ); - // Ensure we have both classic and automatic - // This is to handle cases where they use fragments in the automatic runtime - let _ = p.add_import_record( - bun_ast::ImportKind::Require, - bun_ast::Loc { start: 0 }, - classic_import_source, - ); } scan_pass.approximate_newline_count = p.lexer.approximate_newline_count; diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index ac20af05dffd..ebc026ed7edc 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -1744,6 +1744,7 @@ impl JSTranspiler { }; let mut opts = bun_js_parser::ParserOptions::init(jsx, loader); + opts.features.auto_import_jsx = self.transpiler.get().options.auto_import_jsx; // SAFETY: see `transpiler_mut`. The `&mut Transpiler` is reborrowed // disjointly for `macro_context` (stored in `opts`) and `options.define` // (raw-addr read) below; both end when `opts` is consumed by `scan()`. diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 63746639a5bd..1e1cc46b2f04 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2272,6 +2272,39 @@ console.log(
);`), expect(new Bun.Transpiler({ ...opts, autoImportJSX: false }).scan("export default
;").imports).toEqual([]); expect(new Bun.Transpiler(opts).scan("export const x = 1;").imports).toEqual([]); }); + + // scanImports() used to unconditionally add a `require-call` record for + // `/jsx-dev-runtime` *and* a second one for the classic + // source, regardless of auto_import_jsx or the configured JSX runtime. + // It now mirrors the full-parse gate so it agrees with scan(). + describe(".scanImports() agrees with .scan() on the injected JSX runtime import", () => { + const dev = { loader: "tsx", define: { "process.env.NODE_ENV": JSON.stringify("development") } }; + const jsxDevRuntime = [{ kind: "import-statement", path: "react/jsx-dev-runtime" }]; + + it.each([ + ["automatic (dev)", dev, "export default
;", jsxDevRuntime], + ["automatic + fragment", dev, "export default <>
;", jsxDevRuntime], + [ + "automatic (prod)", + { loader: "tsx", tsconfig: { compilerOptions: { jsx: "react-jsx" } } }, + "export default
;", + [{ kind: "import-statement", path: "react/jsx-runtime" }], + ], + [ + "automatic + jsxImportSource", + { loader: "tsx", tsconfig: { compilerOptions: { jsx: "react-jsx", jsxImportSource: "preact" } } }, + "export default
;", + [{ kind: "import-statement", path: "preact/jsx-runtime" }], + ], + ["autoImportJSX: false", { ...dev, autoImportJSX: false }, "export default
;", []], + ["classic runtime", { loader: "tsx", tsconfig: { compilerOptions: { jsx: "react" } } }, "export default
;", []], + ["no JSX", dev, "export const x = 1;", []], + ])("%s", (_, opts, src, expected) => { + const t = new Bun.Transpiler(opts); + expect(t.scanImports(src)).toEqual(expected); + expect(t.scan(src).imports).toEqual(expected); + }); + }); }); it("JSX bare key prop followed by key with a value does not crash", async () => { From 3be402e97533b3d446dbabc255976fb0b68292fc Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:27:28 +0000 Subject: [PATCH 09/23] [autofix.ci] apply automated fixes --- test/bundler/transpiler/transpiler.test.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 1e1cc46b2f04..0f3b4b18309a 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2297,7 +2297,12 @@ console.log(
);`), [{ kind: "import-statement", path: "preact/jsx-runtime" }], ], ["autoImportJSX: false", { ...dev, autoImportJSX: false }, "export default
;", []], - ["classic runtime", { loader: "tsx", tsconfig: { compilerOptions: { jsx: "react" } } }, "export default
;", []], + [ + "classic runtime", + { loader: "tsx", tsconfig: { compilerOptions: { jsx: "react" } } }, + "export default
;", + [], + ], ["no JSX", dev, "export const x = 1;", []], ])("%s", (_, opts, src, expected) => { const t = new Bun.Transpiler(opts); From b5415800ef4426d58edb9c2e3ba2cb23cf3f9804 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:29:18 +0000 Subject: [PATCH 10/23] Trim multi-line comments; drop bug-history narration in tests --- src/js_parser/parse/parse_entry.rs | 2 -- test/bundler/transpiler/transpiler.test.js | 6 +----- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 3273ed4685a4..5ebd6d400c2d 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -481,8 +481,6 @@ impl<'a> Parser<'a> { } // Symbol use counts are unavailable, so "any JSX parsed?" is the proxy. - // Mirror the full-parse auto-import gate so scanImports() and scan() - // agree on the injected JSX runtime import. if p.options.jsx.parse && p.needs_jsx_import && p.options.features.auto_import_jsx diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 0f3b4b18309a..7408f502f9fa 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2273,11 +2273,7 @@ console.log(
);`), expect(new Bun.Transpiler(opts).scan("export const x = 1;").imports).toEqual([]); }); - // scanImports() used to unconditionally add a `require-call` record for - // `/jsx-dev-runtime` *and* a second one for the classic - // source, regardless of auto_import_jsx or the configured JSX runtime. - // It now mirrors the full-parse gate so it agrees with scan(). - describe(".scanImports() agrees with .scan() on the injected JSX runtime import", () => { + describe(".scanImports() reports the injected JSX runtime import", () => { const dev = { loader: "tsx", define: { "process.env.NODE_ENV": JSON.stringify("development") } }; const jsxDevRuntime = [{ kind: "import-statement", path: "react/jsx-dev-runtime" }]; From 3e1b0ea406a440b75617cc91f270bba14b840667 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:17:47 +0000 Subject: [PATCH 11/23] scanImports: honor // @jsxRuntime pragma when deciding whether to inject the runtime import --- src/js_parser/parse/parse_entry.rs | 9 ++++++++- test/bundler/transpiler/transpiler.test.js | 7 +++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 5ebd6d400c2d..19487e76816e 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -481,10 +481,17 @@ impl<'a> Parser<'a> { } // Symbol use counts are unavailable, so "any JSX parsed?" is the proxy. + let jsx_runtime = match p.lexer.jsx_pragma.jsx_runtime() { + Some(span) => options::JSX::RUNTIME_MAP + .get(span.text.slice()) + .map(|pair| pair.runtime) + .unwrap_or(p.options.jsx.runtime), + None => p.options.jsx.runtime, + }; if p.options.jsx.parse && p.needs_jsx_import && p.options.features.auto_import_jsx - && p.options.jsx.runtime == options::JSX::Runtime::Automatic + && jsx_runtime == options::JSX::Runtime::Automatic { // `add_import_record` requires `&'a [u8]`, but borrowing // `p.options` would conflict with `&mut p`, so copy into the arena. diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 7408f502f9fa..a7626a8c85c6 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2299,6 +2299,13 @@ console.log(
);`), "export default
;", [], ], + [ + "@jsxRuntime automatic over classic tsconfig", + { loader: "tsx", tsconfig: { compilerOptions: { jsx: "react" } } }, + "// @jsxRuntime automatic\nexport default
;", + jsxDevRuntime, + ], + ["@jsxRuntime classic over automatic", dev, "// @jsxRuntime classic\nexport default
;", []], ["no JSX", dev, "export const x = 1;", []], ])("%s", (_, opts, src, expected) => { const t = new Bun.Transpiler(opts); From 86436224ba85abbb23414f527f048ad3f0c90b9e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:37:46 +0000 Subject: [PATCH 12/23] scanImports: also apply the @jsxRuntime pragma's development flag; pin NODE_ENV in the pragma test rows --- src/js_parser/parse/parse_entry.rs | 17 +++++++++-------- test/bundler/transpiler/transpiler.test.js | 8 +++++++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 19487e76816e..f423c0ddc8a1 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -481,17 +481,18 @@ impl<'a> Parser<'a> { } // Symbol use counts are unavailable, so "any JSX parsed?" is the proxy. - let jsx_runtime = match p.lexer.jsx_pragma.jsx_runtime() { - Some(span) => options::JSX::RUNTIME_MAP - .get(span.text.slice()) - .map(|pair| pair.runtime) - .unwrap_or(p.options.jsx.runtime), - None => p.options.jsx.runtime, - }; + if let Some(span) = p.lexer.jsx_pragma.jsx_runtime() + && let Some(pair) = options::JSX::RUNTIME_MAP.get(span.text.slice()) + { + p.options.jsx.runtime = pair.runtime; + if let Some(dev) = pair.development { + p.options.jsx.development = dev; + } + } if p.options.jsx.parse && p.needs_jsx_import && p.options.features.auto_import_jsx - && jsx_runtime == options::JSX::Runtime::Automatic + && p.options.jsx.runtime == options::JSX::Runtime::Automatic { // `add_import_record` requires `&'a [u8]`, but borrowing // `p.options` would conflict with `&mut p`, so copy into the arena. diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index a7626a8c85c6..693f19e7c2c3 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2301,11 +2301,17 @@ console.log(
);`), ], [ "@jsxRuntime automatic over classic tsconfig", - { loader: "tsx", tsconfig: { compilerOptions: { jsx: "react" } } }, + { ...dev, tsconfig: { compilerOptions: { jsx: "react" } } }, "// @jsxRuntime automatic\nexport default
;", jsxDevRuntime, ], ["@jsxRuntime classic over automatic", dev, "// @jsxRuntime classic\nexport default
;", []], + [ + "@jsxRuntime react-jsxdev over react-jsx tsconfig", + { loader: "tsx", tsconfig: { compilerOptions: { jsx: "react-jsx" } } }, + "// @jsxRuntime react-jsxdev\nexport default
;", + jsxDevRuntime, + ], ["no JSX", dev, "export const x = 1;", []], ])("%s", (_, opts, src, expected) => { const t = new Bun.Transpiler(opts); From a1f37750ee42925db7b75754b44a6be1547a0607 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:51:31 +0000 Subject: [PATCH 13/23] Move the needs_jsx_import comment back next to the gate it annotates --- src/js_parser/parse/parse_entry.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index f423c0ddc8a1..7fdf4faf209e 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -480,7 +480,6 @@ impl<'a> Parser<'a> { } } - // Symbol use counts are unavailable, so "any JSX parsed?" is the proxy. if let Some(span) = p.lexer.jsx_pragma.jsx_runtime() && let Some(pair) = options::JSX::RUNTIME_MAP.get(span.text.slice()) { @@ -489,6 +488,7 @@ impl<'a> Parser<'a> { p.options.jsx.development = dev; } } + // Symbol use counts are unavailable, so "any JSX parsed?" is the proxy. if p.options.jsx.parse && p.needs_jsx_import && p.options.features.auto_import_jsx From 2f2125e73a65cebef62c32c32acd3d114ac67e09 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:39:13 +0000 Subject: [PATCH 14/23] scanImports: apply the @jsxImportSource pragma; generalize the mdx option note --- docs/runtime/transpiler.mdx | 6 ++++-- src/js_parser/parse/parse_entry.rs | 6 ++++++ test/bundler/transpiler/transpiler.test.js | 7 +++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/runtime/transpiler.mdx b/docs/runtime/transpiler.mdx index 993865c88678..c1e215cd75f4 100644 --- a/docs/runtime/transpiler.mdx +++ b/docs/runtime/transpiler.mdx @@ -217,8 +217,10 @@ interface TranspilerOptions { // Default: false trimUnusedImports?: boolean, - // Whether to prepend the automatic JSX runtime import - // (`import { jsx } from "react/jsx-runtime"`) when JSX is used + // Whether to prepend the automatic JSX runtime import when JSX is used, + // e.g. `import { jsx } from "react/jsx-runtime"` in production or + // `import { jsxDEV } from "react/jsx-dev-runtime"` in development, + // resolved against the configured jsxImportSource // Default: true autoImportJSX?: boolean, diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index a39b07d6e8be..f4daab207d95 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -485,6 +485,12 @@ impl<'a> Parser<'a> { } } + if let Some(import_source) = p.lexer.jsx_pragma.jsx_import_source() { + let text = import_source.text.slice(); + p.options.jsx.classic_import_source = text.to_vec().into(); + p.options.jsx.package_name = p.options.jsx.classic_import_source.clone(); + p.options.jsx.set_import_source(); + } if let Some(span) = p.lexer.jsx_pragma.jsx_runtime() && let Some(pair) = options::JSX::RUNTIME_MAP.get(span.text.slice()) { diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index c7e43de632fc..37a37ba85f39 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2430,6 +2430,12 @@ console.log(
);`), "// @jsxRuntime react-jsxdev\nexport default
;", jsxDevRuntime, ], + [ + "@jsxImportSource pragma", + dev, + "/** @jsxImportSource preact */\nexport default
;", + [{ kind: "import-statement", path: "preact/jsx-dev-runtime" }], + ], ["no JSX", dev, "export const x = 1;", []], ])("%s", (_, opts, src, expected) => { const t = new Bun.Transpiler(opts); @@ -2619,6 +2625,7 @@ console.log(
);`), define: { "process.env.NODE_ENV": JSON.stringify("development"), }, + autoImportJSX: false, }); for (const [tag, expected] of [ ["Foo-Bar", `"Foo-Bar"`], From ab74d9b8c75d418321cc38718d2082629189099e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:10:36 +0000 Subject: [PATCH 15/23] test: pin autoImportJSX: false where a define test asserts the output without the import --- test/bundler/transpiler/transpiler.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 40ffa50621da..79abe3fe1e8c 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2706,7 +2706,7 @@ export default <>hi // The automatic JSX runtime passes a lone spread object directly as props. describe("define with an object value", () => { it("prints the full object at every use after JSX spreads it", () => { - const transpiler = new Bun.Transpiler({ loader: "jsx", define: { OBJ: '{"a":1}' } }); + const transpiler = new Bun.Transpiler({ loader: "jsx", define: { OBJ: '{"a":1}' }, autoImportJSX: false }); const source = [ `export const before = OBJ;`, `export const spread =
;`, From 6b4af7d8a8ff95bda04c0b3a9870fb1a885c3776 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:09:34 +0000 Subject: [PATCH 16/23] js_parser: copy the JSX import paths only when an import is emitted A file with no JSX no longer allocates the two path copies. --- src/js_parser/parse/parse_entry.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 005e26fd4e0c..25bc630e2c9e 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -2335,14 +2335,14 @@ impl<'a> Parser<'a> { // out via `take` (it is `Default`) to avoid an overlapping `&self.jsx_imports` // borrow. The callee never reads `self.jsx_imports`, so the take/restore is // semantically a no-op. - let import_source: &'a [u8] = p.arena.alloc_slice_copy(p.options.jsx.import_source()); - let package_name: &'a [u8] = p.arena.alloc_slice_copy(&p.options.jsx.package_name); let jsx_imports = core::mem::take(&mut p.jsx_imports); let mut buf: [&'static [u8]; 3] = [b"", b"", b""]; let runtime_import_names = jsx_imports.runtime_import_names(&mut buf); if !runtime_import_names.is_empty() { + let import_source: &'a [u8] = + p.arena.alloc_slice_copy(p.options.jsx.import_source()); p.generate_import_stmt( import_source, runtime_import_names, @@ -2358,6 +2358,7 @@ impl<'a> Parser<'a> { let source_import_names = jsx_imports.source_import_names(); if !source_import_names.is_empty() { + let package_name: &'a [u8] = p.arena.alloc_slice_copy(&p.options.jsx.package_name); p.generate_import_stmt( package_name, source_import_names, From c22b04a7e7fd7a22030998e96c5ca74adb9d5173 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:09:36 +0000 Subject: [PATCH 17/23] docs: the JSX runtime import is added, its position is not part of the option --- docs/runtime/transpiler.mdx | 2 +- packages/bun-types/bun.d.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/runtime/transpiler.mdx b/docs/runtime/transpiler.mdx index dc4e560de4e1..8b5256983821 100644 --- a/docs/runtime/transpiler.mdx +++ b/docs/runtime/transpiler.mdx @@ -217,7 +217,7 @@ interface TranspilerOptions { // Default: false trimUnusedImports?: boolean, - // Whether to prepend the automatic JSX runtime import when JSX is used, + // Whether to add the automatic JSX runtime import when JSX is used, // e.g. `import { jsx } from "react/jsx-runtime"` in production or // `import { jsxDEV } from "react/jsx-dev-runtime"` in development, // resolved against the configured jsxImportSource diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index eecac4caa8bb..255ecefc26b9 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -3025,7 +3025,7 @@ declare module "bun" { macro?: MacroMap; /** - * When the automatic JSX runtime is in use, prepend an import for the + * When the automatic JSX runtime is in use, add an import for the * bindings the transformed output actually uses (some of `jsx`, `jsxs`, * `Fragment` from `"/jsx-runtime"`, or `jsxDEV` from * `jsx-dev-runtime`) so the output can run standalone. Set `false` to From b6ea63e3416d5fa403a93b681ec50e9f3bed882d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:53:05 +0000 Subject: [PATCH 18/23] js_parser: report the createElement fallback from the scan pass A key prop after a spread makes the visit pass import createElement from the package instead of the runtime module. The scan pass now records which of the two imports the output has, so .scan() and .scanImports() list the modules the output imports, in the same order. merge_jsx copies package_name with import_source. A tsconfig with jsxImportSource now sends the createElement fallback to that package, for Bun.Transpiler and for a nested tsconfig in bun build. Remove classic_import_source from the JSX pragma. It always held the value of package_name and the scan pass was its last reader. --- src/js_parser/p.rs | 7 +++-- src/js_parser/parse/parse_entry.rs | 27 +++++++++++++------- src/js_parser/parse/parse_jsx.rs | 13 +++++++--- src/options_types/jsx.rs | 4 --- src/resolver/tsconfig_json.rs | 1 + src/runtime/api/js_bundle_completion_task.rs | 5 ---- 6 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 0771f6bbe8b5..9625fbdd470a 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -490,6 +490,9 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { // "Did we parse anything that looked like JSX"? // If yes, then automatically add the JSX import. pub(crate) needs_jsx_import: NeedsJSXType, + // Scan pass only. A `key` prop after a `{...spread}` makes the visit pass + // call `createElement` from the package instead of the runtime module. + pub(crate) needs_jsx_create_element_import: NeedsJSXType, // The parser does two passes and we need to pass the scope tree information // from the first pass to the second pass. That's done by tracking the calls @@ -3271,8 +3274,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if let Some(import_source) = self.lexer.jsx_pragma.jsx_import_source() { // SAFETY: Span.text is `ArenaStr` valid for 'a. let text = import_source.text.slice(); - self.options.jsx.classic_import_source = text.to_vec().into(); - self.options.jsx.package_name = self.options.jsx.classic_import_source.clone(); + self.options.jsx.package_name = text.to_vec().into(); self.options.jsx.set_import_source(); } @@ -9690,6 +9692,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O module_scope: scope, scopes_in_order: scope_order, needs_jsx_import: false, + needs_jsx_create_element_import: false, lexer, commonjs_named_exports_deoptimized, diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 25bc630e2c9e..d08436c5128e 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -651,8 +651,7 @@ impl<'a> Parser<'a> { if let Some(import_source) = p.lexer.jsx_pragma.jsx_import_source() { let text = import_source.text.slice(); - p.options.jsx.classic_import_source = text.to_vec().into(); - p.options.jsx.package_name = p.options.jsx.classic_import_source.clone(); + p.options.jsx.package_name = text.to_vec().into(); p.options.jsx.set_import_source(); } if let Some(span) = p.lexer.jsx_pragma.jsx_runtime() @@ -664,20 +663,30 @@ impl<'a> Parser<'a> { } } // Symbol use counts are unavailable, so "any JSX parsed?" is the proxy. + // The records follow the order of the imports that the visit pass emits. if p.options.jsx.parse - && p.needs_jsx_import && p.options.features.auto_import_jsx && p.options.jsx.runtime == options::JSX::Runtime::Automatic { // `add_import_record` requires `&'a [u8]`, but borrowing // `p.options` would conflict with `&mut p`, so copy into the arena. let arena = p.arena; - let import_source: &'a [u8] = arena.alloc_slice_copy(p.options.jsx.import_source()); - let _ = p.add_import_record( - bun_ast::ImportKind::Stmt, - bun_ast::Loc { start: 0 }, - import_source, - ); + if p.needs_jsx_import { + let import_source: &'a [u8] = arena.alloc_slice_copy(p.options.jsx.import_source()); + let _ = p.add_import_record( + bun_ast::ImportKind::Stmt, + bun_ast::Loc { start: 0 }, + import_source, + ); + } + if p.needs_jsx_create_element_import { + let package_name: &'a [u8] = arena.alloc_slice_copy(&p.options.jsx.package_name); + let _ = p.add_import_record( + bun_ast::ImportKind::Stmt, + bun_ast::Loc { start: 0 }, + package_name, + ); + } } Ok(()) diff --git a/src/js_parser/parse/parse_jsx.rs b/src/js_parser/parse/parse_jsx.rs index 9a6c1866a790..0235878b1129 100644 --- a/src/js_parser/parse/parse_jsx.rs +++ b/src/js_parser/parse/parse_jsx.rs @@ -16,10 +16,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if !p.stack_check.is_safe_to_recurse() { return Err(crate::Error::StackOverflow); } - if SCAN_ONLY { - p.needs_jsx_import = true; - } - let tag = JSXTag::parse(p)?; // The tag may have TypeScript type arguments: "/>" @@ -219,6 +215,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if is_key_after_spread { flags.insert(flags::JSXElement::IsKeyAfterSpread); } + if SCAN_ONLY { + if is_key_after_spread { + p.needs_jsx_create_element_import = true; + } else { + p.needs_jsx_import = true; + } + } properties = G::PropertyList::move_from_list(props); if is_key_after_spread && p.options.jsx.runtime == options::JSXRuntime::Automatic @@ -231,6 +234,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ); p.has_classic_runtime_warned = true; } + } else if SCAN_ONLY { + p.needs_jsx_import = true; } // People sometimes try to use the output of "JSON.stringify()" as a JSX diff --git a/src/options_types/jsx.rs b/src/options_types/jsx.rs index 0cda8d160553..3f63e0c591db 100644 --- a/src/options_types/jsx.rs +++ b/src/options_types/jsx.rs @@ -162,7 +162,6 @@ pub struct Pragma { /// Facilitates automatic JSX importing /// Set on a per file basis like this: /// /** @jsxImportSource @emotion/core */ - pub classic_import_source: Cow<'static, [u8]>, pub package_name: Cow<'static, [u8]>, /// Configuration Priority: @@ -182,7 +181,6 @@ impl Default for Pragma { fragment: MemberList::Static(defaults::FRAGMENT), runtime: Runtime::Automatic, import_source: ImportSource::default(), - classic_import_source: Cow::Borrowed(b"react"), package_name: Cow::Borrowed(b"react"), development: true, parse: true, @@ -203,7 +201,6 @@ impl Pragma { } hasher.update(&self.import_source.development); hasher.update(&self.import_source.production); - hasher.update(&self.classic_import_source); hasher.update(&self.package_name); // `runtime` selects classic vs automatic emission; `development` // selects `jsx` vs `jsxDEV`. Both shape transpiled output. @@ -287,7 +284,6 @@ impl Pragma { if !jsx.import_source.is_empty() { pragma.package_name = Cow::Owned(jsx.import_source.into_vec()); pragma.set_import_source(); - pragma.classic_import_source = pragma.package_name.clone(); } pragma.development = jsx.development; diff --git a/src/resolver/tsconfig_json.rs b/src/resolver/tsconfig_json.rs index aab471671832..ba42f9051963 100644 --- a/src/resolver/tsconfig_json.rs +++ b/src/resolver/tsconfig_json.rs @@ -241,6 +241,7 @@ impl TSConfigJSON { if self.jsx_flags.contains(JsxField::ImportSource) { out.import_source = self.jsx.import_source.clone(); + out.package_name = self.jsx.package_name.clone(); } if self.jsx_flags.contains(JsxField::Runtime) { diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index b57a80b54751..027e54102cba 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -1133,11 +1133,6 @@ impl CompletionStruct for JSBundleCompletionTask { } else { std::borrow::Cow::Borrowed(b"react".as_slice()) }, - classic_import_source: if !jsx_import.is_empty() { - std::borrow::Cow::Owned(jsx_import.to_vec()) - } else { - std::borrow::Cow::Borrowed(b"react".as_slice()) - }, side_effects: config.jsx.side_effects, parse: true, import_source: options::jsx::ImportSource { From 02006ca4a05c4535a2f45f4a92d1b02c89fde5ed Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:53:07 +0000 Subject: [PATCH 19/23] Bun.Transpiler: scan and scanImports use the logLevel of the transpiler Both built a fresh Log at the default level, so logLevel: "error" did not stop a warning from throwing. transform and transformSync already used the configured level. --- src/bundler/cache.rs | 1 + src/runtime/api/JSTranspiler.rs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/bundler/cache.rs b/src/bundler/cache.rs index d65b7ad84426..cda055d978e8 100644 --- a/src/bundler/cache.rs +++ b/src/bundler/cache.rs @@ -126,6 +126,7 @@ impl JavaScript { } let mut temp_log = bun_ast::Log::init(); + temp_log.level = log.level; // scopeguard cannot capture &mut temp_log while it's used below; // explicit `append_to` calls at each exit. diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index 8c615a29d48b..454f4f2f97ed 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -1240,6 +1240,7 @@ impl JSTranspiler { let arena = Arena::new(); let mut log = bun_ast::Log::init(); + log.level = self.config.get().log.level; // SAFETY: `arena` outlives every use through `self.transpiler` in this fn body; // `_restore` (declared after `arena`/`log`, so dropped first) restores // `prev_arena` and `&self.config.log` before either local drops. @@ -1534,6 +1535,7 @@ impl JSTranspiler { let arena = Arena::new(); let mut log = bun_ast::Log::init(); + log.level = self.config.get().log.level; // SAFETY: `arena` outlives every use through `self.transpiler` in this fn body; // `_restore` (declared after `arena`/`log`, so dropped first) restores // `prev_arena` and `&self.config.log` before either local drops. From d2632a6f10ee464eeb4560ccfeb5c0c1455e3c75 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:53:08 +0000 Subject: [PATCH 20/23] test: scan records and tsconfig package for the createElement fallback --- test/bundler/transpiler/transpiler.test.js | 60 ++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 79abe3fe1e8c..ef92608a8f5c 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2960,6 +2960,17 @@ console.log(
);`), expect(out).toMatch(/^import { createElement as (\w+) } from "react";\nexport default \1\("div",/); }); + it("key-after-spread imports createElement from the tsconfig jsxImportSource", () => { + const out = new Bun.Transpiler({ + loader: "tsx", + tsconfig: { compilerOptions: { jsx: "react-jsx", jsxImportSource: "preact" } }, + logLevel: "error", + }).transformSync(`export default
;`); + expect(out).toMatch( + /^import { jsx as (\w+) } from "preact\/jsx-runtime";\nimport { createElement as (\w+) } from "preact";\nexport default \2\("div",/, + ); + }); + it("does not affect the classic runtime", () => { const out = new Bun.Transpiler({ loader: "tsx", @@ -2988,6 +2999,17 @@ console.log(
);`), expect(new Bun.Transpiler(opts).scan("export const x = 1;").imports).toEqual([]); }); + it(".scan() and .scanImports() honour logLevel like .transformSync()", () => { + const src = `export default
;`; + const loud = new Bun.Transpiler({ loader: "tsx" }); + expect(() => loud.scan(src)).toThrow('"key" prop after a {...spread} is deprecated in JSX'); + expect(() => loud.scanImports(src)).toThrow('"key" prop after a {...spread} is deprecated in JSX'); + + const quiet = new Bun.Transpiler({ loader: "tsx", logLevel: "error" }); + expect(quiet.scan(src).imports).toEqual([{ kind: "import-statement", path: "react" }]); + expect(quiet.scanImports(src)).toEqual([{ kind: "import-statement", path: "react" }]); + }); + describe(".scanImports() reports the injected JSX runtime import", () => { const dev = { loader: "tsx", define: { "process.env.NODE_ENV": JSON.stringify("development") } }; const jsxDevRuntime = [{ kind: "import-statement", path: "react/jsx-dev-runtime" }]; @@ -3034,6 +3056,44 @@ console.log(
);`), [{ kind: "import-statement", path: "preact/jsx-dev-runtime" }], ], ["no JSX", dev, "export const x = 1;", []], + // A key after a spread falls back to createElement from the package. + [ + "createElement fallback alone", + { ...dev, logLevel: "error" }, + `export default
;`, + [{ kind: "import-statement", path: "react" }], + ], + [ + "createElement fallback with a child element", + { ...dev, logLevel: "error" }, + `export default
;`, + [...jsxDevRuntime, { kind: "import-statement", path: "react" }], + ], + [ + "createElement fallback with jsxImportSource", + { + loader: "tsx", + tsconfig: { compilerOptions: { jsx: "react-jsx", jsxImportSource: "preact" } }, + logLevel: "error", + }, + `export default
;`, + [ + { kind: "import-statement", path: "preact/jsx-runtime" }, + { kind: "import-statement", path: "preact" }, + ], + ], + [ + "createElement fallback with @jsxImportSource pragma", + { ...dev, logLevel: "error" }, + `/** @jsxImportSource preact */\nexport default
;`, + [{ kind: "import-statement", path: "preact" }], + ], + [ + "createElement fallback under the classic runtime", + { loader: "tsx", tsconfig: { compilerOptions: { jsx: "react" } }, logLevel: "error" }, + `export default
;`, + [], + ], ])("%s", (_, opts, src, expected) => { const t = new Bun.Transpiler(opts); expect(t.scanImports(src)).toEqual(expected); From 0ce60616b01a11a89b67f4727801571df8f4bfd6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:39:00 +0000 Subject: [PATCH 21/23] js_parser: one apply_jsx_pragmas for the visit pass and the scan pass The scan pass had a trimmed copy of the pragma code. It skipped the factory and fragment pragmas and the warning for an unsupported runtime, so scanImports accepted "@jsxRuntime preserve" while transformSync and scan rejected it. --- src/js_parser/p.rs | 104 +++++++++++++++-------------- src/js_parser/parse/parse_entry.rs | 14 +--- 2 files changed, 56 insertions(+), 62 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 9625fbdd470a..45a18483601d 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -3190,55 +3190,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - pub(crate) fn prepare_for_visit_pass(&mut self) -> Result<(), crate::Error> { - { - // The wrapper stores only the arena and a non-capturing - // fn-pointer trampoline; the `*mut P` context is supplied *at call - // time* (see `Binding::to_expr`) so the raw pointer's provenance is - // a child of the live `&mut P` at the call site rather than a stale - // tag captured here. The transposer shims need no wiring at all — - // call sites invoke `P::maybe_transpose_if_*` etc. directly. - self.to_expr_wrapper_namespace = - bun_ast::binding::ToExprWrapper::new(self.arena, |ctx, loc, ref_| { - // SAFETY: `ctx` was derived from the caller's live `&mut P` - // immediately before `Binding::to_expr`; no other `&mut P` - // borrow is active for the duration of this call. - let p = unsafe { &mut *ctx.cast::>() }; - p.wrap_identifier_namespace(loc, ref_) - }); - self.to_expr_wrapper_hoisted = - bun_ast::binding::ToExprWrapper::new(self.arena, |ctx, loc, ref_| { - // SAFETY: same as above. - let p = unsafe { &mut *ctx.cast::>() }; - p.wrap_identifier_hoisting(loc, ref_) - }); - } - - { - // Compact `scopes_in_order` (parse pass leaves None holes from - // popAndDiscardScope) into a dense bump-slice for the visit pass. - let mut buf = - BumpVec::>::with_capacity_in(self.scopes_in_order.len(), self.arena); - for item_ in self.scopes_in_order.iter().flatten() { - buf.push(*item_); - } - // `into_bump_slice()` leaks the BumpVec into the arena and returns - // a `&'a [T]` for that allocation. - self.scope_order_to_visit = buf.into_bump_slice(); - } - - self.is_file_considered_to_have_esm_exports = !self.top_level_await_keyword.is_empty() - || !self.esm_export_keyword.is_empty() - || self.options.module_type == options::ModuleType::Esm; - - self.push_scope_for_visit_pass(js_ast::scope::Kind::Entry, loc_module_scope)?; - self.fn_or_arrow_data_visit.is_outside_fn_or_arrow = true; - self.module_scope = self.current_scope; - self.has_es_module_syntax = self.has_es_module_syntax - || self.esm_import_keyword.len > 0 - || self.esm_export_keyword.len > 0 - || self.top_level_await_keyword.len > 0; - + /// Applies the `@jsx*` pragmas of the file to `options.jsx`. The visit pass + /// and the scan pass both call it, so both see the same runtime and import source. + pub(crate) fn apply_jsx_pragmas(&mut self) { if let Some(factory) = self.lexer.jsx_pragma.jsx() { // `Span.text` is a `StoreStr` into lexer-owned source; valid for 'a. let text = factory.text.slice(); @@ -3295,6 +3249,58 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ); } } + } + + pub(crate) fn prepare_for_visit_pass(&mut self) -> Result<(), crate::Error> { + { + // The wrapper stores only the arena and a non-capturing + // fn-pointer trampoline; the `*mut P` context is supplied *at call + // time* (see `Binding::to_expr`) so the raw pointer's provenance is + // a child of the live `&mut P` at the call site rather than a stale + // tag captured here. The transposer shims need no wiring at all — + // call sites invoke `P::maybe_transpose_if_*` etc. directly. + self.to_expr_wrapper_namespace = + bun_ast::binding::ToExprWrapper::new(self.arena, |ctx, loc, ref_| { + // SAFETY: `ctx` was derived from the caller's live `&mut P` + // immediately before `Binding::to_expr`; no other `&mut P` + // borrow is active for the duration of this call. + let p = unsafe { &mut *ctx.cast::>() }; + p.wrap_identifier_namespace(loc, ref_) + }); + self.to_expr_wrapper_hoisted = + bun_ast::binding::ToExprWrapper::new(self.arena, |ctx, loc, ref_| { + // SAFETY: same as above. + let p = unsafe { &mut *ctx.cast::>() }; + p.wrap_identifier_hoisting(loc, ref_) + }); + } + + { + // Compact `scopes_in_order` (parse pass leaves None holes from + // popAndDiscardScope) into a dense bump-slice for the visit pass. + let mut buf = + BumpVec::>::with_capacity_in(self.scopes_in_order.len(), self.arena); + for item_ in self.scopes_in_order.iter().flatten() { + buf.push(*item_); + } + // `into_bump_slice()` leaks the BumpVec into the arena and returns + // a `&'a [T]` for that allocation. + self.scope_order_to_visit = buf.into_bump_slice(); + } + + self.is_file_considered_to_have_esm_exports = !self.top_level_await_keyword.is_empty() + || !self.esm_export_keyword.is_empty() + || self.options.module_type == options::ModuleType::Esm; + + self.push_scope_for_visit_pass(js_ast::scope::Kind::Entry, loc_module_scope)?; + self.fn_or_arrow_data_visit.is_outside_fn_or_arrow = true; + self.module_scope = self.current_scope; + self.has_es_module_syntax = self.has_es_module_syntax + || self.esm_import_keyword.len > 0 + || self.esm_export_keyword.len > 0 + || self.top_level_await_keyword.len > 0; + + self.apply_jsx_pragmas(); // ECMAScript modules are always interpreted as strict mode. This has to be // done before "hoistSymbols" because strict mode can alter hoisting (!). diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index d08436c5128e..c4fe2efc97be 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -649,19 +649,7 @@ impl<'a> Parser<'a> { } } - if let Some(import_source) = p.lexer.jsx_pragma.jsx_import_source() { - let text = import_source.text.slice(); - p.options.jsx.package_name = text.to_vec().into(); - p.options.jsx.set_import_source(); - } - if let Some(span) = p.lexer.jsx_pragma.jsx_runtime() - && let Some(pair) = options::JSX::RUNTIME_MAP.get(span.text.slice()) - { - p.options.jsx.runtime = pair.runtime; - if let Some(dev) = pair.development { - p.options.jsx.development = dev; - } - } + p.apply_jsx_pragmas(); // Symbol use counts are unavailable, so "any JSX parsed?" is the proxy. // The records follow the order of the imports that the visit pass emits. if p.options.jsx.parse From 1d049c2623993dc0ff65c2bfe371a0dad8547793 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:39:03 +0000 Subject: [PATCH 22/23] test: scan records match the imports of transformSync for every row --- test/bundler/transpiler/transpiler.test.js | 24 +++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index ef92608a8f5c..b7aa3fd42cc6 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2999,13 +2999,11 @@ console.log(
);`), expect(new Bun.Transpiler(opts).scan("export const x = 1;").imports).toEqual([]); }); - it(".scan() and .scanImports() honour logLevel like .transformSync()", () => { + it(".scan() and .scanImports() use logLevel like .transformSync()", () => { + // The key after the spread logs a warning. logLevel: "error" drops it. const src = `export default
;`; - const loud = new Bun.Transpiler({ loader: "tsx" }); - expect(() => loud.scan(src)).toThrow('"key" prop after a {...spread} is deprecated in JSX'); - expect(() => loud.scanImports(src)).toThrow('"key" prop after a {...spread} is deprecated in JSX'); - const quiet = new Bun.Transpiler({ loader: "tsx", logLevel: "error" }); + expect(quiet.transformSync(src)).toContain('from "react";'); expect(quiet.scan(src).imports).toEqual([{ kind: "import-statement", path: "react" }]); expect(quiet.scanImports(src)).toEqual([{ kind: "import-statement", path: "react" }]); }); @@ -3055,6 +3053,19 @@ console.log(
);`), "/** @jsxImportSource preact */\nexport default
;", [{ kind: "import-statement", path: "preact/jsx-dev-runtime" }], ], + [ + "@jsxImportSource pragma over tsconfig jsxImportSource", + { loader: "tsx", tsconfig: { compilerOptions: { jsx: "react-jsx", jsxImportSource: "preact" } } }, + "/** @jsxImportSource solid-js */\nexport default
;", + [{ kind: "import-statement", path: "solid-js/jsx-runtime" }], + ], + // "preserve" is not supported. Both passes warn and keep the configured runtime. + [ + "@jsxRuntime preserve", + { ...dev, logLevel: "error" }, + "// @jsxRuntime preserve\nexport default
;", + jsxDevRuntime, + ], ["no JSX", dev, "export const x = 1;", []], // A key after a spread falls back to createElement from the package. [ @@ -3098,6 +3109,9 @@ console.log(
);`), const t = new Bun.Transpiler(opts); expect(t.scanImports(src)).toEqual(expected); expect(t.scan(src).imports).toEqual(expected); + // The scan records name the modules that the output imports, in order. + const imported = [...t.transformSync(src).matchAll(/^import .* from "([^"]+)";$/gm)].map(m => m[1]); + expect(imported).toEqual(expected.map(r => r.path)); }); }); }); From 28b0e3069e63f1829904e3e4b2a1459b54e4dedf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:32:17 +0000 Subject: [PATCH 23/23] js_parser: place apply_jsx_pragmas after prepare_for_visit_pass, shorter comments --- src/js_parser/p.rs | 124 ++++++++++++++--------------- src/js_parser/parse/parse_entry.rs | 3 +- 2 files changed, 62 insertions(+), 65 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 45a18483601d..fe44208b3723 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -490,8 +490,7 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { // "Did we parse anything that looked like JSX"? // If yes, then automatically add the JSX import. pub(crate) needs_jsx_import: NeedsJSXType, - // Scan pass only. A `key` prop after a `{...spread}` makes the visit pass - // call `createElement` from the package instead of the runtime module. + // Scan pass only: a `key` after a `{...spread}` imports `createElement` from the package. pub(crate) needs_jsx_create_element_import: NeedsJSXType, // The parser does two passes and we need to pass the scope tree information @@ -3190,67 +3189,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - /// Applies the `@jsx*` pragmas of the file to `options.jsx`. The visit pass - /// and the scan pass both call it, so both see the same runtime and import source. - pub(crate) fn apply_jsx_pragmas(&mut self) { - if let Some(factory) = self.lexer.jsx_pragma.jsx() { - // `Span.text` is a `StoreStr` into lexer-owned source; valid for 'a. - let text = factory.text.slice(); - match options::JSX::Pragma::member_list_to_components_if_different( - &self.options.jsx.factory, - text, - ) { - Some(members) => self.options.jsx.factory = members, - None => self.log().add_range_warning_fmt( - Some(self.source), - factory.range, - format_args!("Invalid JSX factory: \"{}\"", bstr::BStr::new(text)), - ), - } - } - - if let Some(fragment) = self.lexer.jsx_pragma.jsx_frag() { - // SAFETY: Span.text is `ArenaStr` valid for 'a. - let text = fragment.text.slice(); - match options::JSX::Pragma::member_list_to_components_if_different( - &self.options.jsx.fragment, - text, - ) { - Some(members) => self.options.jsx.fragment = members, - None => self.log().add_range_warning_fmt( - Some(self.source), - fragment.range, - format_args!("Invalid JSX fragment: \"{}\"", bstr::BStr::new(text)), - ), - } - } - - if let Some(import_source) = self.lexer.jsx_pragma.jsx_import_source() { - // SAFETY: Span.text is `ArenaStr` valid for 'a. - let text = import_source.text.slice(); - self.options.jsx.package_name = text.to_vec().into(); - self.options.jsx.set_import_source(); - } - - if let Some(runtime) = self.lexer.jsx_pragma.jsx_runtime() { - // SAFETY: Span.text is `ArenaStr` valid for 'a. - let text = runtime.text.slice(); - if let Some(jsx_runtime) = options::JSX::RUNTIME_MAP.get(text) { - self.options.jsx.runtime = jsx_runtime.runtime; - if let Some(dev) = jsx_runtime.development { - self.options.jsx.development = dev; - } - } else { - // make this a warning instead of an error because we don't support "preserve" right now - self.log().add_range_warning_fmt( - Some(self.source), - runtime.range, - format_args!("Unsupported JSX runtime: \"{}\"", bstr::BStr::new(text)), - ); - } - } - } - pub(crate) fn prepare_for_visit_pass(&mut self) -> Result<(), crate::Error> { { // The wrapper stores only the arena and a non-capturing @@ -3403,6 +3341,66 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Ok(()) } + /// The visit pass and the scan pass both call this, so both see the same runtime and import source. + pub(crate) fn apply_jsx_pragmas(&mut self) { + if let Some(factory) = self.lexer.jsx_pragma.jsx() { + // `Span.text` is a `StoreStr` into lexer-owned source; valid for 'a. + let text = factory.text.slice(); + match options::JSX::Pragma::member_list_to_components_if_different( + &self.options.jsx.factory, + text, + ) { + Some(members) => self.options.jsx.factory = members, + None => self.log().add_range_warning_fmt( + Some(self.source), + factory.range, + format_args!("Invalid JSX factory: \"{}\"", bstr::BStr::new(text)), + ), + } + } + + if let Some(fragment) = self.lexer.jsx_pragma.jsx_frag() { + // SAFETY: Span.text is `ArenaStr` valid for 'a. + let text = fragment.text.slice(); + match options::JSX::Pragma::member_list_to_components_if_different( + &self.options.jsx.fragment, + text, + ) { + Some(members) => self.options.jsx.fragment = members, + None => self.log().add_range_warning_fmt( + Some(self.source), + fragment.range, + format_args!("Invalid JSX fragment: \"{}\"", bstr::BStr::new(text)), + ), + } + } + + if let Some(import_source) = self.lexer.jsx_pragma.jsx_import_source() { + // SAFETY: Span.text is `ArenaStr` valid for 'a. + let text = import_source.text.slice(); + self.options.jsx.package_name = text.to_vec().into(); + self.options.jsx.set_import_source(); + } + + if let Some(runtime) = self.lexer.jsx_pragma.jsx_runtime() { + // SAFETY: Span.text is `ArenaStr` valid for 'a. + let text = runtime.text.slice(); + if let Some(jsx_runtime) = options::JSX::RUNTIME_MAP.get(text) { + self.options.jsx.runtime = jsx_runtime.runtime; + if let Some(dev) = jsx_runtime.development { + self.options.jsx.development = dev; + } + } else { + // make this a warning instead of an error because we don't support "preserve" right now + self.log().add_range_warning_fmt( + Some(self.source), + runtime.range, + format_args!("Unsupported JSX runtime: \"{}\"", bstr::BStr::new(text)), + ); + } + } + } + fn ensure_require_symbol(&mut self) { if !self.runtime_imports.__require.is_empty() { return; diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index c4fe2efc97be..181650f8c545 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -650,8 +650,7 @@ impl<'a> Parser<'a> { } p.apply_jsx_pragmas(); - // Symbol use counts are unavailable, so "any JSX parsed?" is the proxy. - // The records follow the order of the imports that the visit pass emits. + // No symbol use counts here: one record per import the visit pass emits, in its order. if p.options.jsx.parse && p.options.features.auto_import_jsx && p.options.jsx.runtime == options::JSX::Runtime::Automatic