Compare commits

...

4 Commits

Author SHA1 Message Date
Claude Bot
06709da16c refactor(tests): cache transformSync results in JSX inlining tests
Address review feedback to avoid calling transformSync multiple times
on the same input. Cache the result in a variable instead.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 04:47:44 +00:00
Jarred Sumner
a7c17814e4 Merge branch 'main' into claude/jsx-inline-optimization 2026-01-13 20:41:12 -08:00
autofix-ci[bot]
5618a7db26 [autofix.ci] apply automated fixes 2026-01-14 04:40:57 +00:00
Claude Bot
373bd8314e feat(transpiler): add opt-in --jsx-inline optimization for React 18/19
Brings back the JSX inlining optimization that was removed in d38f937d,
but this time as an opt-in feature behind the --jsx-inline flag.

The optimization transforms jsx() calls into inline object literals:
  jsx("div", { children: "hello" })
  ->
  { $$typeof: Symbol.for("react.element"), type: "div", ... }

This avoids the overhead of calling jsx() and merging props at runtime.

Two modes are supported:
- `--jsx-inline=react-18`: Uses Symbol.for("react.element")
- `--jsx-inline=react-19`: Uses Symbol.for("react.transitional.element")

The inlining is disabled for:
- Elements with spread props ({...props})
- Elements with ref prop
- Fragment elements

Also adds the `jsxOptimizationInline` option to Bun.Transpiler API
accepting "react-18", "react-19", or boolean values.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 03:52:39 +00:00
15 changed files with 251 additions and 8 deletions

View File

