Skip to content

react_compiler: keep every folded segment of template heads, tails and computed keys - #38993

Open
robobun wants to merge 1 commit into
mainfrom
farm/09b64760/react-compiler-rope-template-quasis
Open

react_compiler: keep every folded segment of template heads, tails and computed keys#38993
robobun wants to merge 1 commit into
mainfrom
farm/09b64760/react-compiler-rope-template-quasis

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • With the React Compiler enabled, a template literal whose head or tail was produced by string folding keeps only the first folded segment in the output: "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 without reactCompiler prints the full text.
  • Folding is file-wide under minify.syntax, and it is always on inside import() / require() arguments, so a default build is affected too: import(Dir.Pages + `/${name}.js`) in a component is emitted as import(`./pages${name}.js`).
  • Cause: fold_string_addition (src/ast/fold_string_addition.rs) and Template::fold (src/ast/e.rs) build the folded head/tail as a rope (see Background). convert_template_contents in src/react_compiler/lowering/build_hir/expr.rs read it with slice8(), which returns data alone, and codegen (template_contents in src/react_compiler/codegen.rs) rebuilds the template from that truncated quasi.
  • Same root cause, lesser symptom: a folded computed object key ({ ["a" + "b"]: id }) is the same rope, and lower_object_property_key in build_hir/helpers.rs returned a Todo error for it, so the whole component silently went uncompiled (output correct, memoization lost). The object method path in build_hir/function.rs already handled this shape.
  • Sibling of Make EString::string() join folded ropes, fixing require.resolve() and other readers of the first segment #38944, which fixes the rope readers in js_parser/ast; this PR covers the react_compiler crate and does not overlap with that diff.

Fix

  • convert_template_contents flattens a roped cooked head/tail with a new arena_str_from_rope before 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-roped E::String; a rope falls through to the existing computed arm, where lower_expression flattens 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 }.
  • Correct because a rope is only a deferred concatenation: the joined segments are exactly the string the program built, and the printer flattens the same rope in place when it prints an uncompiled function, so the compiled output now agrees with the uncompiled one. Ropes are 8-bit by construction (EString::push asserts it), so walking data of each segment covers the whole string.
  • Verified with bun bd test:
    • test/bundler/transpiler/react-compiler.test.ts: FoldedTemplateAndKeyKeepAllSegments runs a minify.syntax bundle 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. FoldedImportSpecifierKeepsAllSegments checks the no-minify import() case. On the unfixed build the first prints pre7, 7/mid, a7, 7/x, 7/one7, 7 and one memo cache instead of two; the second emits ./pages${name}.js.
    • The rest of react-compiler.test.ts (38 tests) and react-compiler-fixtures.test.ts (3293 upstream fixtures, each also run under minify.syntax) pass.

Background

  • Rope: when the visit pass folds "a" + "b", it does not copy the bytes. It links the right operand onto the left E::String through its next pointer and records the total length in rope_len; data still holds only "a". slice8() returns data, so a reader that wants the whole string has to walk next. (resolve_rope_if_needed flattens 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.)
  • Template folding: a template's text is stored as a cooked head plus one tail per ${}. 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 own data is empty. The rope is only resolved when no ${} remains and the template collapses to a plain string.
  • React Compiler lowering: src/react_compiler rewrites 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
$ cat c.tsx
export function C({ id }: { id: string }) {
  const href = "pre" + `fix/${id}`;
  return <a href={href}>{id}</a>;
}
$ cat build.ts
const r = await Bun.build({ entrypoints: ["./c.tsx"], reactCompiler: true, minify: { syntax: true }, external: ["react", "react/*"] });
console.log(await r.outputs[0].text());
$ bun build.ts | grep 'href ='
  let $ = _c(3), { id } = t0, href = `pre${id}`, t1;

$ cat imp.tsx
const enum Dir { Pages = "./pages" }
export function Loader({ name }: { name: string }) {
  const load = () => import(Dir.Pages + `/${name}.js`);
  return <button onClick={load}>{name}</button>;
}
$ bun build --react-compiler --target=browser --external 'react' --external 'react/*' imp.tsx | grep 'import('
    t1 = () => import(`./pages${name}.js`), $[0] = name, $[1] = t1;

… 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.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1b92686a-097d-4865-ae7f-a3813ed50c85

📥 Commits

Reviewing files that changed from the base of the PR and between 732491c and 5e02a70.

📒 Files selected for processing (3)
  • src/react_compiler/lowering/build_hir/expr.rs
  • src/react_compiler/lowering/build_hir/helpers.rs
  • test/bundler/transpiler/react-compiler.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on the 1.4.0 release and on a current canary by bundling a component containing "pre" + followed by a template (Bun.build({ reactCompiler: true, minify: { syntax: true } }) emits pre${id} without the fix/ segment), and on a default build via a folded import() specifier. Both new cases in test/bundler/transpiler/react-compiler.test.ts fail on the unfixed binary and pass with this branch; the existing react-compiler tests and the upstream fixture suite still pass. The automated review above raised nothing actionable; CI is running.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — focused fix that flattens roped EString segments in template quasis and computed keys before HIR lowering.

What was reviewed:

  • arena_str_from_rope walks next and sizes via EString::len(), which returns rope_len for ropes; EString::push asserts 8-bit so slice8() per segment is sound.
  • lower_object_property_key now matches the sibling in function.rs (if s.next.is_none() → rope falls through to the computed arm, where lower_expressionconvert_js_string flattens it); checked that the object-pattern caller already filters computed keys before this point, so computed=false + rope is unreachable there.
  • convert_js_string refactor 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-minify import() 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_expressionconvert_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-on import() folding path without minify. The PR description states these fail on the unfixed build with the specific truncated outputs.
  • The convert_js_string refactor trades a short-lived heap Vec for an arena HirVec that JsString::from_wtf8_bytes then 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 on IsComputed before calling, and passes computed=false, so a rope cannot reach it with computed=false from that path. The object-literal caller passes the real IsComputed flag, which is set for ["a" + "b"].
  • 3293 upstream fixtures + the rest of react-compiler.test.ts reported passing.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:14 AM PT - Aug 15th, 2026

@robobun, your commit 5e02a70 has some failures in Build #97912 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38993

That installs a local version of the PR into your bun-38993 executable, so you can run:

bun-38993 --bun

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant