From 5148743f24f631b65672945eefff7eb86c8cbe45 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:36:51 +0000 Subject: [PATCH 1/6] bundler: keep the cwd tsconfig.json out of the per-file JSX base The resolver computes each file's JSX pragma by merging the file's enclosing tsconfig.json over resolver.opts.jsx. sync_resolver_opts() and Transpiler::for_worker re-projected that base from options.jsx after configure_linker() / run_env_loader() had merged the cwd tsconfig.json into it, so the cwd tsconfig's jsxImportSource, jsxFactory, jsxFragmentFactory and "jsx" filled in whatever a nested tsconfig.json left unset, and files outside of every tsconfig got the cwd's settings. This affected `bun build`, `bun build --no-bundle`, anything built with --env / env: "inline" (including Bun.build()), and HTML entry points of server-side builds, while a default Bun.build() was unaffected, so the same file bundled differently between the CLI and the JS API. resolver_bundle_options_subset() now takes the base pragma explicitly: Transpiler::init passes the freshly built user configuration, and the two re-projection sites carry the pragma the resolver already holds. TSConfigJSON::merge_jsx copies package_name along with import_source: the key-after-spread createElement fallback is imported from package_name, and until now the CLI only got that right because the cwd tsconfig was copied over the pragma wholesale. --- src/bundler/transpiler.rs | 22 +- src/resolver/options.rs | 2 + src/resolver/tsconfig_json.rs | 2 + .../transpiler/jsx-tsconfig-react-jsx.test.ts | 212 ++++++++++++++++++ 4 files changed, 233 insertions(+), 5 deletions(-) diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index c0b455f7fa54..57aa19de70c7 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -325,7 +325,8 @@ impl<'a> Transpiler<'a> { from.options.for_worker(), ) }; - let resolver_opts = resolver_bundle_options_subset(&options); + let resolver_opts = + resolver_bundle_options_subset(&options, from.resolver.opts.jsx.clone()); let log_nn = core::ptr::NonNull::new(log).expect("Transpiler::for_worker: log is non-null"); // SAFETY: see fn doc — `Resolver::for_worker` widens // `standalone_module_graph` / `env_loader` lifetimes. @@ -611,8 +612,13 @@ impl<'a> Transpiler<'a> { /// crate carries a FORWARD_DECL subset of `BundleOptions`, so re-project /// rather than `Clone`. Called after `init_transpiler_with_options` mutates /// `self.options` so the resolver sees the same conditions/target/public_path. + /// + /// `jsx` is kept, not re-projected: by now `configure_linker` / + /// `run_env_loader` have merged the cwd tsconfig.json into `options.jsx` + /// (see `resolver_bundle_options_subset`). pub fn sync_resolver_opts(&mut self) { - self.resolver.opts = resolver_bundle_options_subset(&self.options); + let jsx = core::mem::take(&mut self.resolver.opts.jsx); + self.resolver.opts = resolver_bundle_options_subset(&self.options, jsx); } /// Print the loaded environment variables to stdout as 2-space-indented @@ -1048,6 +1054,11 @@ fn init_file_system(top_level_dir: Option<&'static [u8]>) -> crate::Result<*mut /// `Box<[Box<[u8]>]>`/`StringSet`/`StringArrayHashMap` so this is a faithful /// value copy rather than a `Default` stub. /// +/// `jsx` is not taken from `src`: `configure_linker` / `run_env_loader` merge +/// the cwd tsconfig.json into `src.jsx`, while the resolver merges each file's +/// own tsconfig over `opts.jsx`, so the base it gets must stay the user's +/// configuration (`options.jsx` as built by `from_api`). +/// /// This projection can be dropped once `bun_options_types::BundleOptions` exists and both /// crates re-export it — `Resolver::init1` will then take the canonical type /// directly. @@ -1061,6 +1072,7 @@ fn init_file_system(top_level_dir: Option<&'static [u8]>) -> crate::Result<*mut #[inline(never)] fn resolver_bundle_options_subset( src: &options::BundleOptions<'_>, + jsx: crate::options_impl::jsx::Pragma, ) -> resolver::options::BundleOptions { use resolver::options as ropts; @@ -1070,8 +1082,7 @@ fn resolver_bundle_options_subset( options::PackagesOption::External => ropts::Packages::External, options::PackagesOption::Bundle => ropts::Packages::Bundle, }, - // D042: same nominal type on both sides. - jsx: src.jsx.clone(), + jsx, // Spec `options.ResolveFileExtensions` — clone all four owned slices so // the resolver honours user `--extension-order` and the per-target // `.node` augmentation `from_api` applied. @@ -1274,7 +1285,8 @@ impl<'a> Transpiler<'a> { // crate's `options::BundleOptions<'a>`. Project the fields the resolver // reads; the rest stay at `Default` until MOVE_DOWN to // `bun_options_types` unifies the two (resolver/lib.rs:2773 note). - let resolver_opts = resolver_bundle_options_subset(&bundle_options); + let resolver_opts = + resolver_bundle_options_subset(&bundle_options, bundle_options.jsx.clone()); let outbase = bundle_options.output_dir.clone(); let resolve_results = Box::new(ResolveResults::default()); diff --git a/src/resolver/options.rs b/src/resolver/options.rs index 6000dfd81eb7..0c78c2e812e7 100644 --- a/src/resolver/options.rs +++ b/src/resolver/options.rs @@ -207,6 +207,8 @@ pub struct Framework { pub struct BundleOptions { pub target: Target, pub packages: Packages, + /// The user's JSX configuration with no tsconfig.json applied: `Result::jsx` + /// is the resolved file's own enclosing tsconfig merged over this. pub jsx: jsx::Pragma, pub extension_order: ExtensionOrder, pub conditions: Conditions, diff --git a/src/resolver/tsconfig_json.rs b/src/resolver/tsconfig_json.rs index 402ee82c0505..094ea1ae3420 100644 --- a/src/resolver/tsconfig_json.rs +++ b/src/resolver/tsconfig_json.rs @@ -239,6 +239,8 @@ impl TSConfigJSON { if self.jsx_flags.contains(JsxField::ImportSource) { out.import_source = self.jsx.import_source.clone(); + // The key-after-spread `createElement` fallback is imported from `package_name`. + out.package_name.clone_from(&self.jsx.package_name); } if self.jsx_flags.contains(JsxField::Runtime) { diff --git a/test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts b/test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts index 0b46adc2be8e..b48bad028767 100644 --- a/test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts +++ b/test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "node:path"; // tsconfig "jsx": "react-jsx" selects the production automatic runtime (jsx/jsxs), // "react-jsxdev" selects the development runtime (jsxDEV), matching TypeScript/esbuild. @@ -69,3 +70,214 @@ describe("tsconfig compilerOptions.jsx", () => { } }); }); + +// A file's JSX settings are the tsconfig.json nearest to it merged over the settings given on the +// command line / in bunfig.toml / to Bun.build({ jsx }). The tsconfig.json of the directory the build +// is started from is not part of that base: it only applies to the files it governs, like in tsc. +// `bun build` (and Bun.build() / `bun build` with an --env option) used to merge each file's tsconfig +// over the cwd's tsconfig instead, so the cwd's jsxImportSource / jsxFactory / "jsx" filled in +// whatever a nested tsconfig.json left unset, and the same file built differently depending on the +// cwd and on whether it was built through the CLI or through Bun.build(). +describe("the cwd tsconfig.json is not the base for files governed by another tsconfig.json", () => { + const noNodeEnv = { ...bunEnv, NODE_ENV: undefined, BUN_ENV: undefined }; + const element = `globalThis.a = <>
;\n`; + const tsconfig = (compilerOptions: Record) => JSON.stringify({ compilerOptions }); + + /** + * What the output's JSX is wired to: the packages it imports its runtime from (the dev and prod + * entries of a package are folded together: which one a file gets is decided by the cwd tsconfig + * or NODE_ENV for the CLI and by the jsx option for Bun.build(), and is not what is under test + * here) plus, for the classic runtime, which factory/fragment identifiers it calls. The fixtures' + * cwd tsconfigs always name theirs cwdFactory/cwdFragment. + */ + function runtimeRefsOf(code: string): string[] { + const imports = [...code.matchAll(/ from "([^"]+)"/g)].map(m => m[1].replace(/\/jsx-dev-runtime$/, "/jsx-runtime")); + const classic = [...code.matchAll(/\b(cwdFactory|cwdFragment|React\.createElement|React\.Fragment)\b/g)].map( + m => m[1], + ); + return [...new Set([...imports, ...classic])].sort(); + } + + interface Case { + files: Record; + /** Directory inside the fixture to build from. */ + cwd?: string; + entry: string; + /** Passed as --jsx-import-source / jsx.importSource, i.e. part of the base the tsconfigs merge over. */ + jsxImportSource?: string; + refs: string[]; + } + + const cases: [name: string, c: Case][] = [ + [ + 'nested tsconfig sets "jsx": jsxImportSource is the default, not the cwd tsconfig\'s', + { + files: { + "tsconfig.json": tsconfig({ jsx: "react-jsx", jsxImportSource: "cwd-src" }), + "app/tsconfig.json": tsconfig({ jsx: "react-jsx" }), + "app/m.jsx": element, + }, + entry: "app/m.jsx", + refs: ["react/jsx-runtime"], + }, + ], + [ + 'nested tsconfig sets jsxImportSource: the runtime is the default automatic one, not the cwd tsconfig\'s "react"', + { + files: { + "tsconfig.json": tsconfig({ jsx: "react", jsxFactory: "cwdFactory", jsxFragmentFactory: "cwdFragment" }), + "app/tsconfig.json": tsconfig({ jsxImportSource: "nested-src" }), + "app/m.jsx": element, + }, + entry: "app/m.jsx", + refs: ["nested-src/jsx-runtime"], + }, + ], + [ + 'nested tsconfig sets "jsx": "react": the factories are the defaults, not the cwd tsconfig\'s', + { + files: { + "tsconfig.json": tsconfig({ jsx: "react", jsxFactory: "cwdFactory", jsxFragmentFactory: "cwdFragment" }), + "app/tsconfig.json": tsconfig({ jsx: "react" }), + "app/m.jsx": element, + }, + entry: "app/m.jsx", + refs: ["React.Fragment", "React.createElement"], + }, + ], + [ + "file outside of every tsconfig gets the defaults", + { + files: { + "proj/tsconfig.json": tsconfig({ jsx: "react-jsx", jsxImportSource: "cwd-src" }), + "lib/m.jsx": element, + }, + cwd: "proj", + entry: "../lib/m.jsx", + refs: ["react/jsx-runtime"], + }, + ], + [ + "the command line / Bun.build() jsxImportSource stays the base a nested tsconfig merges over", + { + files: { + "tsconfig.json": tsconfig({ jsx: "react-jsx", jsxImportSource: "cwd-src" }), + "app/tsconfig.json": tsconfig({ jsx: "react-jsx" }), + "app/m.jsx": element, + }, + entry: "app/m.jsx", + jsxImportSource: "flag-src", + refs: ["flag-src/jsx-runtime"], + }, + ], + [ + // A key after a {...spread} makes the automatic runtime fall back to createElement, imported + // from the jsxImportSource package itself. Merging a tsconfig used to carry only the + // /jsx-runtime import over; the CLI got this right only because the cwd tsconfig was copied + // over the base wholesale, which is the copy that leaked it into every other file above. + "the createElement fallback import follows the governing tsconfig's jsxImportSource as well", + { + files: { + "tsconfig.json": tsconfig({ jsxImportSource: "shim" }), + "k.jsx": `const p = {};\nglobalThis.a =
;\nglobalThis.b = ;\n`, + }, + entry: "k.jsx", + refs: ["shim", "shim/jsx-runtime"], + }, + ], + ]; + + async function build(cmd: string[], cwd: string) { + await using proc = Bun.spawn({ cmd, cwd, env: noNodeEnv, stdout: "pipe", stderr: "pipe" }); + // stderr is drained but not asserted: the key-after-spread fixture prints a deprecation warning. + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { refs: runtimeRefsOf(stdout), exitCode }; + } + + function cli(args: string[]) { + return (cwd: string, c: Case) => + build( + [ + bunExe(), + "build", + ...args, + ...(c.jsxImportSource ? [`--jsx-import-source=${c.jsxImportSource}`] : []), + c.entry, + ], + cwd, + ); + } + + function api(extraOptions: Record) { + return (cwd: string, c: Case) => { + const options = { + entrypoints: [c.entry], + external: ["*"], + ...(c.jsxImportSource ? { jsx: { importSource: c.jsxImportSource } } : {}), + ...extraOptions, + }; + const script = `const result = await Bun.build(${JSON.stringify(options)});\nconsole.write(await result.outputs[0].text());`; + return build([bunExe(), "-e", script], cwd); + }; + } + + // Bundling modes mark everything external so the runtime imports stay visible in the output. + // --env / env: "inline" reach the cwd tsconfig through a second code path (the env loader), so + // they are covered separately. + const modes: [name: string, run: (cwd: string, c: Case) => Promise<{ refs: string[]; exitCode: number }>][] = [ + ["bun build", cli(["--external", "*"])], + ["bun build --no-bundle", cli(["--no-bundle"])], + ["bun build --env=inline", cli(["--env=inline", "--external", "*"])], + ["Bun.build()", api({})], + ['Bun.build({ env: "inline" })', api({ env: "inline" })], + ]; + + for (const [mode, run] of modes) { + test.concurrent.each(cases)(`${mode}: %s`, async (_, c) => { + using dir = tempDir("jsx-tsconfig-base", c.files); + expect(await run(join(String(dir), c.cwd ?? ""), c)).toEqual({ refs: c.refs, exitCode: 0 }); + }); + } + + // A browser-side HTML entry point inside a server-side build is resolved by a second transpiler + // derived from the main one, so its base has to be carried over without the cwd tsconfig too. + // Nothing is external here, so the test reads back which runtime package got bundled in. + test.concurrent("bun build --target=bun index.html", async () => { + const runtimePackage = (name: string) => ({ + [`node_modules/${name}/package.json`]: JSON.stringify({ + name, + exports: { "./jsx-runtime": "./runtime.js", "./jsx-dev-runtime": "./runtime.js" }, + }), + [`node_modules/${name}/runtime.js`]: ` + export const Fragment = "RUNTIME_PACKAGE:${name}"; + export const jsx = () => Fragment; + export const jsxs = jsx; + export const jsxDEV = jsx; + `, + }); + using dir = tempDir("jsx-tsconfig-base-html", { + ...runtimePackage("react"), + ...runtimePackage("cwd-src"), + "tsconfig.json": tsconfig({ jsx: "react-jsx", jsxImportSource: "cwd-src" }), + "app/tsconfig.json": tsconfig({ jsx: "react-jsx" }), + "app/m.jsx": element, + "index.html": ``, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--target=bun", "--outdir=out", "index.html"], + cwd: String(dir), + env: noNodeEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + expect(stdout).toContain("index.html"); + + const outputs = await Array.fromAsync(new Bun.Glob("*.js").scan(join(String(dir), "out"))); + expect(outputs).toHaveLength(1); + const code = await Bun.file(join(String(dir), "out", outputs[0])).text(); + expect([...code.matchAll(/RUNTIME_PACKAGE:([\w-]+)/g)].map(m => m[1])).toEqual(["react"]); + }); +}); From f21de0ea5e735390f93158a03ade9fcef79ac08c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:40:42 +0000 Subject: [PATCH 2/6] test: cover the dev/prod bit of the --no-bundle base --- .../transpiler/jsx-tsconfig-react-jsx.test.ts | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts b/test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts index b48bad028767..b5ee0c708449 100644 --- a/test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts +++ b/test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts @@ -187,10 +187,15 @@ describe("the cwd tsconfig.json is not the base for files governed by another ts ], ]; - async function build(cmd: string[], cwd: string) { - await using proc = Bun.spawn({ cmd, cwd, env: noNodeEnv, stdout: "pipe", stderr: "pipe" }); + async function buildOutput(cmd: string[], cwd: string, env: Record = noNodeEnv) { + await using proc = Bun.spawn({ cmd, cwd, env, stdout: "pipe", stderr: "pipe" }); // stderr is drained but not asserted: the key-after-spread fixture prints a deprecation warning. const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, exitCode }; + } + + async function build(cmd: string[], cwd: string) { + const { stdout, exitCode } = await buildOutput(cmd, cwd); return { refs: runtimeRefsOf(stdout), exitCode }; } @@ -239,6 +244,40 @@ describe("the cwd tsconfig.json is not the base for files governed by another ts }); } + // The dev/prod bit is part of the base as well. Bundled builds then choose dev/prod per build from + // the cwd tsconfig / NODE_ENV / --production (the force_node_env arms in bundle_v2), so the per-file + // value only shows in --no-bundle, which emits it as is. + describe("bun build --no-bundle", () => { + const importsOf = (code: string) => [...code.matchAll(/ from "([^"]+)"/g)].map(m => m[1]); + + test.concurrent('a nested tsconfig without "jsx" does not inherit the cwd tsconfig\'s "react-jsx"', async () => { + using dir = tempDir("jsx-tsconfig-base-dev", { + "tsconfig.json": tsconfig({ jsx: "react-jsx" }), + "app/tsconfig.json": tsconfig({ jsxImportSource: "nested-src" }), + "app/m.jsx": element, + }); + const { stdout, exitCode } = await buildOutput([bunExe(), "build", "--no-bundle", "app/m.jsx"], String(dir)); + expect({ imports: importsOf(stdout), exitCode }).toEqual({ + imports: ["nested-src/jsx-dev-runtime"], + exitCode: 0, + }); + }); + + // NODE_ENV is applied to the base before the cwd tsconfig is read; copying the cwd tsconfig's + // settings (development: true unless it sets "jsx") over the base used to discard it. + test.concurrent('NODE_ENV=production survives a cwd tsconfig that does not set "jsx"', async () => { + using dir = tempDir("jsx-tsconfig-base-node-env", { + "tsconfig.json": tsconfig({ jsxImportSource: "shim" }), + "m.jsx": element, + }); + const { stdout, exitCode } = await buildOutput([bunExe(), "build", "--no-bundle", "m.jsx"], String(dir), { + ...noNodeEnv, + NODE_ENV: "production", + }); + expect({ imports: importsOf(stdout), exitCode }).toEqual({ imports: ["shim/jsx-runtime"], exitCode: 0 }); + }); + }); + // A browser-side HTML entry point inside a server-side build is resolved by a second transpiler // derived from the main one, so its base has to be carried over without the cwd tsconfig too. // Nothing is external here, so the test reads back which runtime package got bundled in. From f2a28ac2a4b21dc90fd36e712c3ab67a25d070f2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:36:05 +0000 Subject: [PATCH 3/6] resolver: finalize results resolved through tsconfig paths under packages=external The packages=external tsconfig "paths" early return (#29590) handed back the bare opts.jsx without going through finalize_result, so the files it found never got their own tsconfig.json merged in. That used to be masked for the cwd tsconfig because opts.jsx carried a copy of it; now that opts.jsx is the user configuration only, route these results through finalize_result like every other file on disk, which also gives them the per-file decorator flags, sideEffects data, module type and realpath the regular path computes. --- src/resolver/resolver.rs | 15 ++++-- .../transpiler/jsx-tsconfig-react-jsx.test.ts | 49 +++++++++++++++---- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index a4bf402d54a0..24197d41c8c4 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1189,9 +1189,7 @@ impl<'a> Resolver<'a> { .to_vec(), ); } - let _ = self.flush_debug_logs(FlushMode::Success); - self.extension_order = original_order; - return ResultUnion::Success(Result { + let mut result = Result { import_kind: kind, path_pair: res.path_pair, package_json: res.package_json, @@ -1199,7 +1197,16 @@ impl<'a> Resolver<'a> { file_fd: res.file_fd, jsx: self.opts.jsx.clone(), ..Default::default() - }); + }; + // Same as the regular success path below: this is a file on + // disk, so it gets its own tsconfig, sideEffects and realpath. + if let Err(err) = self.finalize_result(&mut result, kind) { + self.extension_order = original_order; + return ResultUnion::Failure(err); + } + let _ = self.flush_debug_logs(FlushMode::Success); + self.extension_order = original_order; + return ResultUnion::Success(result); } } diff --git a/test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts b/test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts index b5ee0c708449..9ec476194a94 100644 --- a/test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts +++ b/test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts @@ -81,7 +81,7 @@ describe("tsconfig compilerOptions.jsx", () => { describe("the cwd tsconfig.json is not the base for files governed by another tsconfig.json", () => { const noNodeEnv = { ...bunEnv, NODE_ENV: undefined, BUN_ENV: undefined }; const element = `globalThis.a = <>
;\n`; - const tsconfig = (compilerOptions: Record) => JSON.stringify({ compilerOptions }); + const tsconfig = (compilerOptions: Record) => JSON.stringify({ compilerOptions }); /** * What the output's JSX is wired to: the packages it imports its runtime from (the dev and prod @@ -213,28 +213,29 @@ describe("the cwd tsconfig.json is not the base for files governed by another ts ); } - function api(extraOptions: Record) { + function api(options: Record) { return (cwd: string, c: Case) => { - const options = { + const config = { entrypoints: [c.entry], - external: ["*"], ...(c.jsxImportSource ? { jsx: { importSource: c.jsxImportSource } } : {}), - ...extraOptions, + ...options, }; - const script = `const result = await Bun.build(${JSON.stringify(options)});\nconsole.write(await result.outputs[0].text());`; + const script = `const result = await Bun.build(${JSON.stringify(config)});\nconsole.write(await result.outputs[0].text());`; return build([bunExe(), "-e", script], cwd); }; } + type Mode = [name: string, run: (cwd: string, c: Case) => Promise<{ refs: string[]; exitCode: number }>]; + // Bundling modes mark everything external so the runtime imports stay visible in the output. // --env / env: "inline" reach the cwd tsconfig through a second code path (the env loader), so // they are covered separately. - const modes: [name: string, run: (cwd: string, c: Case) => Promise<{ refs: string[]; exitCode: number }>][] = [ + const modes: Mode[] = [ ["bun build", cli(["--external", "*"])], ["bun build --no-bundle", cli(["--no-bundle"])], ["bun build --env=inline", cli(["--env=inline", "--external", "*"])], - ["Bun.build()", api({})], - ['Bun.build({ env: "inline" })', api({ env: "inline" })], + ["Bun.build()", api({ external: ["*"] })], + ['Bun.build({ env: "inline" })', api({ external: ["*"], env: "inline" })], ]; for (const [mode, run] of modes) { @@ -244,6 +245,36 @@ describe("the cwd tsconfig.json is not the base for files governed by another ts }); } + // With packages: "external", an import that matches a tsconfig "paths" alias is resolved by a + // separate early return in the resolver (#29590). Files found that way are files on disk like any + // other and get their own tsconfig: lib/ is governed by the cwd tsconfig, app/ by its own. (The + // runtime packages themselves are bare imports, so packages: "external" keeps them visible.) + const aliased: Case = { + files: { + "tsconfig.json": tsconfig({ + jsxImportSource: "cwd-src", + paths: { "@lib/*": ["./lib/*"], "@app/*": ["./app/*"] }, + }), + "app/tsconfig.json": tsconfig({ jsxImportSource: "nested-src" }), + "lib/a.jsx": element, + "app/b.jsx": element, + "main.js": `import "@lib/a";\nimport "@app/b";\n`, + }, + entry: "main.js", + refs: ["cwd-src/jsx-runtime", "nested-src/jsx-runtime"], + }; + const packagesExternalModes: Mode[] = [ + ["bun build --packages=external", cli(["--packages=external"])], + ['Bun.build({ packages: "external" })', api({ packages: "external" })], + ]; + test.concurrent.each(packagesExternalModes)( + "%s: files reached through tsconfig paths aliases get their own tsconfig", + async (_, run) => { + using dir = tempDir("jsx-tsconfig-base-paths", aliased.files); + expect(await run(String(dir), aliased)).toEqual({ refs: aliased.refs, exitCode: 0 }); + }, + ); + // The dev/prod bit is part of the base as well. Bundled builds then choose dev/prod per build from // the cwd tsconfig / NODE_ENV / --production (the force_node_env arms in bundle_v2), so the per-file // value only shows in --no-bundle, which emits it as is. From 811cc852be49887993370f597a61d76f5f0cd4f8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:40:55 +0000 Subject: [PATCH 4/6] Shorten the comments on the resolver jsx base --- src/bundler/transpiler.rs | 11 +++-------- src/resolver/options.rs | 3 +-- src/resolver/resolver.rs | 2 -- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 57aa19de70c7..d90508c14b4e 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -612,10 +612,7 @@ impl<'a> Transpiler<'a> { /// crate carries a FORWARD_DECL subset of `BundleOptions`, so re-project /// rather than `Clone`. Called after `init_transpiler_with_options` mutates /// `self.options` so the resolver sees the same conditions/target/public_path. - /// - /// `jsx` is kept, not re-projected: by now `configure_linker` / - /// `run_env_loader` have merged the cwd tsconfig.json into `options.jsx` - /// (see `resolver_bundle_options_subset`). + /// `jsx` is kept: see `resolver_bundle_options_subset`. pub fn sync_resolver_opts(&mut self) { let jsx = core::mem::take(&mut self.resolver.opts.jsx); self.resolver.opts = resolver_bundle_options_subset(&self.options, jsx); @@ -1054,10 +1051,8 @@ fn init_file_system(top_level_dir: Option<&'static [u8]>) -> crate::Result<*mut /// `Box<[Box<[u8]>]>`/`StringSet`/`StringArrayHashMap` so this is a faithful /// value copy rather than a `Default` stub. /// -/// `jsx` is not taken from `src`: `configure_linker` / `run_env_loader` merge -/// the cwd tsconfig.json into `src.jsx`, while the resolver merges each file's -/// own tsconfig over `opts.jsx`, so the base it gets must stay the user's -/// configuration (`options.jsx` as built by `from_api`). +/// `jsx` is a parameter because `src.jsx` has the cwd tsconfig merged in +/// (`configure_linker`), and the resolver merges each file's own tsconfig over it. /// /// This projection can be dropped once `bun_options_types::BundleOptions` exists and both /// crates re-export it — `Resolver::init1` will then take the canonical type diff --git a/src/resolver/options.rs b/src/resolver/options.rs index 0c78c2e812e7..21060e963eb5 100644 --- a/src/resolver/options.rs +++ b/src/resolver/options.rs @@ -207,8 +207,7 @@ pub struct Framework { pub struct BundleOptions { pub target: Target, pub packages: Packages, - /// The user's JSX configuration with no tsconfig.json applied: `Result::jsx` - /// is the resolved file's own enclosing tsconfig merged over this. + /// User configuration only; each resolved file's tsconfig is merged over it. pub jsx: jsx::Pragma, pub extension_order: ExtensionOrder, pub conditions: Conditions, diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 24197d41c8c4..fdbf19106479 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1198,8 +1198,6 @@ impl<'a> Resolver<'a> { jsx: self.opts.jsx.clone(), ..Default::default() }; - // Same as the regular success path below: this is a file on - // disk, so it gets its own tsconfig, sideEffects and realpath. if let Err(err) = self.finalize_result(&mut result, kind) { self.extension_order = original_order; return ResultUnion::Failure(err); From 7c0ecf8bb1919637b4e5124645559c12f62d2012 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:43:16 +0000 Subject: [PATCH 5/6] Make the jsx parameter doc a single line --- src/bundler/transpiler.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index d90508c14b4e..6fb22a29c78d 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -1051,8 +1051,7 @@ fn init_file_system(top_level_dir: Option<&'static [u8]>) -> crate::Result<*mut /// `Box<[Box<[u8]>]>`/`StringSet`/`StringArrayHashMap` so this is a faithful /// value copy rather than a `Default` stub. /// -/// `jsx` is a parameter because `src.jsx` has the cwd tsconfig merged in -/// (`configure_linker`), and the resolver merges each file's own tsconfig over it. +/// `jsx` is the base each file's own tsconfig is merged over; `src.jsx` has the cwd's merged in. /// /// This projection can be dropped once `bun_options_types::BundleOptions` exists and both /// crates re-export it — `Resolver::init1` will then take the canonical type From e3e75ff3a0ec42e4eebda3f60237b7a78ef04c82 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:45:23 +0000 Subject: [PATCH 6/6] Document the resolver jsx base on the field only --- src/bundler/transpiler.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 6fb22a29c78d..d9f56c482d15 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -612,7 +612,7 @@ impl<'a> Transpiler<'a> { /// crate carries a FORWARD_DECL subset of `BundleOptions`, so re-project /// rather than `Clone`. Called after `init_transpiler_with_options` mutates /// `self.options` so the resolver sees the same conditions/target/public_path. - /// `jsx` is kept: see `resolver_bundle_options_subset`. + /// `jsx` is kept: see `bun_resolver::options::BundleOptions::jsx`. pub fn sync_resolver_opts(&mut self) { let jsx = core::mem::take(&mut self.resolver.opts.jsx); self.resolver.opts = resolver_bundle_options_subset(&self.options, jsx); @@ -1051,8 +1051,6 @@ fn init_file_system(top_level_dir: Option<&'static [u8]>) -> crate::Result<*mut /// `Box<[Box<[u8]>]>`/`StringSet`/`StringArrayHashMap` so this is a faithful /// value copy rather than a `Default` stub. /// -/// `jsx` is the base each file's own tsconfig is merged over; `src.jsx` has the cwd's merged in. -/// /// This projection can be dropped once `bun_options_types::BundleOptions` exists and both /// crates re-export it — `Resolver::init1` will then take the canonical type /// directly.