@@ -72,6 +72,8 @@ pub const Flags = struct {
pub const JSXElement = enum {
is_key_after_spread,
has_any_dynamic,
/// The element can be inlined to an object literal (no spread props, no ref prop)
can_be_inlined,
pub const Bitset = std.enums.EnumSet(JSXElement);
};

View File

@@ -5571,7 +5571,7 @@ pub fn NewParser_(
}
}
fn runtimeIdentifier(p: *P, loc: logger.Loc, comptime name: string) Expr {
pub fn runtimeIdentifier(p: *P, loc: logger.Loc, comptime name: string) Expr {
const ref = p.runtimeIdentifierRef(loc, name);
p.recordUsage(ref);
return p.newExpr(

View File

@@ -8,6 +8,7 @@ pub const Parser = struct {
pub const Options = struct {
jsx: options.JSX.Pragma,
jsx_optimization_inline: Runtime.Features.JsxInlineMode = .none,
ts: bool = false,
keep_names: bool = true,
ignore_dce_annotations: bool = false,
@@ -44,10 +45,8 @@ pub const Parser = struct {
if (did_use_jsx) {
if (this.jsx.parse) {
this.jsx.hashForRuntimeTranspiler(hasher);
// this holds the values for the jsx optimizaiton flags, which have both been removed
// as the optimizations break newer versions of react, see https://github.com/oven-sh/bun/issues/11025
const jsx_optimizations = [_]bool{ false, false };
hasher.update(std.mem.asBytes(&jsx_optimizations));
// Include jsx_optimization_inline mode in hash
hasher.update(std.mem.asBytes(&@intFromEnum(this.jsx_optimization_inline)));
} else {
hasher.update("NO_JSX");
}
@@ -1538,8 +1537,9 @@ const Define = @import("../defines.zig").Define;
const importRecord = @import("../import_record.zig");
const ImportRecord = importRecord.ImportRecord;
const RuntimeFeatures = _runtime.Runtime.Features;
const RuntimeImports = _runtime.Runtime.Imports;
const Runtime = _runtime.Runtime;
const RuntimeFeatures = Runtime.Features;
const RuntimeImports = Runtime.Imports;
const bun = @import("bun");
const Environment = bun.Environment;

View File

@@ -26,11 +26,14 @@ pub fn ParseJSXElement(
var key_prop_i: i32 = -1;
var flags = Flags.JSXElement.Bitset{};
var start_tag: ?ExprNodeIndex = null;
// Track whether this element can be inlined (no spread props, no ref prop)
var can_be_inlined = false;
// Fragments don't have props
// Fragments of the form "React.Fragment" are not parsed as fragments.
if (@as(JSXTag.TagType, tag.data) == .tag) {
start_tag = tag.data.tag;
can_be_inlined = p.options.jsx_optimization_inline.isEnabled();
var spread_loc: logger.Loc = logger.Loc.Empty;
var props = ListManaged(G.Property).init(p.allocator);
@@ -46,6 +49,11 @@ pub fn ParseJSXElement(
const special_prop = E.JSXElement.SpecialProp.Map.get(prop_name_literal) orelse E.JSXElement.SpecialProp.any;
try p.lexer.nextInsideJSXElement();
// ref prop prevents inlining
if (special_prop == .ref) {
can_be_inlined = false;
}
if (special_prop == .key) {
// <ListItem key>
if (p.lexer.token != .t_equals) {
@@ -80,6 +88,8 @@ pub fn ParseJSXElement(
switch (p.lexer.token) {
.t_dot_dot_dot => {
try p.lexer.next();
// Spread props prevent inlining
can_be_inlined = false;
if (first_spread_prop_i == -1) first_spread_prop_i = i;
spread_loc = p.lexer.loc();
@@ -148,6 +158,7 @@ pub fn ParseJSXElement(
const is_key_after_spread = key_prop_i > -1 and first_spread_prop_i > -1 and key_prop_i > first_spread_prop_i;
flags.setPresent(.is_key_after_spread, is_key_after_spread);
flags.setPresent(.can_be_inlined, can_be_inlined);
properties = G.Property.List.moveFromList(&props);
if (is_key_after_spread and p.options.jsx.runtime == .automatic and !p.has_classic_runtime_warned) {
try p.log.addWarning(p.source, spread_loc, "\"key\" prop after a {...spread} is deprecated in JSX. Falling back to classic runtime.");

View File

@@ -323,6 +323,84 @@ pub fn VisitExpr(
}) catch |err| bun.handleOom(err);
}
// JSX inlining optimization: transform jsx() calls into inline object literals
// This avoids the overhead of calling jsx() and merging props at runtime
// The output object looks like:
// { $$typeof: Symbol.for("react.element"), type: "div", key: null, ref: null, props: {}, _owner: null }
if (p.options.jsx_optimization_inline.isEnabled() and e_.flags.contains(.can_be_inlined)) {
const key_expr = if (maybe_key_value) |key_value| brk: {
// key: void 0 === key ? null : "" + key
break :brk switch (key_value.data) {
.e_string => key_value,
.e_undefined, .e_null => p.newExpr(E.Null{}, key_value.loc),
else => p.newExpr(E.If{
.test_ = p.newExpr(E.Binary{
.left = p.newExpr(E.Undefined{}, key_value.loc),
.op = Op.Code.bin_strict_eq,
.right = key_value,
}, key_value.loc),
.yes = p.newExpr(E.Null{}, key_value.loc),
.no = p.newExpr(E.Binary{
.op = Op.Code.bin_add,
.left = p.newExpr(&E.String.empty, key_value.loc),
.right = key_value,
}, key_value.loc),
}, key_value.loc),
};
} else p.newExpr(E.Null{}, expr.loc);
const props_object = p.newExpr(E.Object{
.properties = props.*,
.close_brace_loc = e_.close_tag_loc,
}, expr.loc);
// For component tags (not strings), we need to handle defaultProps
const props_expression = brk: {
if (tag.data != .e_string) {
// We assume defaultProps is supposed to _not_ have side effects
const defaultProps = p.newExpr(E.Dot{
.name = "defaultProps",
.name_loc = tag.loc,
.target = tag,
.can_be_removed_if_unused = true,
.call_can_be_unwrapped_if_unused = .if_unused,
}, tag.loc);
// props: MyComponent.defaultProps || {}
if (props.len == 0) {
break :brk p.newExpr(E.Binary{ .op = Op.Code.bin_logical_or, .left = defaultProps, .right = props_object }, defaultProps.loc);
} else {
var call_args = p.allocator.alloc(Expr, 2) catch bun.outOfMemory();
call_args[0..2].* = .{ props_object, defaultProps };
// __merge(props, MyComponent.defaultProps)
break :brk p.callRuntime(tag.loc, "__merge", call_args);
}
}
break :brk props_object;
};
// Select the right $$typeof based on React version
const typeof_expr = switch (p.options.jsx_optimization_inline) {
.react_18 => p.runtimeIdentifier(tag.loc, "$$typeof_18"),
.react_19 => p.runtimeIdentifier(tag.loc, "$$typeof_19"),
.none => unreachable,
};
var jsx_element = p.allocator.alloc(G.Property, 6) catch bun.outOfMemory();
jsx_element[0..6].* = .{
G.Property{ .key = Expr{ .data = Prefill.Data.@"$$typeof", .loc = tag.loc }, .value = typeof_expr },
G.Property{ .key = Expr{ .data = Prefill.Data.type, .loc = tag.loc }, .value = tag },
G.Property{ .key = Expr{ .data = Prefill.Data.key, .loc = key_expr.loc }, .value = key_expr },
G.Property{ .key = Expr{ .data = Prefill.Data.ref, .loc = expr.loc }, .value = p.newExpr(E.Null{}, expr.loc) },
G.Property{ .key = Expr{ .data = Prefill.Data.props, .loc = expr.loc }, .value = props_expression },
G.Property{ .key = Expr{ .data = Prefill.Data._owner, .loc = key_expr.loc }, .value = p.newExpr(E.Null{}, expr.loc) },
};
return p.newExpr(E.Object{
.properties = G.Property.List.fromOwnedSlice(jsx_element),
.close_brace_loc = e_.close_tag_loc,
}, expr.loc);
}
// Either:
// jsxDEV(type, arguments, key, isStaticChildren, source, self)
// jsx(type, arguments, key)
@@ -1725,6 +1803,7 @@ const E = js_ast.E;
const Expr = js_ast.Expr;
const ExprNodeIndex = js_ast.ExprNodeIndex;
const ExprNodeList = js_ast.ExprNodeList;
const Op = js_ast.Op;
const Scope = js_ast.Scope;
const Stmt = js_ast.Stmt;
const Symbol = js_ast.Symbol;

View File

@@ -233,6 +233,29 @@ pub const Config = struct {
this.runtime.allow_runtime = flag;
}
if (try object.getTruthy(globalThis, "jsxOptimizationInline")) |jsx_inline| {
if (jsx_inline.isString()) {
const str = try jsx_inline.toSlice(globalThis, bun.default_allocator);
defer str.deinit();
if (bun.strings.eqlComptime(str.slice(), "react-18")) {
this.runtime.jsx_optimization_inline = .react_18;
} else if (bun.strings.eqlComptime(str.slice(), "react-19")) {
this.runtime.jsx_optimization_inline = .react_19;
} else {
return globalThis.throwInvalidArguments("jsxOptimizationInline must be \"react-18\" or \"react-19\"", .{});
}
} else if (jsx_inline.isBoolean()) {
// For backwards compatibility, true means react-18
if (jsx_inline.toBoolean()) {
this.runtime.jsx_optimization_inline = .react_18;
} else {
this.runtime.jsx_optimization_inline = .none;
}
} else {
return globalThis.throwInvalidArguments("jsxOptimizationInline must be a string (\"react-18\" or \"react-19\") or boolean", .{});
}
}
if (try object.getBooleanLoose(globalThis, "inline")) |flag| {
this.runtime.inlining = flag;
}
@@ -716,6 +739,7 @@ pub fn constructor(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) b
transpiler.options.auto_import_jsx = config.runtime.auto_import_jsx;
transpiler.options.inlining = config.runtime.inlining;
transpiler.options.hot_module_reloading = config.runtime.hot_module_reloading;
transpiler.options.jsx_optimization_inline = config.runtime.jsx_optimization_inline;
transpiler.options.react_fast_refresh = false;
return this;

View File

@@ -1207,6 +1207,7 @@ fn runWithSourceCode(
} else .none;
opts.framework = transpiler.options.framework;
opts.jsx_optimization_inline = transpiler.options.jsx_optimization_inline;
opts.ignore_dce_annotations = transpiler.options.ignore_dce_annotations and !source.index.isRuntime();

View File

@@ -441,6 +441,7 @@ pub const Command = struct {
keep_names: bool = false,
ignore_dce_annotations: bool = false,
emit_dce_annotations: bool = true,
jsx_inline: Runtime.Features.JsxInlineMode = .none,
output_format: options.Format = .esm,
bytecode: bool = false,
banner: []const u8 = "",
@@ -1745,6 +1746,7 @@ const Bunfig = @import("./bunfig.zig").Bunfig;
const ColonListType = @import("./cli/colon_list_type.zig").ColonListType;
const MacroMap = @import("./resolver/package_json.zig").MacroMap;
const RunCommand_ = @import("./cli/run_command.zig").RunCommand;
const Runtime = @import("./runtime.zig").Runtime;
const TestCommand = @import("./cli/test_command.zig").TestCommand;
const Install = @import("./install/install.zig");

View File

@@ -74,6 +74,7 @@ pub const transpiler_params_ = [_]ParamType{
clap.parseParam("--jsx-import-source <STR> Declares the module specifier to be used for importing the jsx and jsxs factory functions. Default: \"react\"") catch unreachable,
clap.parseParam("--jsx-runtime <STR> \"automatic\" (default) or \"classic\"") catch unreachable,
clap.parseParam("--jsx-side-effects Treat JSX elements as having side effects (disable pure annotations)") catch unreachable,
clap.parseParam("--jsx-inline <STR> Inline JSX elements to object literals: \"react-18\" or \"react-19\"") catch unreachable,
clap.parseParam("--ignore-dce-annotations Ignore tree-shaking annotations such as @__PURE__") catch unreachable,
};
pub const runtime_params_ = [_]ParamType{
@@ -935,6 +936,17 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
ctx.bundler_options.css_chunking = args.flag("--css-chunking");
if (args.option("--jsx-inline")) |jsx_inline| {
if (strings.eqlComptime(jsx_inline, "react-18")) {
ctx.bundler_options.jsx_inline = .react_18;
} else if (strings.eqlComptime(jsx_inline, "react-19")) {
ctx.bundler_options.jsx_inline = .react_19;
} else {
Output.prettyErrorln("<r><red>error<r>: Invalid --jsx-inline value: \"{s}\". Expected \"react-18\" or \"react-19\"", .{jsx_inline});
Global.exit(1);
}
}
ctx.bundler_options.emit_dce_annotations = args.flag("--emit-dce-annotations") or
!ctx.bundler_options.minify_whitespace;

View File

@@ -78,6 +78,7 @@ pub const BuildCommand = struct {
this_transpiler.options.keep_names = ctx.bundler_options.keep_names;
this_transpiler.options.emit_dce_annotations = ctx.bundler_options.emit_dce_annotations;
this_transpiler.options.ignore_dce_annotations = ctx.bundler_options.ignore_dce_annotations;
this_transpiler.options.jsx_optimization_inline = ctx.bundler_options.jsx_inline;
this_transpiler.options.banner = ctx.bundler_options.banner;
this_transpiler.options.footer = ctx.bundler_options.footer;

View File

@@ -1806,6 +1806,7 @@ pub const BundleOptions = struct {
ignore_dce_annotations: bool = false,
emit_dce_annotations: bool = false,
jsx_optimization_inline: Runtime.Features.JsxInlineMode = .none,
bytecode: bool = false,
code_coverage: bool = false,

View File

@@ -171,8 +171,13 @@ export var __legacyMetadataTS = (k, v) => {
export var __esm = (fn, res) => () => (fn && (res = fn((fn = 0))), res);
// This is used for JSX inlining with React.
// These are used for JSX inlining with React.
// $$typeof is kept for backwards compatibility
export var $$typeof = /* @__PURE__ */ Symbol.for("react.element");
// $$typeof_18 is for React 18 and earlier (uses "react.element")
export var $$typeof_18 = /* @__PURE__ */ Symbol.for("react.element");
// $$typeof_19 is for React 19+ (uses "react.transitional.element")
export var $$typeof_19 = /* @__PURE__ */ Symbol.for("react.transitional.element");
export var __jsonParse = /* @__PURE__ */ a => JSON.parse(a);

View File

@@ -175,6 +175,11 @@ pub const Runtime = struct {
set_breakpoint_on_first_line: bool = false,
/// JSX element inlining optimization mode.
/// Instead of jsx("div", {}, void 0), transform to an inline object literal:
/// { $$typeof: Symbol.for("react.element"), type: "div", ... }
jsx_optimization_inline: JsxInlineMode = .none,
trim_unused_imports: bool = false,
/// Allow runtime usage of require(), converting `require` into `__require`
@@ -261,6 +266,8 @@ pub const Runtime = struct {
}
hasher.update(std.mem.asBytes(&bools));
// Include jsx_optimization_inline mode in hash
hasher.update(std.mem.asBytes(&@intFromEnum(this.jsx_optimization_inline)));
}
pub fn shouldUnwrapRequire(this: *const Features, package_name: string) bool {
@@ -278,6 +285,21 @@ pub const Runtime = struct {
pub const Map = bun.StringArrayHashMapUnmanaged(ReplaceableExport);
};
/// JSX element inlining optimization mode.
/// This transforms jsx() calls into inline object literals for better performance.
pub const JsxInlineMode = enum {
/// Disabled - no JSX inlining (default)
none,
/// React 18 compatible - uses Symbol.for("react.element")
react_18,
/// React 19 compatible - uses Symbol.for("react.transitional.element")
react_19,
pub fn isEnabled(mode: JsxInlineMode) bool {
return mode != .none;
}
};
pub const ServerComponentsMode = enum {
/// Server components is disabled, strings "use client" and "use server" mean nothing.
none,
@@ -335,6 +357,8 @@ pub const Runtime = struct {
__legacyDecorateParamTS: ?Ref = null,
__legacyMetadataTS: ?Ref = null,
@"$$typeof": ?Ref = null,
@"$$typeof_18": ?Ref = null,
@"$$typeof_19": ?Ref = null,
__using: ?Ref = null,
__callDispose: ?Ref = null,
__jsonParse: ?Ref = null,
@@ -352,6 +376,8 @@ pub const Runtime = struct {
"__legacyDecorateParamTS",
"__legacyMetadataTS",
"$$typeof",
"$$typeof_18",
"$$typeof_19",
"__using",
"__callDispose",
"__jsonParse",

View File

@@ -1107,6 +1107,7 @@ pub const Transpiler = struct {
opts.filepath_hash_for_hmr = file_hash orelse 0;
opts.features.auto_import_jsx = transpiler.options.auto_import_jsx;
opts.jsx_optimization_inline = transpiler.options.jsx_optimization_inline;
opts.warn_about_unbundled_modules = !target.isBun();
opts.features.inject_jest_globals = this_parse.inject_jest_globals;

View File

@@ -1519,6 +1519,84 @@ console.log(<div {...obj} key="after" />);`),
);
});
describe("JSX inlining optimization", () => {
it("inlines JSX elements with react-19", () => {
const bun = new Bun.Transpiler({
loader: "jsx",
jsxOptimizationInline: "react-19",
});
const result = bun.transformSync("export var foo = <div>hello</div>");
expect(result).toContain("$$typeof:");
expect(result).toContain("$$typeof_19");
expect(result).toContain('type: "div"');
expect(result).toContain("props:");
});
it("inlines JSX elements with react-18", () => {
const bun = new Bun.Transpiler({
loader: "jsx",
jsxOptimizationInline: "react-18",
});
const result = bun.transformSync("export var foo = <div>hello</div>");
expect(result).toContain("$$typeof:");
expect(result).toContain("$$typeof_18");
expect(result).toContain('type: "div"');
});
it("does not inline when spread props are present", () => {
const bun = new Bun.Transpiler({
loader: "jsx",
jsxOptimizationInline: "react-19",
});
// With spread, it should fall back to jsx call
const result = bun.transformSync("export var foo = <div {...props}>hello</div>");
expect(result).not.toContain("$$typeof:");
expect(result).toContain("jsx");
});
it("does not inline when ref prop is present", () => {
const bun = new Bun.Transpiler({
loader: "jsx",
jsxOptimizationInline: "react-19",
});
// With ref, it should fall back to jsx call
const result = bun.transformSync("export var foo = <div ref={myRef}>hello</div>");
expect(result).not.toContain("$$typeof:");
expect(result).toContain("jsx");
});
it("does not inline by default (no jsxOptimizationInline)", () => {
const bun = new Bun.Transpiler({
loader: "jsx",
});
// Should use jsx/jsxDEV call by default
const result = bun.transformSync("export var foo = <div>hello</div>");
expect(result).not.toContain("$$typeof:");
expect(result).toContain("jsx");
});
it("handles component tags with defaultProps", () => {
const bun = new Bun.Transpiler({
loader: "jsx",
jsxOptimizationInline: "react-19",
});
const result = bun.transformSync("export var foo = <MyComponent a={1} />");
expect(result).toContain("$$typeof:");
// Should include merge logic for defaultProps
expect(result).toContain("defaultProps");
});
it("handles key prop", () => {
const bun = new Bun.Transpiler({
loader: "jsx",
jsxOptimizationInline: "react-19",
});
const result = bun.transformSync('export var foo = <div key="my-key">hello</div>');
expect(result).toContain("$$typeof:");
expect(result).toContain('key: "my-key"');
});
});
it("require with a dynamic non-string expression", () => {
var nodeTranspiler = new Bun.Transpiler({ platform: "node" });
expect(nodeTranspiler.transformSync("require('hi' + bar)")).toBe('require("hi" + bar);\n');