diff --git a/src/react_compiler/lowering/build_hir/expr.rs b/src/react_compiler/lowering/build_hir/expr.rs index 2a6c45593e5f..cf6e616c7ee1 100644 --- a/src/react_compiler/lowering/build_hir/expr.rs +++ b/src/react_compiler/lowering/build_hir/expr.rs @@ -1273,6 +1273,8 @@ fn convert_template_contents( E::TemplateContents::Cooked(s) => { let cooked = if s.is_utf16 { arena_utf8_from_utf16(s.slice16(), loc)? + } else if s.next.is_some() { + arena_str_from_rope(s) } else { StoreStr::new(s.slice8()) }; @@ -1301,6 +1303,22 @@ fn arena_utf8_from_utf16( Ok(StoreStr::new(buf.leak())) } +/// Flatten a rope. The parser's string folding ("a" + "b", "a" + `b${x}`, +/// `${"a"}b${x}`) links the operands through `next` instead of copying, so +/// `data` holds only the first segment. String literals and template +/// heads/tails both reach lowering in that shape; ropes are always 8-bit +/// (`EString::push` asserts it). +fn arena_str_from_rope(s: &E::EString) -> StoreStr { + let mut buf: HirVec = AstAlloc::vec_with_capacity(s.len()); + let mut cur = Some(s); + while let Some(seg) = cur { + debug_assert!(!seg.is_utf16); + buf.extend_from_slice(seg.slice8()); + cur = seg.next.as_ref().map(|r| r.get()); + } + StoreStr::new(buf.leak()) +} + // ============================================================================= // lower_reorderable_expression (build_hir.rs:6553-6713) // ============================================================================= @@ -1481,16 +1499,7 @@ fn convert_js_string(s: StoreRef) -> JsString { if s.get().next.is_none() { return JsString::new(s); } - // Roped literal (rare; only from parser-level constant folding): flatten so - // every HIR consumer can ignore ropes. - let mut joined: Vec = Vec::with_capacity(s.get().len()); - let mut cur = Some(s.get()); - while let Some(seg) = cur { - debug_assert!(!seg.is_utf16); - joined.extend_from_slice(seg.slice8()); - cur = seg.next.as_ref().map(|r| r.get()); - } - JsString::from_wtf8_bytes(&joined) + JsString::from_wtf8_bytes(arena_str_from_rope(s.get()).slice()) } fn unsupported_node(node_type: &'static str, loc: Option) -> InstructionValue { diff --git a/src/react_compiler/lowering/build_hir/helpers.rs b/src/react_compiler/lowering/build_hir/helpers.rs index 18dd45369bbb..c63e6381b968 100644 --- a/src/react_compiler/lowering/build_hir/helpers.rs +++ b/src/react_compiler/lowering/build_hir/helpers.rs @@ -1132,11 +1132,12 @@ pub(super) fn lower_object_property_key( computed: bool, ) -> Result, CompilerError> { match &key.data { - Data::EString(s) => { + // A folded computed key (`{["a" + "b"]: x}`) arrives as a rope; it takes + // the computed arm below, where `lower_expression` flattens it, just as + // upstream lowers the unfolded `"a" + "b"` to a computed key. + Data::EString(s) if s.next.is_none() => { let name = if s.is_utf16 { arena_str(&bun_core::strings::to_utf8_alloc(s.slice16())) - } else if s.next.is_some() { - return Err(cold_todo("rope property key", convert_loc(key.loc))); } else { StoreStr::new(s.slice8()) }; diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index 484d4c78cd76..7ea67409e2e9 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -1063,4 +1063,88 @@ describe("bundler", () => { expect(out).toMatch(/__MEMO_CACHE_SENTINEL\)\s*\{[^}]*globalFn\(\)/); }, }); + + // The parser's constant folding (on under minify.syntax) joins strings as a + // rope: the E::String keeps only its first segment in `data` and links the + // rest through `next`. Template heads and tails come out of folding in that + // shape too, and lowering used to read `data` alone, so every segment after + // the first was dropped from the compiled output: "pre" + `fix/${id}` came + // out as `pre${id}`. A folded computed object key is the same rope and used + // to make the whole function bail out of compilation. + itBundled("react-compiler/FoldedTemplateAndKeyKeepAllSegments", { + files: { + "/entry.tsx": /* tsx */ ` + const enum Route { Users = "users" } + export function Links({ id }: { id: string }) { + const head = "pre" + \`fix/\${id}\`; + const tail = \`\${id}/mid\` + "dle"; + const foldedHead = \`a\${"b"}c/\${id}\`; + const foldedTail = \`\${id}/x\${"y"}z\`; + const joined = \`\${id}/one\` + \`two/\${id}\`; + const emptyHead = \`\${Route.Users}/\${id}\`; + return ( + + {id} + + ); + } + export function ComputedKey({ id }: { id: string }) { + const o = { ["a" + "b"]: id }; + return {o.ab}; + } + const { children: _a, ...links } = Links({ id: "7" }).props; + const { children: _b, ...computed } = ComputedKey({ id: "7" }).props; + console.log(JSON.stringify(links)); + console.log(JSON.stringify(computed)); + console.log(globalThis.memoCachesAllocated); + `, + "/node_modules/react/index.js": `module.exports = {};`, + "/node_modules/react/jsx-runtime.js": `exports.jsx = exports.jsxs = (t, p) => ({ t, props: p });`, + "/node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = (t, p) => ({ t, props: p });`, + "/node_modules/react/compiler-runtime.js": ` + exports.c = n => { + globalThis.memoCachesAllocated = (globalThis.memoCachesAllocated ?? 0) + 1; + return new Array(n).fill(Symbol.for("react.memo_cache_sentinel")); + }; + `, + "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, + }, + reactCompiler: true, + target: "browser", + backend: "cli", + minifySyntax: true, + run: { + stdout: [ + '{"href":"prefix/7","data-tail":"7/middle","data-fh":"abc/7","data-ft":"7/xyz","data-j":"7/onetwo/7","data-e":"users/7"}', + '{"data-keys":"ab"}', + // One memo cache per component: both must have been compiled rather + // than left as written. + "2", + ].join("\n"), + }, + }); + + // import() arguments are folded even without minify.syntax, so this rope + // reaches the compiler in a default build; the emitted specifier used to lose + // its "/" and come out as `./pages${name}.js`. + itBundled("react-compiler/FoldedImportSpecifierKeepsAllSegments", { + files: { + "/entry.tsx": /* tsx */ ` + const enum Dir { Pages = "./pages" } + export function Loader({ name }: { name: string }) { + const load = () => import(Dir.Pages + \`/\${name}.js\`); + return ; + } + `, + }, + reactCompiler: true, + target: "browser", + backend: "cli", + external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"], + onAfterBundle(api) { + const out = api.readFile("/out.js"); + expect(out).toMatch(/\b_c\(\d+\)/); + expect(out).toMatch(/import\(`\.\/pages\/\$\{name\}\.js`\)/); + }, + }); });