Skip to content

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

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

robobun wants to merge 4 commits 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 nine rope shapes (head and tail from +, head and tail from template folding, one template folded at both ends, two templates joined, a head whose own text is empty, folded arithmetic in the head and in a middle tail) 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, p7r, 7/one7, 7, n7, 7:7 and one memo cache instead of two; the second emits ./pages${name}.js.
    • The rest of react-compiler.test.ts (47 tests in total) and react-compiler-fixtures.test.ts (3293 upstream fixtures, each also run under minify.syntax) pass. Last run on this branch merged with main 4b5862f.

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;

[human-review] gate passed · iteration 0 · 3 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/react-compiler.test.ts
bun test v1.4.3 (6a92015fc)

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [750.55ms]
(pass) bundler > react-compiler/ComponentWithHooks [241.99ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [253.10ms]
(pass) bundler > react-compiler/UnderscoreAndDollarComponentTags [295.08ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [135.93ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [100.40ms]
(pass) bundler > react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode [322.70ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [109.24ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [121.59ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [112.30ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [254.75ms]
(pass) bundler > react-compiler/BundledCjsCompilerRuntimeSurvivesTreeShaking [432.44ms]
(
... (truncated)

release without fix: 6 failed, 1 skipped
bun test v1.4.3-canary.1 (6a92015fc)

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [16.63ms]
(pass) bundler > react-compiler/ComponentWithHooks [7.18ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [6.19ms]
(pass) bundler > react-compiler/UnderscoreAndDollarComponentTags [6.32ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [5.48ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [3.15ms]
(pass) bundler > react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode [7.64ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [3.47ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [3.11ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [2.82ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [6.30ms]
(pass) bundler > react-compiler/BundledCjsCompilerRuntimeSurvivesTreeShaking [9.54ms]
(pass) bundler > react-compiler/RequireStringPreservesImportRecord [5.35ms]
(pass) bundler > react-compiler/BranchBooleanFeatureFlagPreservesDCE [4.65ms]
(pass) bundler > react-compiler/ForwardR
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/react-compiler.test.ts
bun test v1.4.3 (6a92015fc)

test/bundler/transpiler/react-compiler.test.ts:
(pass) bundler > react-compiler/SimpleComponent [732.73ms]
(pass) bundler > react-compiler/ComponentWithHooks [374.59ms]
(pass) bundler > react-compiler/ObjectPatternRestInProps [319.84ms]
(pass) bundler > react-compiler/UnderscoreAndDollarComponentTags [230.59ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Browser [125.75ms]
(pass) bundler > react-compiler/OutputModeDefaultsByTarget-Bun [103.82ms]
(pass) bundler > react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode [410.12ms]
(pass) bundler > react-compiler/OutputModeExplicitSsrOverridesTarget [138.36ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client [130.60ms]
(pass) bundler > react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr [133.15ms]
(pass) bundler > react-compiler/BundledReactPreservesImportRefs [393.37ms]
(pass) bundler > react-compiler/BundledCjsCompilerRuntimeSurvivesTreeShaking [413.70ms]
(
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 675ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/4] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_react_compiler v0.0.0 (/workspace/bun/src/react_compiler)
�[1m�[92m   Compiling�[0m bun_js_parser v0.0.0 (/workspace/bun/src/js_parser)
�[1m�[92m   Compiling�[0m bun_resolver v0.0.0 (/workspace/bun/src/resolver)
�[1m�[92m   Compiling�[0m bun_ini v0.0.0 (/workspace/bun/src/ini)
�[1m�[92m   Compiling�[0m bun_bundler v0.0.0 (/workspace/bun/src/bundler)
�[1m�[92m   Compiling�[0m bun_router v0.0.0 (/workspace/bun/src/router)
�[1m�[92m   Compiling�[0m bun_standalone_graph v0.0.0 (/workspace/bun/src/standalone_graph)
�[1m�[92m   Compiling�[0m bun_transpiler v0.0.0 (/workspace/bun/src/transpiler)
�[1m�[92m   Compiling�[0m bun_bunfig v0.0.0 (/workspace/bun/src/bunfig)
�[1m�[92m   Compiling�[0m bun_install v0.0.0 (/workspace/bun/src/install)
�[1m�[92m   Compiling�[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
�[1m�[92m   Compiling�[0m bun_js_parser_jsc v0.0.0 (/workspace/bun/src/js_parser_jsc)
�[1m�[92m   Compiling�[0m bun_bundler_jsc v0.0.0 (/w
... (truncated)
diff hotspot
src/react_compiler/lowering/build_hir/expr.rs    |  25 +++---
 src/react_compiler/lowering/build_hir/helpers.rs |   5 +-
 test/bundler/transpiler/react-compiler.test.ts   | 107 +++++++++++++++++++++++
 3 files changed, 124 insertions(+), 13 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                              reads  edits  tests
src/react_compiler/lowering/build_hir/expr.rs         1      1      1
src/react_compiler/lowering/build_hir/helpers.rs      1      1      1
test/bundler/transpiler/react-compiler.test.ts        0      0      1

… 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

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 9efbd2a9-224f-4546-8f13-35c5540eb7ef

📥 Commits

Reviewing files that changed from the base of the PR and between 48658bf and 7bab183.

📒 Files selected for processing (2)
  • src/react_compiler/lowering/build_hir/expr.rs
  • src/react_compiler/lowering/build_hir/helpers.rs

Included review availability: Your plan provides up to 10 included reviews per hour; 4 remain after this review.


Walkthrough

The React Compiler now flattens rope-backed strings through a shared helper. Computed rope keys use computed-expression lowering. Regression tests cover folded templates, concatenated strings, object keys, and dynamic import specifiers.

Changes

String rope lowering

Layer / File(s) Summary
Shared rope string conversion
src/react_compiler/lowering/build_hir/expr.rs
Template and JavaScript string conversion now share arena-backed rope flattening.
Computed key routing and regression coverage
src/react_compiler/lowering/build_hir/helpers.rs, test/bundler/transpiler/react-compiler.test.ts
Rope property keys use computed-expression lowering. Tests cover folded strings, computed keys, memoization, and dynamic import specifiers.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 7bab1

The change is supported by regression coverage and no unresolved merge-blocking risk was identified.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly describes the main change: preserving all folded string segments during React Compiler lowering for template heads, tails, and computed keys.
Description check ✅ Passed The description provides a detailed problem statement, fix summary, verification results, regression coverage, and background. It uses equivalent sections instead of the template headings, but it incl…

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 again on a canary at 6a92015 (2026-09-11). Bundle a component that contains "pre" + followed by a template with Bun.build({ reactCompiler: true, minify: { syntax: true } }): the output is pre${id}, without the fix/ segment. A default build shows it through a folded import() specifier. Both cases in test/bundler/transpiler/react-compiler.test.ts fail on the unfixed binary and pass with this branch.

2026-09-11: merged main (4b5862f) into the branch and shortened two comments that the comment check flagged. react-compiler.test.ts (47 tests) and react-compiler-fixtures.test.ts (3293 fixtures) pass on a debug build of the merged branch.

CI on 7bab183: 180 of 181 jobs pass. The one red job is the x64-asan test lane, which fails on test/js/bun/http/serve-pending-promise-abort-leak.test.ts. That test also fails on main and is not touched by this diff. The react-compiler tests pass on every lane. Ready for review.

@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_expression → convert_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_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-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 2:33 PM PT - Sep 11th, 2026

❌ @robobun, your commit 7bab183 has 1 failures in Build #114437 (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

…ompiler-rope-template-quasis

# Conflicts:
#	test/bundler/transpiler/react-compiler.test.ts
…th ends

Add three shapes to FoldedTemplateAndKeyKeepAllSegments: a constant
substitution at both ends of one template, folded arithmetic that lands
in the head, and folded arithmetic that lands in a middle tail. Template
folding turns the number into a string and links it onto the head or the
tail as one more rope segment, so the unfixed build drops it the same way.
Comment thread src/react_compiler/lowering/build_hir/expr.rs Outdated
Comment thread src/react_compiler/lowering/build_hir/helpers.rs Outdated

@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 rope-handling fix with good variant coverage.

What was reviewed:

  • arena_str_from_rope: EString::len() returns rope_len for ropes, so the HirVec capacity is correct; the buf.leak() pattern matches arena_utf8_from_utf16 right above it.
  • lower_object_property_key fall-through: a folded ["a" + "b"] keeps computed == true, so the rope reaches the _ if computed arm and flattens via lower_expression → convert_js_string; a non-computed string key can never be a rope, so the final Todo arm is unreachable for this shape.
  • convert_js_string refactor: from_wtf8_bytes still receives the full joined bytes; only the backing allocation moved from a temp Vec to the arena.
  • Tests cover head/tail/mid/both-ends/joined/empty-head/numeric folds, the computed-key bailout (memo-cache count = 2), and the always-folded import() path without minify.syntax.
Extended reasoning...

Overview

This PR fixes a React Compiler HIR-lowering bug where roped E::EString values (produced by parser constant folding) were read only from their first segment. Three sites are touched: convert_template_contents gains a s.next.is_some() branch that flattens via a new arena_str_from_rope helper; convert_js_string is refactored to reuse that helper instead of an inline flatten loop; and lower_object_property_key guards its EString arm with s.next.is_none() so a folded computed key falls through to the existing computed arm rather than returning a cold_todo that bailed the whole component. Two itBundled tests are added covering nine template-rope shapes, the computed-key case (asserting both components allocate memo caches), and the always-folded import() specifier path.

Security risks

None. This is AST→HIR string handling in the bundler's React Compiler pass — no auth, crypto, network, filesystem, or untrusted-length arithmetic. The only allocation sizing (AstAlloc::vec_with_capacity(s.len())) is derived from the parser's own rope_len, and I confirmed in src/ast/e.rs that EString::len() returns rope_len when the string is roped, so the buffer is sized for the joined result.

Level of scrutiny

Low-to-medium. The Rust change is ~15 net lines and mirrors the existing arena_utf8_from_utf16 pattern (arena HirVec<u8> + .leak()), so the arena/Drop concern from CLAUDE.md doesn't apply — u8 has no destructor. I traced the helpers.rs fall-through: a folded ["a" + "b"] retains computed == true on the property, so it hits the _ if computed arm and lowers via lower_expression_to_temporary, which reaches convert_js_string and flattens correctly. A non-computed string-literal key cannot be a rope (folding only happens on +/template expressions, which are syntactically computed), so the final Todo arm is not newly reachable. The convert_js_string refactor is behavior-preserving: from_wtf8_bytes still receives the same joined bytes, just from an arena slice instead of a stack Vec.

Other factors

REVIEW.md's "extract a helper the second time a block appears" and "fix the whole class" rules are followed — the inline flatten loop in convert_js_string was deduplicated into arena_str_from_rope, and the PR description names #38944 as the sibling PR covering the js_parser/ast crate readers. Test coverage hits the variant matrix REVIEW.md asks for (head, tail, mid, both-ends, joined templates, empty-head const-enum, numeric folds in head and mid-tail, computed key, and the no-minify import() path), asserts exact stdout, and asserts the memo-cache count so a silent bailout would fail. No CODEOWNERS entry covers these paths. There is no outstanding third-party CHANGES_REQUESTED; the earlier COMMENTED review and bot inline threads were followed by three commits on this branch, and the current diff looks clean against the concerns I checked.

This branch has not been deployed

No deployments
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