Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Bot
17b3db806e Fix JSX development mode for HTML bundling
Ensure force_node_env flag is properly applied in enqueueParseTask and
enqueueParseTask2 functions. These functions create ParseTasks but were
not calling computeJSXDevelopment to apply the force_node_env override.

This ensures that when HTML bundles are created with development: true,
the JSX transform respects that setting.

Related to #23959
2025-10-27 22:05:43 +00:00
Claude Bot
91d061de25 Fix JSX transform not respecting NODE_ENV=production (#23959)
## Summary

Fixes a bug where `Bun.build` would use development JSX transforms (`jsxDEV` from `react/jsx-dev-runtime`) even when `NODE_ENV=production` was set, instead of using production JSX transforms (`jsx` from `react/jsx-runtime`).

## Root Causes

1. **Missing force_node_env flag for production**: When `NODE_ENV=production` was set, the transpiler set `jsx.development = false` but did not set `force_node_env = .production`. This meant that later code couldn't distinguish between "production due to NODE_ENV" vs "default settings", causing tsconfig jsx settings to incorrectly override NODE_ENV.

2. **Incorrect RuntimeMap for react-jsx**: The `RuntimeMap` mapped `"react-jsx"` (production mode) to `.development = true`, which is incorrect. It should map to `.development = false`.

3. **Wrong fallback in bundle_v2**: When `force_node_env` was `.unspecified`, the bundler used the global `transpiler.options.jsx.development` instead of the per-module `task.jsx.development` which includes tsconfig settings.

## Changes

1. **src/transpiler.zig**: Set `force_node_env = .production` when `NODE_ENV=production` is detected, matching the behavior for `NODE_ENV=development`.

2. **src/options.zig**: Fix RuntimeMap to correctly map `"react-jsx"` to `.development = false` (production mode) instead of `.development = true`.

3. **src/bundler/bundle_v2.zig**: When `force_node_env` is `.unspecified`, use `task.jsx.development` (which includes per-module tsconfig settings) instead of the global `transpiler.options.jsx.development`.

## Configuration Priority

The correct priority is now enforced:
1. `NODE_ENV=production/development` (highest - overrides all)
2. `--production` flag (sets NODE_ENV internally)
3. tsconfig.json `compilerOptions.jsx` (lowest - default fallback)

## Test Plan

Added comprehensive regression tests in `test/regression/issue/23959.test.ts`:
-  `NODE_ENV=production` uses production JSX (`jsx`)
-  `NODE_ENV=development` uses development JSX (`jsxDEV`)
-  No NODE_ENV defaults to development JSX (`jsxDEV`)
-  `--production` flag uses production JSX (`jsx`)

Fixes #23959
2025-10-25 23:22:42 +00:00
6 changed files with 253 additions and 25 deletions

View File

@@ -281,6 +281,7 @@ pub const Route = struct {
if (!is_development) {
bun.handleOom(config.define.put("process.env.NODE_ENV", "\"production\""));
config.force_node_env = .production;
config.jsx.development = false;
} else {
config.force_node_env = .development;

View File

@@ -161,6 +161,16 @@ pub const BundleV2 = struct {
return &this.linker.loop;
}
/// Determines the JSX development mode based on force_node_env and current task settings.
/// Priority: NODE_ENV > tsconfig.json
inline fn computeJSXDevelopment(force_node_env: options.BundleOptions.ForceNodeEnv, current: bool) bool {
return switch (force_node_env) {
.development => true,
.production => false,
.unspecified => current,
};
}
/// Returns the jsc.EventLoop where plugin callbacks can be queued up on
pub fn jsLoopForPlugins(this: *BundleV2) *jsc.EventLoop {
bun.assert(this.plugins != null);
@@ -755,11 +765,7 @@ pub const BundleV2 = struct {
task.task.node.next = null;
task.tree_shaking = this.linker.options.tree_shaking;
task.known_target = target;
task.jsx.development = switch (t.options.force_node_env) {
.development => true,
.production => false,
.unspecified => t.options.jsx.development,
};
task.jsx.development = computeJSXDevelopment(t.options.force_node_env, task.jsx.development);
// Handle onLoad plugins as entry points
if (!this.enqueueOnLoadPluginIfNeeded(task)) {
@@ -820,11 +826,7 @@ pub const BundleV2 = struct {
task.known_target = target;
{
const bundler = this.transpilerForTarget(target);
task.jsx.development = switch (bundler.options.force_node_env) {
.development => true,
.production => false,
.unspecified => bundler.options.jsx.development,
};
task.jsx.development = computeJSXDevelopment(bundler.options.force_node_env, task.jsx.development);
}
// Handle onLoad plugins as entry points
@@ -1320,6 +1322,10 @@ pub const BundleV2 = struct {
task.io_task.node.next = null;
task.tree_shaking = this.linker.options.tree_shaking;
task.known_target = known_target;
{
const bundler = this.transpilerForTarget(known_target);
task.jsx.development = computeJSXDevelopment(bundler.options.force_node_env, task.jsx.development);
}
this.incrementScanCounter();
@@ -1374,6 +1380,10 @@ pub const BundleV2 = struct {
};
task.task.node.next = null;
task.io_task.node.next = null;
{
const bundler = this.transpilerForTarget(known_target);
task.jsx.development = computeJSXDevelopment(bundler.options.force_node_env, task.jsx.development);
}
this.incrementScanCounter();
@@ -3462,11 +3472,7 @@ pub const BundleV2 = struct {
target;
resolve_task.jsx = resolve_result.jsx;
resolve_task.jsx.development = switch (transpiler.options.force_node_env) {
.development => true,
.production => false,
.unspecified => transpiler.options.jsx.development,
};
resolve_task.jsx.development = computeJSXDevelopment(transpiler.options.force_node_env, resolve_task.jsx.development);
resolve_task.loader = import_record_loader;
resolve_task.tree_shaking = transpiler.options.tree_shaking;

View File

@@ -1226,7 +1226,7 @@ pub const JSX = struct {
.{ "classic", RuntimeDevelopmentPair{ .runtime = .classic, .development = null } },
.{ "automatic", RuntimeDevelopmentPair{ .runtime = .automatic, .development = true } },
.{ "react", RuntimeDevelopmentPair{ .runtime = .classic, .development = null } },
.{ "react-jsx", RuntimeDevelopmentPair{ .runtime = .automatic, .development = true } },
.{ "react-jsx", RuntimeDevelopmentPair{ .runtime = .automatic, .development = false } },
.{ "react-jsxdev", RuntimeDevelopmentPair{ .runtime = .automatic, .development = true } },
});

View File

@@ -559,6 +559,8 @@ pub const Transpiler = struct {
} else if (is_production) {
this.options.setProduction(true);
this.resolver.opts.setProduction(true);
this.options.force_node_env = .production;
this.resolver.opts.force_node_env = .production;
}
}

View File

@@ -787,26 +787,25 @@ describe("bundler", () => {
onAfterBundle(api) {
const file = api.readFile("out.js");
// When sideEffects is true via tsconfig with automatic jsx: should NOT include /* @__PURE__ */ comments
// tsconfig "jsx": "react-jsx" uses production runtime (jsx), not development (jsxDEV)
expect(file).not.toContain("/* @__PURE__ */");
expect(normalizeBunSnapshot(file)).toMatchInlineSnapshot(`
"// @bun
// node_modules/react/jsx-dev-runtime.js
var $$typeof = Symbol.for("jsxdev");
function jsxDEV(type, props, key, source, self) {
// node_modules/react/jsx-runtime.js
var $$typeof = Symbol.for("jsx");
function jsx(type, props, key) {
return {
$$typeof,
type,
props,
key,
source,
self
key
};
}
var Fragment = Symbol.for("jsxdev.fragment");
var Fragment = Symbol.for("jsx.fragment");
// index.jsx
console.log(jsxDEV("a", {}, undefined, false, undefined, this));
console.log(jsxDEV(Fragment, {}, undefined, false, undefined, this));"
console.log(jsx("a", {}));
console.log(jsx(Fragment, {}));"
`);
},
});

View File

@@ -0,0 +1,220 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
test("Bun.build respects NODE_ENV=production for JSX transform", async () => {
using dir = tempDir("test-jsx-prod", {
"test.tsx": `console.log(<div>Hello</div>);`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "test.tsx", "--outfile=out.js", "--external", "react"],
env: {
...bunEnv,
NODE_ENV: "production",
},
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const output = await Bun.file(dir + "/out.js").text();
// Should use production JSX runtime (jsx from react/jsx-runtime)
// NOT development runtime (jsxDEV from react/jsx-dev-runtime)
expect(output).toContain('from "react/jsx-runtime"');
expect(output).toContain("jsx(");
expect(output).not.toContain("jsxDEV");
expect(output).not.toContain('from "react/jsx-dev-runtime"');
expect(exitCode).toBe(0);
});
test("Bun.build uses development JSX transform when NODE_ENV=development", async () => {
using dir = tempDir("test-jsx-dev", {
"test.tsx": `console.log(<div>Hello</div>);`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "test.tsx", "--outfile=out.js", "--external", "react"],
env: {
...bunEnv,
NODE_ENV: "development",
},
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const output = await Bun.file(dir + "/out.js").text();
// Should use development JSX runtime
expect(output).toContain('from "react/jsx-dev-runtime"');
expect(output).toContain("jsxDEV");
expect(output).not.toContain('from "react/jsx-runtime"');
expect(output).not.toContain("jsx(");
expect(exitCode).toBe(0);
});
test("Bun.build defaults to development JSX when NODE_ENV is not set", async () => {
using dir = tempDir("test-jsx-default", {
"test.tsx": `console.log(<div>Hello</div>);`,
});
const env = { ...bunEnv };
delete env.NODE_ENV;
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "test.tsx", "--outfile=out.js", "--external", "react"],
env,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const output = await Bun.file(dir + "/out.js").text();
// Should default to development JSX runtime
expect(output).toContain('from "react/jsx-dev-runtime"');
expect(output).toContain("jsxDEV");
expect(output).not.toContain('from "react/jsx-runtime"');
expect(output).not.toContain("jsx(");
expect(exitCode).toBe(0);
});
test("Bun.build --production flag uses production JSX transform", async () => {
using dir = tempDir("test-jsx-flag", {
"test.tsx": `console.log(<div>Hello</div>);`,
});
const env = { ...bunEnv };
delete env.NODE_ENV;
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "--production", "test.tsx", "--outfile=out.js", "--external", "react"],
env,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const output = await Bun.file(dir + "/out.js").text();
// Should use production JSX runtime (minified, so just check for the runtime)
expect(output).toContain("react/jsx-runtime");
expect(output).not.toContain("jsxDEV");
expect(output).not.toContain("react/jsx-dev-runtime");
expect(exitCode).toBe(0);
});
test("NODE_ENV=production overrides tsconfig.json jsx:react-jsx", async () => {
using dir = tempDir("test-jsx-tsconfig-override", {
"test.tsx": `console.log(<div>Hello</div>);`,
"tsconfig.json": JSON.stringify({
compilerOptions: {
jsx: "react-jsx",
},
}),
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "test.tsx", "--outfile=out.js", "--external", "react"],
env: {
...bunEnv,
NODE_ENV: "production",
},
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const output = await Bun.file(dir + "/out.js").text();
// NODE_ENV=production should override tsconfig and use production JSX runtime
expect(output).toContain('from "react/jsx-runtime"');
expect(output).toContain("jsx(");
expect(output).not.toContain("jsxDEV");
expect(output).not.toContain('from "react/jsx-dev-runtime"');
expect(exitCode).toBe(0);
});
test("NODE_ENV=production overrides tsconfig.json jsx:react-jsxdev", async () => {
using dir = tempDir("test-jsx-prod-override-jsxdev", {
"test.tsx": `console.log(<div>Hello</div>);`,
"tsconfig.json": JSON.stringify({
compilerOptions: {
jsx: "react-jsxdev",
},
}),
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "test.tsx", "--outfile=out.js", "--external", "react"],
env: {
...bunEnv,
NODE_ENV: "production",
},
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const output = await Bun.file(dir + "/out.js").text();
// NODE_ENV=production should override tsconfig react-jsxdev and force production runtime
expect(output).toContain('from "react/jsx-runtime"');
expect(output).toContain("jsx(");
expect(output).not.toContain("jsxDEV");
expect(output).not.toContain('from "react/jsx-dev-runtime"');
expect(exitCode).toBe(0);
});
test("NODE_ENV=development overrides tsconfig.json jsx:react-jsx", async () => {
using dir = tempDir("test-jsx-dev-override-jsx", {
"test.tsx": `console.log(<div>Hello</div>);`,
"tsconfig.json": JSON.stringify({
compilerOptions: {
jsx: "react-jsx",
},
}),
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "test.tsx", "--outfile=out.js", "--external", "react"],
env: {
...bunEnv,
NODE_ENV: "development",
},
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const output = await Bun.file(dir + "/out.js").text();
// NODE_ENV=development should override tsconfig react-jsx and force development runtime
expect(output).toContain('from "react/jsx-dev-runtime"');
expect(output).toContain("jsxDEV");
expect(output).not.toContain('from "react/jsx-runtime"');
expect(output).not.toContain("jsx(");
expect(exitCode).toBe(0);
});