react_compiler: keep every folded segment of template heads, tails and computed keys - #38993
react_compiler: keep every folded segment of template heads, tails and computed keys#38993robobun wants to merge 1 commit into
Conversation
… when lowering
After the parser folds "pre" + `fix/${id}` (or `${"a"}b${x}`), the template's
head or tail is a rope E::String: `data` holds the first segment and the rest
is linked through `next`. convert_template_contents read `data` only, so the
compiled output kept just the first segment. Flatten the rope into the arena
the same way roped string literals already are.
A folded computed object key arrives as the same rope. lower_object_property_key
returned a Todo error for it, bailing the whole function out of compilation;
let it take the computed arm, which flattens it, like the object method path
already does.
|
Warning Review limit reached
Next review available in: 2 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
|
Status: reproduced on the 1.4.0 release and on a current canary by bundling a component containing |
There was a problem hiding this comment.
LGTM — focused fix that flattens roped EString segments in template quasis and computed keys before HIR lowering.
What was reviewed:
arena_str_from_ropewalksnextand sizes viaEString::len(), which returnsrope_lenfor ropes;EString::pushasserts 8-bit soslice8()per segment is sound.lower_object_property_keynow matches the sibling infunction.rs(if s.next.is_none()→ rope falls through to thecomputedarm, wherelower_expression→convert_js_stringflattens it); checked that the object-pattern caller already filters computed keys before this point, socomputed=false+ rope is unreachable there.convert_js_stringrefactor is behaviour-preserving (same walk, now shared; arena buffer instead of heap Vec is fine — arena-lifetime, rare path).- Tests cover head/tail via
+, head/tail via template folding, joined templates, empty-data head, computed key, and the no-minifyimport()case; each asserts observable rendered output plus memo-cache allocation count.
Extended reasoning...
Overview
Two-file Rust change in src/react_compiler/lowering/build_hir/ plus a test addition. convert_template_contents gains a s.next.is_some() branch that flattens a roped cooked head/tail into a single arena string via a new arena_str_from_rope helper; convert_js_string (which already flattened ropes inline) is deduplicated to call the same helper. lower_object_property_key in helpers.rs restricts its static-string arm to non-roped EString so a folded computed key ({["a" + "b"]: x}) falls through to the existing computed arm, which routes through lower_expression → convert_js_string and flattens correctly. This mirrors the existing lower_object_property_key in function.rs:373, which already had the if s.next.is_none() guard.
Security risks
None. Pure AST-to-HIR string handling in the bundler's React Compiler pass; no untrusted input parsing, no syscalls, no allocation sizing driven by external data beyond source-file string lengths already bounded by the parser.
Level of scrutiny
Medium. It's a correctness fix in codegen (wrong output emitted), but the mechanism is well-understood — the PR description traces the rope invariant precisely, and I verified EString::len() returns rope_len (src/ast/e.rs:1838) and EString::push asserts is_utf8() on both operands (src/ast/e.rs:2021-2022), so the 8-bit walk and capacity presize are sound. The helpers.rs change is a one-line guard tightening that brings it in line with the identical function in function.rs.
Other factors
- The tests are strong: the first bundles under
minify.syntax, runs the output, and asserts exact JSON of rendered props for six distinct rope shapes plus a memo-cache allocation counter proving neither component bailed. The second covers the always-onimport()folding path without minify. The PR description states these fail on the unfixed build with the specific truncated outputs. - The
convert_js_stringrefactor trades a short-lived heapVecfor an arenaHirVecthatJsString::from_wtf8_bytesthen copies from. The arena buffer survives until arena reset, but this path is only reached for roped string literals (rare, per the deleted comment) and the deduplication is what REVIEW.md asks for when a block appears twice in a diff. - Checked the object-pattern caller of
lower_object_property_key(helpers.rs, destructuring path): it bails onIsComputedbefore calling, and passescomputed=false, so a rope cannot reach it withcomputed=falsefrom that path. The object-literal caller passes the realIsComputedflag, which is set for["a" + "b"]. - 3293 upstream fixtures + the rest of react-compiler.test.ts reported passing.
|
Updated 10:14 AM PT - Aug 15th, 2026
❌ @robobun, your commit 5e02a70 has some failures in 🧪 To try this PR locally: bunx bun-pr 38993That installs a local version of the PR into your bun-38993 --bun |
Problem
"pre" + `fix/${id}`in a component is emitted as`pre${id}`,`${Route.Users}/${id}`(const enum) as`${id}`,`${id}/mid` + "dle"as`${id}/mid`. The same build withoutreactCompilerprints the full text.minify.syntax, and it is always on insideimport()/require()arguments, so a default build is affected too:import(Dir.Pages + `/${name}.js`)in a component is emitted asimport(`./pages${name}.js`).fold_string_addition(src/ast/fold_string_addition.rs) andTemplate::fold(src/ast/e.rs) build the folded head/tail as a rope (see Background).convert_template_contentsinsrc/react_compiler/lowering/build_hir/expr.rsread it withslice8(), which returnsdataalone, and codegen (template_contentsinsrc/react_compiler/codegen.rs) rebuilds the template from that truncated quasi.{ ["a" + "b"]: id }) is the same rope, andlower_object_property_keyinbuild_hir/helpers.rsreturned a Todo error for it, so the whole component silently went uncompiled (output correct, memoization lost). The object method path inbuild_hir/function.rsalready handled this shape.js_parser/ast; this PR covers thereact_compilercrate and does not overlap with that diff.Fix
convert_template_contentsflattens a roped cooked head/tail with a newarena_str_from_ropebefore storing it as the quasi;convert_js_string, which already flattened roped string literals inline, now uses the same helper.lower_object_property_key(helpers.rs) only takes the static-string arm for a non-ropedE::String; a rope falls through to the existing computed arm, wherelower_expressionflattens it. This matches what function.rs does for methods and what upstream does with the unfolded"a" + "b"(a computed key); it is emitted as{ ["ab"]: id }.EString::pushasserts it), so walkingdataof each segment covers the whole string.bun bd test:test/bundler/transpiler/react-compiler.test.ts:FoldedTemplateAndKeyKeepAllSegmentsruns aminify.syntaxbundle and checks the six rope shapes (head and tail from+, head and tail from template folding, two templates joined, a head whose own text is empty) plus the computed key, and that both components allocated a memo cache.FoldedImportSpecifierKeepsAllSegmentschecks the no-minifyimport()case. On the unfixed build the first printspre7,7/mid,a7,7/x,7/one7,7and one memo cache instead of two; the second emits./pages${name}.js.react-compiler.test.ts(38 tests) andreact-compiler-fixtures.test.ts(3293 upstream fixtures, each also run underminify.syntax) pass.Background
"a" + "b", it does not copy the bytes. It links the right operand onto the leftE::Stringthrough itsnextpointer and records the total length inrope_len;datastill holds only"a".slice8()returnsdata, so a reader that wants the whole string has to walknext. (resolve_rope_if_neededflattens in place, but it needs the parser arena and a mutable AST; React Compiler lowering has neither, so it copies into its own arena instead.)${}. Folding"x" + `y${a}`pushes onto the head,`${a}b` + "c"pushes onto the last tail, and`${"a"}b${x}`moves the constant part into the head, so a head can be a rope whose owndatais empty. The rope is only resolved when no${}remains and the template collapses to a plain string.src/react_compilerrewrites component and hook bodies by lowering the post-visit AST to HIR and generating new AST from it, so any text it fails to carry into HIR is missing from the output. A function whose lowering reports a Todo error is left as written.Repro on the unfixed build