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