From 650dfb1773f90760702ca4fbaef924e758ca4271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sun, 4 Oct 2026 13:52:29 -0300 Subject: [PATCH 1/2] fix(compiler): preserve TSRX expression and comment spans --- .changeset/fix-parenthesized-tsrx-keys.md | 5 + .../__tests__/tsrx-for-semantics.test.js | 65 ++++- .../compiler/__tests__/tsrx-runtime.test.js | 112 ++++++-- .../__tests__/tsrx-source-comments.test.js | 63 +++++ packages/compiler/src/tsrx/leaf.rs | 262 +++++++++++++++++- packages/compiler/src/tsrx/lower.rs | 55 +++- 6 files changed, 526 insertions(+), 36 deletions(-) create mode 100644 .changeset/fix-parenthesized-tsrx-keys.md create mode 100644 packages/compiler/__tests__/tsrx-source-comments.test.js diff --git a/.changeset/fix-parenthesized-tsrx-keys.md b/.changeset/fix-parenthesized-tsrx-keys.md new file mode 100644 index 000000000..aef5f276c --- /dev/null +++ b/.changeset/fix-parenthesized-tsrx-keys.md @@ -0,0 +1,5 @@ +--- +"@solidjs/compiler": patch +--- + +Fix native TSRX compilation of parenthesized keys, iterables, and conditions, including annotated `@for` loops used directly in a component body. Map comments back to the authored source to prevent invalid JavaScript and Unicode panics when source maps are enabled. diff --git a/packages/compiler/__tests__/tsrx-for-semantics.test.js b/packages/compiler/__tests__/tsrx-for-semantics.test.js index 73c738929..3df6d545c 100644 --- a/packages/compiler/__tests__/tsrx-for-semantics.test.js +++ b/packages/compiler/__tests__/tsrx-for-semantics.test.js @@ -1,4 +1,4 @@ -const { compileBabel, compileOxc, modes } = require("./parity/harness"); +const { compileBabel, compileOxc, modes, normalize } = require("./parity/harness"); const source = ` export function Rows({ rows }) @{ @@ -53,3 +53,66 @@ describe("TSRX @for semantics", () => { ); }); }); + +// Keep the published parser's grammar: parenthesized key values are supported, +// while index bindings still use a bare identifier. +describe.each(["dom", "ssr", "universal"])("parenthesized @for keys (%s)", mode => { + test.each([ + ["(item.id)", 3], + ["(((item.id)))", 3], + ["( /* before */ (item.id) /* after */ )", 3], + ["((item.id + 1) * 2)", 8], + ["(item.id, item.other)", 7], + ["(item.id ? (item.other ?? 9) : 0)", 7] + ])("preserves emitted code and key evaluation for key%s", (key, expected) => { + const source = `export function F(items) @{ @for(const item of items; key${key}) {
} }`; + const options = modes[`tsrx-${mode}`].options; + const babel = compileBabel(source, options, "parenthesized-key.tsrx"); + const native = compileOxc(source, "parenthesized-key", options, ".tsrx"); + expect(normalize(native)).toBe(normalize(babel)); + for (const output of [babel, native]) { + expect(output).not.toMatch(/__tsrx/); + // Execute the emitted key callback independently of renderer helpers. + const core = require("@babel/core"); + const callbacks = []; + core.transformSync(output, { + configFile: false, + babelrc: false, + plugins: [ + () => ({ + visitor: { + ObjectProperty(path) { + if (path.node.key.name !== "keyed") return; + const expression = core.transformFromAstSync( + core.types.file( + core.types.program([core.types.expressionStatement(path.node.value)]) + ), + null, + { configFile: false, babelrc: false } + ).code; + callbacks.push(new Function(`return (${expression.replace(/;$/, "")});`)()); + } + } + }) + ] + }); + expect(callbacks).toHaveLength(1); + expect(callbacks[0]({ id: 3, other: 7 })).toBe(expected); + } + }); +}); + +describe.each(["dom", "ssr", "universal"])("deep parenthesized control anchors (%s)", mode => { + test.each([ + "@for(const item of (((items))); key item.id) {
}", + "@for(const item of (((items)))) {
}", + "@if ((((ready)))) {
}", + "@if ((/* outer */ ((ready)))) {
}" + ])("matches Babel for %s", control => { + const source = `export function F(items, ready) @{ ${control} }`; + const options = modes[`tsrx-${mode}`].options; + expect(normalize(compileOxc(source, "deep-control", options, ".tsrx"))).toBe( + normalize(compileBabel(source, options, "deep-control.tsrx")) + ); + }); +}); diff --git a/packages/compiler/__tests__/tsrx-runtime.test.js b/packages/compiler/__tests__/tsrx-runtime.test.js index 7fb40979b..5f4dfc8e7 100644 --- a/packages/compiler/__tests__/tsrx-runtime.test.js +++ b/packages/compiler/__tests__/tsrx-runtime.test.js @@ -54,7 +54,7 @@ function compileRuntime(source, compiler, generate) { // prod (signals' `DEV` export is undefined) and the first click throws. Every // flag the runtime packages gate on must be listed in BOTH places: a flag the // define misses is a ReferenceError at first use. -const workspaceSourceRoots = ["solid", "web", "signals"].map( +const workspaceSourceRoots = ["solid", "web", "signals", "universal"].map( name => path.join(repoRoot, "packages", name, "src") + path.sep ); @@ -62,7 +62,13 @@ async function loadRuntimeModule(code, generate) { const aliases = new Map([ [ "@solidjs/web", - path.join(repoRoot, "packages/web/src", generate === "ssr" ? "index.server.ts" : "index.ts") + generate === "universal" + ? path.join(repoRoot, "packages/universal/test/custom.js") + : path.join( + repoRoot, + "packages/web/src", + generate === "ssr" ? "index.server.ts" : "index.ts" + ) ], ["solid-js", path.join(repoRoot, "packages/solid/src/index.ts")], // The web runtime reaches solid-js's seams through this subpath (#3470). @@ -99,6 +105,11 @@ async function loadRuntimeModule(code, generate) { path: aliases.get(args.path) }) ); + esbuild.onLoad({ filter: /\/universal\/test\/custom\.js$/ }, args => ({ + contents: fs.readFileSync(args.path, "utf8") + '\nexport * from "solid-js";', + loader: "js", + resolveDir: path.dirname(args.path) + })); esbuild.onLoad({ filter: /\.ts$/ }, args => { if (!workspaceSourceRoots.some(root => args.path.startsWith(root))) return; return { @@ -188,7 +199,11 @@ describe.each(["babel", "oxc"])("%s TSRX runtime behavior", compiler => { dispose(); }); - test("passes For and Errored accessor bindings through as authored", async () => { + test.each( + ["dom", "universal"].flatMap(mode => + [" row.id", "(row.id)", "(/* key */ ((row.id)))"].map(key => [mode, key]) + ) + )("passes For and Errored accessor bindings through in %s with key%s", async (mode, key) => { // #3474: the bindings are the accessors Solid hands out. `row()` and `i()` // read live under a custom key, `err()` is the ErrorAccessor. const source = ` @@ -209,7 +224,7 @@ describe.each(["babel", "oxc"])("%s TSRX runtime behavior", compiler => { export function App() @{
    - @for (const row of rows(); index i; key row.id) { + @for (const row of rows(); index i; key${key}) { const snapshot = row;
  • {i()}:{row().label ?? row().id}:{row().extra}
  • } @@ -231,7 +246,7 @@ describe.each(["babel", "oxc"])("%s TSRX runtime behavior", compiler => { flush(); } `; - const runtime = await loadRuntimeModule(compileRuntime(source, compiler, "dom"), "dom"); + const runtime = await loadRuntimeModule(compileRuntime(source, compiler, mode), mode); const root = document.createElement("div"); const dispose = runtime.mount(root); @@ -254,6 +269,54 @@ describe.each(["babel", "oxc"])("%s TSRX runtime behavior", compiler => { dispose(); }); + test.each( + ["dom", "ssr", "universal"].flatMap(mode => + ["key item.id", "index i", "index i; key item.id"].map(annotation => [mode, annotation]) + ) + )("renders a statement-position annotated loop in %s with %s", async (mode, annotation) => { + const source = ` + import { createSignal, flush } from "solid-js"; + import { ${mode === "ssr" ? "renderToString" : "render"} } from "@solidjs/web"; + const [items, setItems] = createSignal([ + { id: 1, label: "one" }, { id: 2, label: "two" } + ]); + export function App() @{ + @for (const item of items(); ${annotation}) { + {item().label} + } + } + export function run(target) { + return ${mode === "ssr" ? "renderToString(App)" : "render(App, target)"}; + } + export function replaceItems(next) { + setItems(next); + flush(); + } + `; + const output = compileRuntime(source, compiler, mode); + expect(output).not.toMatch(/__tsrx/); + const runtime = await loadRuntimeModule(output, mode); + if (mode === "ssr") { + expect(runtime.run()).toBe('onetwo'); + return; + } + const root = document.createElement("div"); + const dispose = runtime.run(root); + expect(root.textContent).toBe("onetwo"); + const retained = root.querySelector('[data-id="2"]'); + runtime.replaceItems([ + { id: 2, label: "TWO" }, + { id: 3, label: "three" } + ]); + expect(root.textContent).toBe("TWOthree"); + if (annotation.includes("key")) { + expect(root.querySelector('[data-id="2"]')).toBe(retained); + } + runtime.replaceItems([]); + expect(root.textContent).toBe(""); + dispose(); + }); + test("rejects destructuring where Solid passes an accessor", () => { const cases = [ [ @@ -321,22 +384,25 @@ describe.each(["babel", "oxc"])("%s TSRX runtime behavior", compiler => { dispose(); }); - test("renders SSR branches, keyed lists, and empty fallbacks", async () => { - const source = readRuntimeFixture("ssr"); - const runtime = await loadRuntimeModule(compileRuntime(source, compiler, "ssr"), "ssr"); - - expect( - runtime.renderPage({ - show: true, - items: [ - { id: 1, label: "one" }, - { id: 2, label: "two" } - ] - }) - ).toBe("
    • one
    • two
    "); - expect(runtime.renderPage({ show: true, items: [] })).toBe( - "
    • empty
    " - ); - expect(runtime.renderPage({ show: false, items: [] })).toBe("

    hidden

    "); - }); + test.each([" item.id", "(item.id)", "(/* key */ ((item.id)))"])( + "renders SSR branches, keyed lists, and empty fallbacks with key%s", + async key => { + const source = readRuntimeFixture("ssr").replace("key item.id", `key${key}`); + const runtime = await loadRuntimeModule(compileRuntime(source, compiler, "ssr"), "ssr"); + + expect( + runtime.renderPage({ + show: true, + items: [ + { id: 1, label: "one" }, + { id: 2, label: "two" } + ] + }) + ).toBe("
    • one
    • two
    "); + expect(runtime.renderPage({ show: true, items: [] })).toBe( + "
    • empty
    " + ); + expect(runtime.renderPage({ show: false, items: [] })).toBe("

    hidden

    "); + } + ); }); diff --git a/packages/compiler/__tests__/tsrx-source-comments.test.js b/packages/compiler/__tests__/tsrx-source-comments.test.js new file mode 100644 index 000000000..aaa3f4420 --- /dev/null +++ b/packages/compiler/__tests__/tsrx-source-comments.test.js @@ -0,0 +1,63 @@ +const babel = require("@babel/core"); +const { compileBabel, compileOxc, modes } = require("./parity/harness"); + +function assertValid(code) { + expect(() => babel.parseSync(code, { babelrc: false, configFile: false })).not.toThrow(); + expect(code).not.toMatch(/_t\d+_(?:R|K|I|H)\d/); +} + +describe.each(["dom", "ssr", "universal"])("TSRX authored comments (%s)", mode => { + const options = { ...modes[`tsrx-${mode}`].options, sourceMap: true }; + test("emits valid JavaScript across projected comment offsets", () => { + for (let width = 0; width <= 32; width++) { + const key = `item.id + "${"a".repeat(width)}"`; + const sources = [ + `export function F(items, ready) @{ @for(const item of items; key(${key})) {
    } }`, + `export function F(items, ready) @{ @for(const item of items; key ${key}) {
    } }`, + `const v = @for(const item of items; key ${key}) {
    };`, + `const v = @for(const item of items; key(${key})) {
    };`, + `const v = @for(const item of items; index i; key ${key}) {
    {i()}
    };`, + `export function F(items) @{
      @for(const item of items; key ${key}) {
    • }
    }`, + `export function F(ready) @{ @if (ready === "${"a".repeat(width)}") {
    } }`, + `const v = @switch (value) { @case "${"a".repeat(width)}": {
    } @default: { } };` + ]; + for (const source of sources) assertValid(compileOxc(source, "comments", options, ".tsrx")); + } + }); + + test("emits a valid conditional key after LF", () => { + const source = + "export function F(items) @{ @for(const item of items;\nkey(item.active ? item.id : item.other)) {
    } }"; + assertValid(compileBabel(source, options, "comments.tsrx")); + assertValid(compileOxc(source, "comments", options, ".tsrx")); + }); + + test("preserves authored comments and annotations after projection", () => { + const source = ` + /*! authored license */ + export function F(items) @{ + // authored setup + const local = /* @__PURE__ */ factory(); + @for(const item of items; key(/* authored key */ item.id)) {
    {local}
    } + } + /* @__NO_SIDE_EFFECTS__ */ + function helper() { return 1; } + // authored tail + export const result = /* #__PURE__ */ helper(); + `; + const output = compileOxc(source, "comments", options, ".tsrx"); + assertValid(output); + for (const comment of [ + "authored license", + "authored setup", + "authored tail", + "@__NO_SIDE_EFFECTS__", + "#__PURE__" + ]) { + expect(output).toContain(comment); + } + // Babel also drops the key comment; it must not leak authored source instead. + expect(output).not.toMatch(/item\.id\)\) \{/); + expect(output).toMatch(/@__PURE__[\s\S]*factory\(\)/); + }); +}); diff --git a/packages/compiler/src/tsrx/leaf.rs b/packages/compiler/src/tsrx/leaf.rs index cfa7a3a23..f29d3e961 100644 --- a/packages/compiler/src/tsrx/leaf.rs +++ b/packages/compiler/src/tsrx/leaf.rs @@ -1,7 +1,9 @@ //! Load ordinary JavaScript, TypeScript, and JSX leaves from the parser's //! legal-TSX scaffold without deserializing `FlatTape`. -use oxc_allocator::{Allocator, CloneIn}; +use std::collections::HashMap; + +use oxc_allocator::{Allocator, CloneIn, TakeIn}; use oxc_ast::ast::{BindingPattern, Expression, Program, Statement}; use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; use oxc_span::{GetSpan, SourceType, Span}; @@ -16,6 +18,7 @@ use crate::error::CompileError; pub(super) struct LeafProgram<'a> { pub program: Program<'a>, pub(super) map: LeafMap, + parenthesized: HashMap, marker_prefix: String, control_contexts: Vec, } @@ -39,16 +42,26 @@ impl<'a> LeafProgram<'a> { let projected = allocator.alloc_str(projection.source()); let parsed = oxc_parser::Parser::new(allocator, projected, SourceType::tsx()) .with_options(oxc_parser::ParseOptions { - preserve_parens: false, + preserve_parens: true, ..oxc_parser::ParseOptions::default() }) .parse(); if let Some(error) = crate::shared::parser::first_parser_error(parsed.diagnostics) { return Err(CompileError::parse(error)); } + let map = LeafMap::new(projection.view().segments); + let mut program = parsed.program; + let mut parentheses = ParenthesesNormalizer { + allocator, + map: &map, + authored: HashMap::new(), + }; + parentheses.visit_program(&mut program); + let parenthesized = parentheses.authored; Ok(Self { - program: parsed.program, - map: LeafMap::new(projection.view().segments), + program, + map, + parenthesized, marker_prefix, control_contexts, }) @@ -70,13 +83,36 @@ impl<'a> LeafProgram<'a> { SpanRebaser { map: &self.map }.visit_program(&mut self.program); } + pub fn parenthesized_starts(&self) -> HashMap { + self.parenthesized + .iter() + .map(|(outer, inner)| (outer.start, inner.start)) + .collect() + } + pub fn finish(mut self, authored_source: &'a str) -> Program<'a> { + // Comments are not visited by SpanRebaser. Keep only verbatim authored + // comments, then rebase both their text and their attachment before + // switching source_text. Scaffold offsets must never index authored UTF-8. + let starts = self.parenthesized_starts(); + let map = &self.map; + self.program.comments.retain_mut(|comment| { + let Some(authored) = map.authored_span(comment.span) else { + return false; + }; + comment.span = Span::new(authored.start, authored.end); + let attached = map + .authored_endpoint(comment.attached_to, true) + .unwrap_or(authored.end); + comment.attached_to = starts.get(&attached).copied().unwrap_or(attached); + true + }); self.program.source_text = authored_source; self.program } - /// Clone the smallest expression whose unchanged projected span exactly - /// corresponds to `authored`. + /// Clone the expression corresponding to `authored`, including authored + /// parentheses removed before lowering. pub fn expression( &self, allocator: &'a Allocator, @@ -85,7 +121,11 @@ impl<'a> LeafProgram<'a> { let mut finder = ExpressionFinder { allocator, map: &self.map, - target: authored, + target: self + .parenthesized + .get(&authored) + .copied() + .unwrap_or(authored), found: None, }; finder.visit_program(&self.program); @@ -205,6 +245,31 @@ impl LeafMap { } } +/// Keep authored parenthesis spans available for leaf lookup, then unwrap +/// expression nodes before scaffold and JSX lowering. +/// Only contiguous authored spans become aliases; synthetic scaffolds must not +/// acquire an authored expression identity from their surrounding parentheses. +struct ParenthesesNormalizer<'a, 'm> { + allocator: &'a Allocator, + map: &'m LeafMap, + authored: HashMap, +} + +impl<'a> VisitMut<'a> for ParenthesesNormalizer<'a, '_> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + walk_mut::walk_expression(self, expression); + if let Expression::ParenthesizedExpression(parenthesized) = expression { + if let (Some(outer), Some(inner)) = ( + self.map.authored_span(parenthesized.span), + self.map.authored_span(parenthesized.expression.span()), + ) { + self.authored.insert(outer, inner); + } + *expression = parenthesized.expression.take_in(&self.allocator); + } + } +} + struct ExpressionFinder<'a, 'm> { allocator: &'a Allocator, map: &'m LeafMap, @@ -301,6 +366,189 @@ impl<'a> VisitMut<'a> for SpanRebaser<'_> { mod tests { use super::*; + #[test] + fn finished_comments_have_authored_provenance() { + let source = "/*! license 🚀 */ export function F(items) @{ const local = /* @__PURE__ */ factory(); @for(const item of items; key(/* key 🚀 */ item.id)) {
    {local}
    } }"; + let allocator = Allocator::default(); + let leaves = LeafProgram::parse(&allocator, source).unwrap(); + let expected = leaves + .program + .comments + .iter() + .filter_map(|comment| { + leaves + .map + .authored_span(comment.span) + .map(|span| Span::new(span.start, span.end)) + }) + .collect::>(); + assert_eq!(expected.len(), 3); + assert!( + leaves.program.comments[0].is_leading(), + "{:?}", + leaves.program.comments[0] + ); + let program = leaves.finish(source); + assert_eq!( + program + .comments + .iter() + .map(|comment| comment.span) + .collect::>(), + expected + ); + for comment in &program.comments { + assert!(comment.span.source_text(source).starts_with("/*")); + assert!(source.is_char_boundary(comment.attached_to as usize)); + } + } + + #[test] + fn preserves_codegen_comment_contract() { + let source = "/*! license */ export function F(items) @{ const local = /* @__PURE__ */ factory(); @for(const item of items; key(/* key */ item.id)) {
    {local}
    } }"; + let allocator = Allocator::default(); + let lowered = + super::super::run_compiler_frontend(&allocator, source, Some("comment.tsrx")).unwrap(); + assert_eq!(lowered.program.comments.len(), 3); + let output = crate::compile( + source, + &crate::CompileOptions { + filename: Some("comment.tsrx".into()), + source_map: true, + ..crate::CompileOptions::default() + }, + ) + .unwrap(); + assert!(output.code.contains("license"), "{}", output.code); + } + + #[test] + fn codegen_comments_use_authored_utf8_offsets() { + for generate in [ + crate::Generate::Dom, + crate::Generate::Ssr, + crate::Generate::Universal, + ] { + for value in ["🚀", "x🚀x", "é", "abcd"] { + let source = format!( + "export function F(items, ready) @{{ @for(const item of items; key(item.id + \"{value}\")) {{
    }} }}" + ); + let result = std::panic::catch_unwind(|| { + crate::compile( + &source, + &crate::CompileOptions { + filename: Some("comments.tsrx".into()), + generate, + source_map: true, + ..crate::CompileOptions::default() + }, + ) + }); + let output = result + .expect("codegen must not panic on authored UTF-8") + .expect("valid TSRX"); + let allocator = Allocator::default(); + let parsed = + oxc_parser::Parser::new(&allocator, &output.code, SourceType::mjs()).parse(); + assert!( + parsed.diagnostics.is_empty(), + "invalid generated JS: {}", + output.code + ); + } + } + } + + #[test] + fn loads_parenthesized_key_with_authored_inner_span() { + for key in [ + "(item.id)", + "(((item.id)))", + "( /* before */ (item.id) /* after */ )", + ] { + let source = format!( + "export function F(items) @{{ @for(const item of items; key{key}) {{
    }} }}" + ); + let allocator = Allocator::default(); + let leaves = LeafProgram::parse(&allocator, &source).expect("parser scaffold"); + let start = source.find(key).unwrap() as u32; + let expression = leaves + .expression( + &allocator, + AuthoredSpan { + start, + end: start + key.len() as u32, + }, + ) + .expect("parenthesized authored key"); + assert!(matches!(expression, Expression::StaticMemberExpression(_))); + let inner = source.find("item.id").unwrap() as u32; + assert_eq!(expression.span(), Span::new(inner, inner + 7)); + } + } + + #[test] + fn normalizes_parentheses_before_scaffold_lowering() { + let source = "export function F(items, ready) @{ @if ((ready && items)) {
    {((ready ? 1 : 2) + 3) * 4}
    } @for(const item of items; key((item.id))) {
    } }"; + let allocator = Allocator::default(); + let leaves = LeafProgram::parse(&allocator, source).unwrap(); + let baseline = + oxc_parser::Parser::new(&allocator, leaves.program.source_text, SourceType::tsx()) + .with_options(oxc_parser::ParseOptions { + preserve_parens: false, + ..oxc_parser::ParseOptions::default() + }) + .parse(); + assert!(baseline.diagnostics.is_empty()); + // Includes scaffold parentheses and precedence-sensitive expressions. + assert_eq!( + oxc_codegen::Codegen::new().build(&leaves.program).code, + oxc_codegen::Codegen::new().build(&baseline.program).code, + ); + struct NoParentheses; + impl<'a> Visit<'a> for NoParentheses { + fn visit_expression(&mut self, expression: &Expression<'a>) { + assert!(!matches!( + expression, + Expression::ParenthesizedExpression(_) + )); + walk::walk_expression(self, expression); + } + } + NoParentheses.visit_program(&leaves.program); + } + + #[test] + fn does_not_alias_parentheses_across_synthetic_segments() { + let allocator = Allocator::default(); + let mut parsed = oxc_parser::Parser::new(&allocator, "(value)", SourceType::tsx()) + .with_options(oxc_parser::ParseOptions { + preserve_parens: true, + ..oxc_parser::ParseOptions::default() + }) + .parse(); + let map = LeafMap { + segments: vec![LeafSegment { + projected: Span::new(1, 6), + authored_start: 10, + }], + }; + let mut normalizer = ParenthesesNormalizer { + allocator: &allocator, + map: &map, + authored: HashMap::new(), + }; + normalizer.visit_program(&mut parsed.program); + assert!(normalizer.authored.is_empty()); + assert_eq!( + oxc_codegen::Codegen::new() + .build(&parsed.program) + .code + .trim(), + "value;" + ); + } + #[test] fn loads_authored_expressions_from_parser_scaffolds() { let source = "export function View({ ready, value }: Props) @{ const local = 1; @if (ready) {

    {value + local}

    } }"; diff --git a/packages/compiler/src/tsrx/lower.rs b/packages/compiler/src/tsrx/lower.rs index bbb074d98..79aa9077e 100644 --- a/packages/compiler/src/tsrx/lower.rs +++ b/packages/compiler/src/tsrx/lower.rs @@ -93,6 +93,7 @@ pub(super) fn lower<'a>( semantic, leaves: &leaves, }; + let parenthesized_starts = leaves.parenthesized_starts(); let mut expression_replacements = HashMap::new(); let mut statement_replacements = HashMap::new(); for (index, (control, context)) in controls @@ -107,6 +108,7 @@ pub(super) fn lower<'a>( "a statement control-flow construct is missing its anchor", )); }; + let anchor = parenthesized_starts.get(&anchor).copied().unwrap_or(anchor); statement_replacements.insert(anchor, expression); } else { expression_replacements.insert(leaves.wrapper_name(index), expression); @@ -128,6 +130,7 @@ pub(super) fn lower<'a>( "a code block render expression is missing its authored span", )); }; + let start = parenthesized_starts.get(&start).copied().unwrap_or(start); code_block_replacements.insert(start, lowerer.code_block(block)?); code_block_origins.insert(start, ast_span(block.origin.span)); } @@ -138,6 +141,8 @@ pub(super) fn lower<'a>( leaves.rebase(); let mut replacer = ScaffoldReplacer { ast: AstBuilder::new(allocator), + marker_prefix: &marker_prefix, + parenthesized_starts: &parenthesized_starts, expression_replacements, statement_replacements, code_block_replacements, @@ -181,6 +186,7 @@ pub(super) fn lower<'a>( FunctionCodeBlockFinalizer { ast: AstBuilder::new(allocator), code_blocks: &mut code_blocks, + parenthesized_starts: &parenthesized_starts, } .visit_program(&mut leaves.program); if !code_blocks.is_empty() { @@ -1525,14 +1531,16 @@ fn jsx_attribute_name<'a>(name: &'a JSXAttributeName<'_>) -> Option<&'a str> { } } -struct ScaffoldReplacer<'a> { +struct ScaffoldReplacer<'a, 's> { + marker_prefix: &'s str, + parenthesized_starts: &'s HashMap, ast: AstBuilder<'a>, expression_replacements: HashMap>, statement_replacements: HashMap>, code_block_replacements: HashMap>, } -impl<'a> ScaffoldReplacer<'a> { +impl<'a> ScaffoldReplacer<'a, '_> { fn take_expression_replacement( &mut self, expression: &Expression<'a>, @@ -1552,11 +1560,37 @@ impl<'a> ScaffoldReplacer<'a> { } } -impl<'a> VisitMut<'a> for ScaffoldReplacer<'a> { +impl<'a> VisitMut<'a> for ScaffoldReplacer<'a, '_> { fn visit_statement(&mut self, statement: &mut Statement<'a>) { let anchor = match statement { Statement::IfStatement(statement) => Some(statement.test.span()), - Statement::ForOfStatement(statement) => Some(statement.right.span()), + Statement::ForOfStatement(statement) => { + // Annotated headers wrap the authored iterable in a synthetic + // H{ordinal}_ call. Its own span is erased during rebasing. + let iterable = match &statement.right { + Expression::CallExpression(call) + if call + .callee + .get_identifier_reference() + .is_some_and(|callee| { + callee + .name + .as_str() + .strip_prefix(self.marker_prefix) + .and_then(|suffix| suffix.strip_prefix('H')) + .and_then(|suffix| suffix.strip_suffix('_')) + .is_some_and(|ordinal| { + !ordinal.is_empty() + && ordinal.bytes().all(|byte| byte.is_ascii_digit()) + }) + }) => + { + call.arguments.first().and_then(Argument::as_expression) + } + expression => Some(expression), + }; + iterable.map(GetSpan::span) + } Statement::SwitchStatement(statement) => Some(statement.discriminant.span()), Statement::TryStatement(statement) => { statement @@ -1572,7 +1606,12 @@ impl<'a> VisitMut<'a> for ScaffoldReplacer<'a> { } _ => None, } - .map(|span| span.start); + .map(|span| { + self.parenthesized_starts + .get(&span.start) + .copied() + .unwrap_or(span.start) + }); if let Some(replacement) = anchor.and_then(|anchor| remove_near(&mut self.statement_replacements, anchor)) { @@ -1728,6 +1767,7 @@ impl<'a> VisitMut<'a> for DynamicElementAnchorer<'_> { struct FunctionCodeBlockFinalizer<'a, 'c> { ast: AstBuilder<'a>, + parenthesized_starts: &'c HashMap, code_blocks: &'c mut HashMap, } @@ -1754,6 +1794,11 @@ impl<'a> VisitMut<'a> for FunctionCodeBlockFinalizer<'a, '_> { } expression => first_authored_start(expression).unwrap_or(expression.span().start), }; + let start = self + .parenthesized_starts + .get(&start) + .copied() + .unwrap_or(start); let Some(span) = remove_near(self.code_blocks, start) else { return; }; From c64668b3b22eaac351a2242bb7a38389d69bb3d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Mon, 5 Oct 2026 00:24:46 -0300 Subject: [PATCH 2/2] chore(ci): rerun performance validation