Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/bundler/transpiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -611,8 +612,10 @@ 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 `bun_resolver::options::BundleOptions::jsx`.
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
Expand Down Expand Up @@ -1061,6 +1064,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;

Expand All @@ -1070,8 +1074,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.
Expand Down Expand Up @@ -1274,7 +1277,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());
Expand Down
1 change: 1 addition & 0 deletions src/resolver/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ pub struct Framework {
pub struct BundleOptions {
pub target: Target,
pub packages: Packages,
/// User configuration only; each resolved file's tsconfig is merged over it.
pub jsx: jsx::Pragma,
pub extension_order: ExtensionOrder,
pub conditions: Conditions,
Expand Down
13 changes: 9 additions & 4 deletions src/resolver/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1189,17 +1189,22 @@ 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,
dirname_fd: res.dirname_fd,
file_fd: res.file_fd,
jsx: self.opts.jsx.clone(),
..Default::default()
});
};
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);
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/resolver/tsconfig_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
282 changes: 282 additions & 0 deletions test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -69,3 +70,284 @@ 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 = <><div /></>;\n`;
const tsconfig = (compilerOptions: Record<string, unknown>) => 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<string, string>;
/** 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 = <div {...p} key="k" />;\nglobalThis.b = <span />;\n`,
},
entry: "k.jsx",
refs: ["shim", "shim/jsx-runtime"],
},
],
];

async function buildOutput(cmd: string[], cwd: string, env: Record<string, string | undefined> = 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 };
}

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(options: Record<string, unknown>) {
return (cwd: string, c: Case) => {
const config = {
entrypoints: [c.entry],
...(c.jsxImportSource ? { jsx: { importSource: c.jsxImportSource } } : {}),
...options,
};
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: Mode[] = [
["bun build", cli(["--external", "*"])],
["bun build --no-bundle", cli(["--no-bundle"])],
["bun build --env=inline", cli(["--env=inline", "--external", "*"])],
["Bun.build()", api({ external: ["*"] })],
['Bun.build({ env: "inline" })', api({ external: ["*"], 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 });
});
}

// 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.
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.
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": `<!doctype html><script type="module" src="./app/m.jsx"></script>`,
});

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"]);
});
});
Loading