Repository navigation
Conversation
… default value A default value and a case test are lowered only if each operand is safe to reorder. The check took a JSX element as a plain call and judged every argument. A development `jsxDEV` call ends in `isStaticChildren, source, self`, and `self` is `this`, which is not reorderable. So a function with JSX in a default was compiled in production and left as written in development. `automatic_jsx_call` in build_hir/jsx.rs says which call is an automatic-runtime call and returns its callee, props and operands (tag, props, key). `lower_jsx_call` takes the call shape from it, and a new arm of `is_reorderable_expression` judges only the operands.
|
Status Reproduced with the script in the PR notes on bun 1.4.3-canary.1+367d939d9 and on a debug build of the base of this PR: |
|
Updated 10:05 PM PT - Oct 18th, 2026
❌ @robobun, your commit 65f015d has some failures in 🧪 To try this PR locally: bunx bun-pr 45991That installs a local version of the PR into your bun-45991 --bun |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes which functions bun build --react-compiler memoizes in development (and the description itself asks for a maintainer's call on whether JSX in a default should be reorderable at all, versus upstream's verdict), a human look at the direction is still worthwhile.
What was reviewed:
lower_jsx_callrefactor throughautomatic_jsx_call: the old(callee, props_arg)match and the new?-based early returns accept exactly the same calls (callee in jsx/jsxs/jsxDEV andargs[1]anE::Object); theargs.len() == 6isStaticChildren branch and theJsxsfallback are unchanged.- The new
is_reorderable_expressionarm: sits after thejsx_can_be_reorderedarm sobun lintis unaffected, only fires when the callee is the auto-imported runtime symbol (so classiccreateElementcalls still take the generic arm, which theMemberOfLocalChildcontrol pins), and the dropped callee judgement was always true for that symbol. && letin a match guard is already used insrc/glob/matcher.rson the pinned nightly, so it compiles;jsx_import_kindremains used in jsx.rs, no dead imports left.- Test trio: each clause of the arm has a negative control (tag, props, key), and the dev/prod/classic verdicts are asserted as exact objects.
Extended reasoning...
The change touches the React Compiler's HIR lowering (src/react_compiler/lowering/build_hir/expr.rs and jsx.rs), adding an automatic_jsx_call helper, a new reorderability arm for transpiled automatic-runtime JSX calls, a DESIGN.md paragraph, and a 200-line itBundled test trio across development, production and classic JSX modes. It touches no security-sensitive surface. The Rust diff is small and the refactor is verifiably behavior-preserving, but it changes development-mode compile verdicts for user code and is stacked on another open PR whose base this diff assumes, and the author explicitly flags a design question (whether bun build should compile JSX defaults at all, diverging from upstream) that only a maintainer can settle, which decided defer over approve.
|
The arrow arm next to the one that this PR adds needs the same decision, so one answer can cover both.
#46426 ships only the part that holds either way. Its notes have the measurements for both. |
Stacked on #45550.
Problem
bun build --react-compilercompiles a function with JSX in a default value ({ icon = <Spinner /> }) orcasetest in production and silently leaves it as written in development:(BuildHIR::node.lowerReorderableExpression) Expression type `CallExpression` cannot be safely reordered. An effect that depends on the default runs on every render in development only.is_reorderable_expression(src/react_compiler/lowering/build_hir/expr.rs:1428) judges every argument of the parser's call.jsxDEVends inisStaticChildren, source, self, andselfisthis.Fix
automatic_jsx_call(build_hir/jsx.rs) returns the operands of an automatic-runtime call: tag, props, key. A new arm ofis_reorderable_expressionjudges only those.lower_jsx_callreads it too.bun lintverdicts stay.react-compiler/JsxInDefaultValue-*intest/bundler/transpiler/react-compiler.test.ts(23 forms, 3 JSX modes). Development fails on the base.Background
jsx_can_be_reordered,hir/environment_config.rs:91).thisas reorderable (same output, the check still walks non-operands) and a callee check in the arm (a copy of the decoder's rule).Downsides
WithADefault: []).Host::jsx_import_kindcalls on the fixture corpus and a 106,911,456-byte release binary, before and after.Notes
Repro
Before:
development: left as written,production: memoized. With--jsx-runtime=classicdevelopment is memoized too, becausecreateElement(tag, props, ...children)has no trailingthis. A debug build prints the Todo withBUN_DEBUG_react_compiler=1. The report came from a fuzz run. There is no user report.Direction: this needs a maintainer's word
Upstream compiles a JSX default in neither mode: its
isReorderableExpressionhas no JSX case.bun lintkeeps that verdict (jsx_can_be_reordered: false).bun buildcompiles it in production by the rule atenvironment_config.rs:91, and this PR makes development agree. The other consistent direction isjsx_can_be_reordered: falseforbun build. Then production stops compiling these functions and this PR is not needed.Draft #44833 has
react-compiler/WhichFunctionsAreCompiled, whose expected sizes come from babel-plugin-react-compiler. Its rowWithADefault: []is for({ depth, render = () => <b /> }). Development gives[]there today only because of the trailingthis. Production gives a compiled function for the same source. With this PR development gives one too. The two branches merge without a textual conflict in that row, so whichever lands second has to change it.What changes in development, measured with React 18.3.1 on debug builds of the base and of this branch
Card({ title, icon = <Spinner /> })withuseEffect(fn, [icon]), five renders: the effect runs 5 times in development and 1 time in production on the base. On this branch it runs 1 time in both.({ icon = <b title={later} />, later })throwsReferenceErrorin development on the base and not in production, because the compiled function reads the whole pattern before it evaluates a default. On this branch it throws in neither. The same holds for the pattern in aconstand for an array pattern.bun lintreports it.icon = <Spinner {...{ size: 1 }} />compiles in development and production and not with the classic runtime, because only the automatic transform folds that spread into the props.Why it is stacked on #45550
On main a function in a parameter list takes the compile of its component (#45550). For
item = ({ value, fallback = <Spinner /> }) => ...that compile fails in development today, for the reason this PR removes. So with this PR alone on main, that arrow would get the cache hook in development as it already does in production: with React 18.3.1, two rows and then three rows,Rendered more hooks than during the previous render.On top of #45550 neither mode compiles the arrow. The rowArrowInParameterListholds that order: it passes only on top of #45550.What the check covers
All three callers of
lower_reorderable_expressiongo through the new arm: defaults in binding patterns (stmt.rslower_default_value), defaults in parameters and assignment patterns (helpers.rslower_assignment_pattern_default), andcasetests (stmt.rs). The controls of the test hold each clause of the arm:MemberOfLocalTagSpreadProp,MemberOfLocalChildMemberOfLocalKeyLocalInArrow,LocalTag,LocalKeyMemberOfLocalChildwith the classic runtimecreateElement(Spinner, null, 1, 2, 3, rest.id)has six arguments tooSelf-review: what was raised and what changed
thisas reorderable would change the result for a user-writtenthis. It would not: lowering refuses everythis. The reason to prefer the decoder is structure, and Background says so now.WithADefaultrow.ArrowReturningJsxdoes now, and the row is named above.DESIGN.mdand in comments were not exact: lowering readsisStaticChildren, and acasetest is evaluated earlier, not later. They are corrected.DESIGN.md.lower_jsx_callkeeps its read of the key (args.get(2)) and its comment onargs[3..6], so this branch merges with #42935 without a conflict.Measurements (debug and release builds of the base, #45550 at f9b900b, and of this branch, linux-x64)
Host::jsx_import_kindcalls (gdb breakpoint hit counts): one build of 1,714 fixture inputs, base 1,482, PR 1,482. One-line reproexport function C({ i = <a /> }) { return <b>{i}</b>; }, development / production: base 2 / 2, PR 3 / 3.is_reorderable_expressionentries foricon = <Spinner />, development / production: base 8 / 4, PR 4 / 3.BUN_REACT_COMPILER_DUMP_HIR=1) and outputs, 1,807 fixture files x 2 JSX modes, base vs PR: 0 dumps differ, 0 outputs differ.lower_jsx_callindirect calls in the release build: base 4 (2 aredyn Host), PR 3, plus 1 inautomatic_jsx_call(2 aredyn Host).automatic_jsx_callis not inlined: each lowered element makes one more direct call. AST allocations on an input with no default (two components): base 1,460, PR 1,460.lower_jsx_call5,321 -> 5,255 bytes,is_reorderable_expression889 -> 1,018 bytes,automatic_jsx_call126 bytes (new).is_reorderable_expression: +9 / -0 lines, all in one Bun-only arm. Upstream arms changed: 0.Suites run on the debug build
test/bundler/transpiler/react-compiler.test.ts(124 pass),react-compiler-fixtures.test.ts(3,293 pass),react-compiler-fixtures-run.test.ts,test/cli/lint/react-compiler.test.tsandtest/cli/lint/conformance.test.ts(878 pass together),test/bundler/bundler_jsx.test.tsandtest/bundler/transpiler/transpiler.test.js(302 pass). No snapshot entry changes.JsxInDefaultValue-Developmentfails on a debug build of the base (17 functions not compiled). The production and classic cases pass before and after: they pin the verdict that development has to match.The fixture and lint suites needed longer timeouts on my machine, for the base binary too: one full-corpus build took 70 to 140 s there.
Not changed here
jsxDEV: bun build: keep NODE_ENV=production when tsconfig.json does not set "jsx" #38050, bundler: stop --jsx-* flags from overriding tsconfig jsx dev/prod #36269.CaptureWalker::walk_expr(build_hir/mod.rs:759),find_context_identifiers.rs:473andrefs_in_expr(build_hir/stmt.rs:501) still walk a lowered JSX call through a generic call arm. They collect identifiers, and the trailing arguments hold none. react-compiler: do not capture the JSX runtime imports as context of a nested function #43528 changes what the first one collects from the callee.visit_expr.rsandcodegen.rs).