From 3182142d9c419d490631f81d9effd14a1aed7736 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:11:05 +0000 Subject: [PATCH] react-compiler: treat only the last `children` key of a JSX call as the JSX children The parser lowers `
y
` to `jsx("div", { children: "x", children: "y" })`. The JSX children are the last property, so at runtime they replace every earlier `children` key. `lower_jsx_call` read every `children` key as a JSX child and the compiled component rendered `["x", "y"]`. The same happened for two `children` attributes, for an inlined `{...{ children }}` spread, and a `children` attribute before a spread lost to the spread's own `children`. Only the last property is read as the JSX children now. An earlier `children` key stays an attribute in its position. The parser also inlines `` into the props object. The lowering passed the getter function itself as the prop. A function with an accessor in its JSX props is now left uncompiled, like a function with an accessor in an object literal. --- src/react_compiler/lowering/build_hir/jsx.rs | 38 +++++++++- .../bundler/transpiler/react-compiler.test.ts | 76 +++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/react_compiler/lowering/build_hir/jsx.rs b/src/react_compiler/lowering/build_hir/jsx.rs index 08849efe0783..ecc76504dcc4 100644 --- a/src/react_compiler/lowering/build_hir/jsx.rs +++ b/src/react_compiler/lowering/build_hir/jsx.rs @@ -265,7 +265,8 @@ pub(super) fn lower_jsx_call( } } - for prop in obj.properties.iter() { + let last_index = obj.properties.len().saturating_sub(1); + for (index, prop) in obj.properties.iter().enumerate() { if matches!(prop.kind, G::PropertyKind::Spread) { let value = prop.value.as_ref().ok_or_else(|| { todo_err("(BuildHIR::lowerJsxCall) spread without value", loc) @@ -274,6 +275,9 @@ pub(super) fn lower_jsx_call( props.push(JsxAttribute::SpreadAttribute { argument }); continue; } + if record_accessor_prop(builder, prop)? { + continue; + } let Some(key_expr) = prop.key.as_ref() else { continue; }; @@ -293,7 +297,11 @@ pub(super) fn lower_jsx_call( continue; }; - if name == b"children" { + // The visit pass appends the JSX children as the last property. + // Any earlier `children` key is an attribute (or came from an + // inlined `{...{children}}` spread) that a later key overrides at + // runtime, so it stays an attribute and keeps its position. + if name == b"children" && index == last_index { lower_jsx_children(builder, value_expr, is_static_children, &mut children)?; continue; } @@ -315,6 +323,9 @@ pub(super) fn lower_jsx_call( props.push(JsxAttribute::SpreadAttribute { argument }); continue; } + if record_accessor_prop(builder, prop)? { + continue; + } let Some(key_expr) = prop.key.as_ref() else { continue; }; @@ -408,6 +419,29 @@ fn lower_jsx_children( Ok(()) } +/// The visit pass inlines `` into the props object, so +/// a getter or setter can reach here. A `JsxAttribute` holds a value, not an +/// accessor, so the function is left uncompiled, as `lower_object_method` does +/// for an accessor in an object literal. Returns true when `prop` is one. +fn record_accessor_prop( + builder: &mut HirBuilder, + prop: &G::Property, +) -> Result { + let kind = match prop.kind { + G::PropertyKind::Get => "get", + G::PropertyKind::Set => "set", + _ => return Ok(false), + }; + builder.record_error(CompilerErrorDetail { + category: ErrorCategory::Todo, + reason: format!("(BuildHIR::lowerJsxCall) Handle {kind} functions in JSX props"), + description: None, + loc: convert_loc(prop.key.as_ref().map_or(Loc::EMPTY, |key| key.loc)), + suggestions: None, + })?; + Ok(true) +} + fn todo_err(reason: &str, loc: Option) -> CompilerError { let mut err = CompilerError::new(); err.push_error_detail(CompilerErrorDetail { diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index 94621cd153a7..9171f90ba43e 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -1139,6 +1139,82 @@ describe("bundler", () => { }, }); + // The parser turns `
y
` into + // `jsx("div", { children: "x", children: "y" })`: the JSX children are the + // last property, so they replace every earlier `children` key. It also + // inlines `{...{ k: v }}` into that object. The compiler reads the element + // back from the call, and has to keep each key in place and keep a getter a + // getter. + itBundled("react-compiler/ChildrenAttributeIsNotAJsxChild", { + files: { + "/entry.jsx": /* jsx */ ` + function Attr(p) { return
y
; } + function AttrExpr(p) { return
{p.b}
; } + function TwoAttrs(p) { return
; } + function InlineSpread(p) { return
real
; } + function AttrThenSpread(p) { return
; } + function AttrSpreadChildren(p) { return
{p.a}{p.b}
; } + function SpreadThenAttr(p) { return
; } + function Getter(p) { return ; } + console.log(JSON.stringify({ + Attr: Attr({}).p.children, + AttrExpr: AttrExpr({ a: 1, b: "x" }).p.children, + TwoAttrs: TwoAttrs({}).p.children, + InlineSpread: InlineSpread({}).p.children, + AttrThenSpread: [AttrThenSpread({ children: "q" }).p.children, AttrThenSpread({}).p.children], + AttrSpreadChildren: AttrSpreadChildren({ a: 1, b: 2, children: "q" }).p.children, + SpreadThenAttr: SpreadThenAttr({ children: "q" }).p.children, + Getter: Getter({ a: 5 }).p.g, + })); + `, + ...stubReact, + }, + reactCompiler: true, + target: "browser", + backend: "api", + run: { + stdout: JSON.stringify({ + Attr: "y", + AttrExpr: "x", + TwoAttrs: "b", + InlineSpread: "real", + AttrThenSpread: ["q", "x"], + AttrSpreadChildren: [1, 2], + SpreadThenAttr: "x", + Getter: 5, + }), + }, + onAfterBundle(api) { + const out = api.readFile("/out.js"); + const body = (name: string) => { + const start = out.indexOf(`function ${name}(`); + return out.slice(start, out.indexOf("\nfunction ", start + 1)); + }; + // A compiled component reads its memo cache. The getter has no JSX + // attribute form, so that one component is left as written. + const compiled = [ + "Attr", + "AttrExpr", + "TwoAttrs", + "InlineSpread", + "AttrThenSpread", + "AttrSpreadChildren", + "SpreadThenAttr", + "Getter", + ].filter(name => /\$\[\d+\]/.test(body(name))); + expect(compiled).toEqual([ + "Attr", + "AttrExpr", + "TwoAttrs", + "InlineSpread", + "AttrThenSpread", + "AttrSpreadChildren", + "SpreadThenAttr", + ]); + expect(body("Getter")).toContain("get g()"); + }, + }); + // A 0-arg call to an unknown import is non-reactive in InferReactivePlaces // (no operand is reactive, callee isn't a hook), so its scope's deps prune // to empty and it becomes a sentinel-only block. Babel does the same; this