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.