diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 6adfc2c41..daa9e0f34 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -3919,9 +3919,18 @@ pub fn transform_angular_file( compute_effective_start(class, &decorator_spans_to_remove, stmt_start), class.body.span.end, )); + // Constants pooled by the host bindings are declared before the class. + let mut decls_before_class = String::new(); + for stmt in &definitions.statements { + if !decls_before_class.is_empty() { + decls_before_class.push('\n'); + } + decls_before_class.push_str(&emitter.emit_statement(stmt)); + } + class_definitions.insert( class_name, - (property_assignments, String::new(), decls_after_class), + (property_assignments, decls_before_class, decls_after_class), ); } else if let Some(mut pipe_metadata) = extract_pipe_metadata_in( allocator, diff --git a/crates/oxc_angular_compiler/src/directive/definition.rs b/crates/oxc_angular_compiler/src/directive/definition.rs index e5330b57e..1e3486342 100644 --- a/crates/oxc_angular_compiler/src/directive/definition.rs +++ b/crates/oxc_angular_compiler/src/directive/definition.rs @@ -21,7 +21,7 @@ use crate::factory::{ FactoryTarget, R3ConstructorFactoryMetadata, R3DependencyMetadata, R3FactoryDeps, R3FactoryMetadata, compile_factory_function, }; -use crate::output::ast::OutputExpression; +use crate::output::ast::{OutputExpression, OutputStatement}; use crate::partial::directive::{ compile_declare_directive_from_metadata, compile_declare_factory_for_directive, }; @@ -37,6 +37,9 @@ pub struct DirectiveDefinitions<'a> { /// The next available pool index after compilation. /// Used to track constant pool usage across multiple directives in the same file. pub next_pool_index: u32, + + /// Constants the host bindings refer to, to be declared before the class. + pub statements: oxc_allocator::Vec<'a, OutputStatement<'a>>, } /// Generate ɵdir and ɵfac definitions for a directive. @@ -88,14 +91,14 @@ pub fn generate_directive_definitions<'a>( match compilation_mode { CompilationMode::Full => { let fac_definition = generate_fac_definition(allocator, core_namespace, metadata); - let (dir_definition, next_pool_index) = generate_dir_definition( + let (dir_definition, next_pool_index, statements) = generate_dir_definition( allocator, core_namespace, metadata, pool_starting_index, angular_version, ); - DirectiveDefinitions { dir_definition, fac_definition, next_pool_index } + DirectiveDefinitions { dir_definition, fac_definition, next_pool_index, statements } } CompilationMode::Partial => { // Partial mode doesn't use the constant pool — the linker does @@ -111,6 +114,7 @@ pub fn generate_directive_definitions<'a>( dir_definition, fac_definition, next_pool_index: pool_starting_index, + statements: oxc_allocator::Vec::new_in(&allocator), } } } @@ -137,7 +141,7 @@ fn generate_dir_definition<'a>( metadata: &R3DirectiveMetadata<'a>, pool_starting_index: u32, angular_version: Option, -) -> (OutputExpression<'a>, u32) { +) -> (OutputExpression<'a>, u32, oxc_allocator::Vec<'a, OutputStatement<'a>>) { let result = compile_directive( allocator, core_namespace, @@ -145,7 +149,7 @@ fn generate_dir_definition<'a>( pool_starting_index, angular_version, ); - (result.expression, result.next_pool_index) + (result.expression, result.next_pool_index, result.statements) } /// Generate the ɵfac factory function. @@ -445,7 +449,8 @@ mod tests { let allocator = Allocator::default(); let metadata = create_test_metadata(&allocator); - let (dir, _next_pool_index) = generate_dir_definition(&allocator, "i0", &metadata, 0, None); + let (dir, _next_pool_index, _statements) = + generate_dir_definition(&allocator, "i0", &metadata, 0, None); let emitter = JsEmitter::new(); let js = emitter.emit_expression(&dir); diff --git a/crates/oxc_angular_compiler/src/ir/expression.rs b/crates/oxc_angular_compiler/src/ir/expression.rs index e20b82741..a779a79a4 100644 --- a/crates/oxc_angular_compiler/src/ir/expression.rs +++ b/crates/oxc_angular_compiler/src/ir/expression.rs @@ -785,6 +785,7 @@ impl<'a> IrExpression<'a> { ResolvedTemplateLiteralExpr { elements, expressions, + tagged: e.tagged, source_span: e.source_span, }, &allocator, @@ -828,9 +829,9 @@ impl<'a> IrExpression<'a> { ArrowFunctionExpr { params, body: Box::new_in(e.body.clone_in(allocator), &allocator), - // ops are not cloned as they are transient data added during compilation - ops: Vec::new_in(&allocator), + ops: clone_arrow_function_ops(&e.ops, allocator), var_offset: e.var_offset, + hoisted: e.hoisted, source_span: e.source_span, }, &allocator, @@ -1098,7 +1099,13 @@ pub struct ResolvedTemplateLiteralExpr<'a> { /// Template literal text elements (the static parts between expressions). pub elements: Vec<'a, IrTemplateLiteralElement<'a>>, /// Resolved expressions (the dynamic parts inside ${...}). + /// + /// For a tagged template the tag comes first, ahead of the `${...}` parts, so that + /// every phase resolves and rewrites it along with them. pub expressions: Vec<'a, IrExpression<'a>>, + /// Whether this is a tagged template (`` tag`...` ``), with the tag stored as the + /// first entry of `expressions`. + pub tagged: bool, /// Source span. pub source_span: Option, } @@ -1554,6 +1561,10 @@ pub enum IrUnaryOperator { Plus, /// Unary minus (-) Minus, + /// Spread (`...expr`), which only appears as a call argument. It is modelled as a + /// unary operator so that every phase resolves and rewrites its operand like any + /// other expression. + Spread, } /// Logical NOT expression (!expr). @@ -1604,13 +1615,14 @@ pub struct VoidExpr<'a> { /// Arrow function expression. /// -/// Created by the generateArrowFunctions phase to wrap user-defined arrow functions -/// found in template expressions. Arrow functions in event listeners are preserved -/// in place (not wrapped) because they need to access $event. +/// Created during ingestion for every arrow function the user wrote. The +/// generateArrowFunctions phase then marks the ones to hoist (see `hoisted`); arrow +/// functions in event listeners stay in place because they need to access $event. /// -/// The `ops` list is used to store Variable ops that are prepended by the -/// generate_variables phase. These ops are processed during the naming phase -/// to ensure variables are named in the correct order. +/// For a hoisted arrow function, `ops` holds the Variable ops prepended by the +/// generate_variables phase and `body` is its return value. Angular keeps the return +/// statement in the op list too; here it is separate, as a listener's +/// `handler_expression` is. /// /// Ported from Angular's `ir.ArrowFunctionExpr` in `expression.ts`. #[derive(Debug)] @@ -1619,17 +1631,76 @@ pub struct ArrowFunctionExpr<'a> { pub params: Vec<'a, crate::output::ast::FnParam<'a>>, /// Function body expression. pub body: Box<'a, IrExpression<'a>>, - /// Operations list for this arrow function. + /// Operations list for this arrow function, used only when it is hoisted. /// Initially empty, populated by generate_variables phase with Variable ops. /// These ops are processed by the naming phase before create/update ops. pub ops: Vec<'a, crate::ir::ops::UpdateOp<'a>>, /// Variable offset for change detection slot indexing. /// Assigned by the var_counting phase. pub var_offset: Option, + /// Whether the generateArrowFunctions phase hoisted this arrow function. A hoisted + /// arrow is emitted as a shared factory instantiated through `ɵɵarrowFunction`; + /// the others (in listeners, or nested in another arrow) are emitted in place. + pub hoisted: bool, /// Source span. pub source_span: Option, } +impl<'a> ArrowFunctionExpr<'a> { + /// Lends the ops and the body in the shape the phases use for listener handlers + /// (`handler_ops` plus an optional `handler_expression`), so a hoisted arrow function + /// is processed by the same code as a listener. + pub fn with_handler( + &mut self, + allocator: &'a oxc_allocator::Allocator, + f: impl FnOnce( + &mut Vec<'a, crate::ir::ops::UpdateOp<'a>>, + &mut Option>>, + ) -> R, + ) -> R { + let placeholder = Box::new_in( + IrExpression::Empty(Box::new_in(EmptyExpr { source_span: None }, &allocator)), + &allocator, + ); + let mut body = Some(std::mem::replace(&mut self.body, placeholder)); + let result = f(&mut self.ops, &mut body); + if let Some(body) = body { + self.body = body; + } + result + } +} + +/// Clones the ops of an arrow function: the variables prepended to it and the statements +/// the variable optimizer turns some of them into. +pub fn clone_arrow_function_ops<'a>( + ops: &Vec<'a, crate::ir::ops::UpdateOp<'a>>, + allocator: &'a oxc_allocator::Allocator, +) -> Vec<'a, crate::ir::ops::UpdateOp<'a>> { + use crate::ir::ops::{StatementOp, UpdateOp, UpdateVariableOp}; + let mut cloned = Vec::with_capacity_in(ops.len(), &allocator); + for op in ops { + match op { + UpdateOp::Variable(var) => cloned.push(UpdateOp::Variable(UpdateVariableOp { + base: Default::default(), + xref: var.xref, + kind: var.kind, + name: var.name, + initializer: Box::new_in(var.initializer.clone_in(allocator), &allocator), + flags: var.flags, + view: var.view, + local: var.local, + })), + UpdateOp::Statement(stmt) => cloned.push(UpdateOp::Statement(StatementOp { + base: Default::default(), + statement: crate::output::ast::clone_output_statement(&stmt.statement, allocator), + })), + _ => {} + } + } + cloned +} + // ============================================================================ // Expression Transformation // ============================================================================ @@ -2393,8 +2464,12 @@ pub fn transform_expressions_in_create_op<'a, F>( | CreateOp::I18nAttributes(_) | CreateOp::SourceLocation(_) | CreateOp::ConditionalBranch(_) - | CreateOp::ControlCreate(_) - | CreateOp::Statement(_) => {} + | CreateOp::ControlCreate(_) => {} + CreateOp::Statement(op) => { + // Statement ops may contain WrappedIrNode which wraps IR expressions, + // same as UpdateOp::Statement. + transform_expressions_in_output_statement(&mut op.statement, transform, flags); + } } } @@ -2474,11 +2549,165 @@ pub fn visit_expressions_in_update_op<'a, F>( UpdateOp::DeferWhen(op) => { visit_expressions_in_expression(&op.condition, visitor, flags); } + UpdateOp::Statement(op) => { + // Statement ops may contain WrappedIrNode which wraps IR expressions; + // transform_expressions_in_update_op covers them, and upstream's + // visitExpressionsInOp shares that same implementation. Mirror the + // transform here so read-only consumers see the same expressions. + visit_expressions_in_output_statement(&op.statement, visitor, flags); + } // Operations without expressions - UpdateOp::ListEnd(_) - | UpdateOp::Advance(_) - | UpdateOp::I18nApply(_) - | UpdateOp::Statement(_) => {} + UpdateOp::ListEnd(_) | UpdateOp::Advance(_) | UpdateOp::I18nApply(_) => {} + } +} + +/// Visit all expressions inside an OutputStatement (read-only). +/// This handles WrappedIrNode expressions that contain IR expressions. +fn visit_expressions_in_output_statement<'a, F>( + stmt: &crate::output::ast::OutputStatement<'a>, + visitor: &F, + flags: VisitorContextFlag, +) where + F: Fn(&IrExpression<'a>, VisitorContextFlag), +{ + use crate::output::ast::OutputStatement; + + match stmt { + OutputStatement::Expression(expr_stmt) => { + visit_expressions_in_output_expression(&expr_stmt.expr, visitor, flags); + } + OutputStatement::Return(ret_stmt) => { + visit_expressions_in_output_expression(&ret_stmt.value, visitor, flags); + } + OutputStatement::DeclareVar(decl) => { + if let Some(ref value) = decl.value { + visit_expressions_in_output_expression(value, visitor, flags); + } + } + OutputStatement::If(if_stmt) => { + visit_expressions_in_output_expression(&if_stmt.condition, visitor, flags); + for stmt in if_stmt.true_case.iter() { + visit_expressions_in_output_statement(stmt, visitor, flags); + } + for stmt in if_stmt.false_case.iter() { + visit_expressions_in_output_statement(stmt, visitor, flags); + } + } + OutputStatement::DeclareFunction(_) => { + // Function declarations don't contain IrExpressions to visit + } + } +} + +/// Visit all expressions inside an OutputExpression (read-only). +/// This handles WrappedIrNode which contains IR expressions. +fn visit_expressions_in_output_expression<'a, F>( + expr: &crate::output::ast::OutputExpression<'a>, + visitor: &F, + flags: VisitorContextFlag, +) where + F: Fn(&IrExpression<'a>, VisitorContextFlag), +{ + use crate::output::ast::OutputExpression; + + match expr { + OutputExpression::WrappedIrNode(wrapped) => { + visit_expressions_in_expression(&wrapped.node, visitor, flags); + } + OutputExpression::Conditional(cond) => { + visit_expressions_in_output_expression(&cond.condition, visitor, flags); + visit_expressions_in_output_expression(&cond.true_case, visitor, flags); + if let Some(false_case) = &cond.false_case { + visit_expressions_in_output_expression(false_case, visitor, flags); + } + } + OutputExpression::BinaryOperator(bin) => { + visit_expressions_in_output_expression(&bin.lhs, visitor, flags); + visit_expressions_in_output_expression(&bin.rhs, visitor, flags); + } + OutputExpression::UnaryOperator(un) => { + visit_expressions_in_output_expression(&un.expr, visitor, flags); + } + OutputExpression::Not(not) => { + visit_expressions_in_output_expression(¬.condition, visitor, flags); + } + OutputExpression::ReadProp(member) => { + visit_expressions_in_output_expression(&member.receiver, visitor, flags); + } + OutputExpression::ReadKey(idx) => { + visit_expressions_in_output_expression(&idx.receiver, visitor, flags); + visit_expressions_in_output_expression(&idx.index, visitor, flags); + } + OutputExpression::TaggedTemplateLiteral(tagged) => { + visit_expressions_in_output_expression(&tagged.tag, visitor, flags); + for expr in tagged.template.expressions.iter() { + visit_expressions_in_output_expression(expr, visitor, flags); + } + } + OutputExpression::ArrowFunction(arrow) => match &arrow.body { + crate::output::ast::ArrowFunctionBody::Expression(expr) => { + visit_expressions_in_output_expression(expr, visitor, flags); + } + crate::output::ast::ArrowFunctionBody::Statements(stmts) => { + for stmt in stmts.iter() { + visit_expressions_in_output_statement(stmt, visitor, flags); + } + } + }, + OutputExpression::LiteralArray(arr) => { + for elem in arr.entries.iter() { + visit_expressions_in_output_expression(elem, visitor, flags); + } + } + OutputExpression::LiteralMap(obj) => { + for entry in obj.entries.iter() { + visit_expressions_in_output_expression(&entry.value, visitor, flags); + } + } + OutputExpression::InvokeFunction(inv) => { + visit_expressions_in_output_expression(&inv.fn_expr, visitor, flags); + for arg in inv.args.iter() { + visit_expressions_in_output_expression(arg, visitor, flags); + } + } + OutputExpression::Function(func) => { + for stmt in func.statements.iter() { + visit_expressions_in_output_statement(stmt, visitor, flags); + } + } + OutputExpression::Instantiate(inst) => { + visit_expressions_in_output_expression(&inst.class_expr, visitor, flags); + for arg in inst.args.iter() { + visit_expressions_in_output_expression(arg, visitor, flags); + } + } + OutputExpression::Parenthesized(paren) => { + visit_expressions_in_output_expression(&paren.expr, visitor, flags); + } + OutputExpression::Comma(comma) => { + for part in comma.parts.iter() { + visit_expressions_in_output_expression(part, visitor, flags); + } + } + OutputExpression::Typeof(typeof_expr) => { + visit_expressions_in_output_expression(&typeof_expr.expr, visitor, flags); + } + OutputExpression::Void(void_expr) => { + visit_expressions_in_output_expression(&void_expr.expr, visitor, flags); + } + OutputExpression::SpreadElement(spread) => { + visit_expressions_in_output_expression(&spread.expr, visitor, flags); + } + // Leaf expressions without sub-expressions + OutputExpression::Literal(_) + | OutputExpression::TemplateLiteral(_) + | OutputExpression::RegularExpressionLiteral(_) + | OutputExpression::ReadVar(_) + | OutputExpression::External(_) + | OutputExpression::LocalizedString(_) + | OutputExpression::WrappedNode(_) + | OutputExpression::DynamicImport(_) + | OutputExpression::RawSource(_) => {} } } @@ -2582,8 +2811,11 @@ pub fn visit_expressions_in_create_op<'a, F>( | CreateOp::I18nAttributes(_) | CreateOp::SourceLocation(_) | CreateOp::ConditionalBranch(_) - | CreateOp::ControlCreate(_) - | CreateOp::Statement(_) => {} + | CreateOp::ControlCreate(_) => {} + CreateOp::Statement(op) => { + // Same as UpdateOp::Statement: statements may wrap IR expressions. + visit_expressions_in_output_statement(&op.statement, visitor, flags); + } } } diff --git a/crates/oxc_angular_compiler/src/output/ast.rs b/crates/oxc_angular_compiler/src/output/ast.rs index ff3a57ac7..a7ce41583 100644 --- a/crates/oxc_angular_compiler/src/output/ast.rs +++ b/crates/oxc_angular_compiler/src/output/ast.rs @@ -574,6 +574,29 @@ pub struct TaggedTemplateLiteralExpr<'a> { pub source_span: Option, } +impl TemplateLiteralExpr<'_> { + /// Checks if two template literals are structurally equivalent. + /// + /// Mirrors `TemplateLiteralExpr.isEquivalent` in `output_ast.ts`: elements + /// compare by `text` (upstream also compares `rawText` at the element + /// level, so both are checked here) and expressions by structural + /// equivalence. + pub fn is_equivalent(&self, other: &Self) -> bool { + self.elements.len() == other.elements.len() + && self + .elements + .iter() + .zip(other.elements.iter()) + .all(|(x, y)| x.text == y.text && x.raw_text == y.raw_text) + && self.expressions.len() == other.expressions.len() + && self + .expressions + .iter() + .zip(other.expressions.iter()) + .all(|(x, y)| x.is_equivalent(y)) + } +} + /// Read variable expression. #[derive(Debug)] pub struct ReadVarExpr<'a> { @@ -1109,6 +1132,42 @@ impl<'a> OutputExpression<'a> { (OutputExpression::RawSource(a), OutputExpression::RawSource(b)) => { a.source == b.source } + // Template literals + (OutputExpression::TemplateLiteral(a), OutputExpression::TemplateLiteral(b)) => { + a.is_equivalent(b) + } + ( + OutputExpression::TaggedTemplateLiteral(a), + OutputExpression::TaggedTemplateLiteral(b), + ) => a.tag.is_equivalent(&b.tag) && a.template.is_equivalent(&b.template), + // Constructor invocations + (OutputExpression::Instantiate(a), OutputExpression::Instantiate(b)) => { + a.class_expr.is_equivalent(&b.class_expr) + && a.args.len() == b.args.len() + && a.args.iter().zip(b.args.iter()).all(|(x, y)| x.is_equivalent(y)) + } + // Dynamic imports: upstream compares url/urlComment by identity for + // expression URLs; structural comparison is strictly more precise here. + (OutputExpression::DynamicImport(a), OutputExpression::DynamicImport(b)) => { + a.url_comment == b.url_comment + && match (&a.url, &b.url) { + (DynamicImportUrl::String(x), DynamicImportUrl::String(y)) => x == y, + (DynamicImportUrl::Expression(x), DynamicImportUrl::Expression(y)) => { + x.is_equivalent(y) + } + _ => false, + } + } + // Wrapped nodes compare by identity upstream; node_id is the port's + // stable identity for a wrapped external node. + (OutputExpression::WrappedNode(a), OutputExpression::WrappedNode(b)) => { + a.node_id == b.node_id + } + (OutputExpression::WrappedIrNode(a), OutputExpression::WrappedIrNode(b)) => { + std::ptr::eq(a.node.as_ref(), b.node.as_ref()) + } + // Upstream LocalizedString.isEquivalent always returns false. + (OutputExpression::LocalizedString(_), OutputExpression::LocalizedString(_)) => false, _ => false, } } @@ -1428,15 +1487,13 @@ impl<'a> OutputExpression<'a> { WrappedNodeExpr { node_id: e.node_id.clone(), source_span: e.source_span }, &allocator, )), - OutputExpression::WrappedIrNode(_) => { - // WrappedIrNode expressions wrap IR expressions for deferred processing. - // They should be resolved during the reify phase before any cloning occurs. - // Return a placeholder undefined literal as a safe fallback. - OutputExpression::Literal(Box::new_in( - LiteralExpr { value: LiteralValue::Undefined, source_span: None }, - &allocator, - )) - } + OutputExpression::WrappedIrNode(e) => OutputExpression::WrappedIrNode(Box::new_in( + WrappedIrExpr { + node: Box::new_in(e.node.clone_in(allocator), &allocator), + source_span: e.source_span, + }, + &allocator, + )), OutputExpression::SpreadElement(e) => OutputExpression::SpreadElement(Box::new_in( SpreadElementExpr { expr: Box::new_in(e.expr.clone_in(allocator), &allocator), diff --git a/crates/oxc_angular_compiler/src/output/emitter.rs b/crates/oxc_angular_compiler/src/output/emitter.rs index a39c920eb..feef6736e 100644 --- a/crates/oxc_angular_compiler/src/output/emitter.rs +++ b/crates/oxc_angular_compiler/src/output/emitter.rs @@ -1030,42 +1030,10 @@ impl JsEmitter { expr: &super::ast::TaggedTemplateLiteralExpr<'_>, ctx: &mut EmitterContext, ) { - // Downlevel tagged template to function call for compatibility - // tag`...` becomes tag(__makeTemplateObject(cooked, raw), expr1, expr2, ...) - const MAKE_TEMPLATE_OBJECT_POLYFILL: &str = "(this&&this.__makeTemplateObject||function(e,t){return Object.defineProperty?Object.defineProperty(e,\"raw\",{value:t}):e.raw=t,e})"; - + // Emitted natively, as ngtsc does, so the tag receives the same strings array on + // every call. self.visit_expression(&expr.tag, ctx); - ctx.print("("); - ctx.print(MAKE_TEMPLATE_OBJECT_POLYFILL); - ctx.print("("); - - // Cooked strings - ctx.print("["); - let elements = &expr.template.elements; - for (i, element) in elements.iter().enumerate() { - if i > 0 { - ctx.print(", "); - } - ctx.print(&escape_string(&element.text, false)); - } - ctx.print("], "); - - // Raw strings - ctx.print("["); - for (i, element) in elements.iter().enumerate() { - if i > 0 { - ctx.print(", "); - } - ctx.print(&escape_string(&element.raw_text, false)); - } - ctx.print("])"); - - // Expressions - for expression in &expr.template.expressions { - ctx.print(", "); - self.visit_expression(expression, ctx); - } - ctx.print(")"); + self.visit_template_literal(&expr.template, ctx); } fn visit_localized_string( diff --git a/crates/oxc_angular_compiler/src/pipeline/constant_pool.rs b/crates/oxc_angular_compiler/src/pipeline/constant_pool.rs index ab833892f..640a40ee4 100644 --- a/crates/oxc_angular_compiler/src/pipeline/constant_pool.rs +++ b/crates/oxc_angular_compiler/src/pipeline/constant_pool.rs @@ -408,11 +408,11 @@ impl<'a> ConstantPool<'a> { // Get the per-base-name count for deduplication within this component let count = self.claimed_names.get(base).copied().unwrap_or(0); - // Only apply the global offset for "generic" names that start with underscore - // (like `_forTrack`, `_c`). These names could collide across components. + // Only apply the global offset for "generic" names (`_forTrack`, `_c`, `arrowFn`). + // These names could collide across components. // Component-specific names (like `ComponentName_Template`) don't need the // offset because they already contain the component class name. - let apply_offset = base.starts_with('_'); + let apply_offset = base.starts_with('_') || base == "arrowFn"; let effective_count = if apply_offset { self.unique_name_offset + count } else { count }; let name = if effective_count == 0 && !always_include_suffix { diff --git a/crates/oxc_angular_compiler/src/pipeline/conversion.rs b/crates/oxc_angular_compiler/src/pipeline/conversion.rs index 947e5d117..773e3bffb 100644 --- a/crates/oxc_angular_compiler/src/pipeline/conversion.rs +++ b/crates/oxc_angular_compiler/src/pipeline/conversion.rs @@ -195,14 +195,18 @@ pub fn convert_unary_operator(op: AstUnaryOperator) -> OutputUnaryOperator { /// Since the Angular expression parser gives us cooked text, we need to /// re-escape it for contexts that need raw text (template literal emission). /// +/// Mirrors Angular's `escapeForTemplateLiteral(escapeSlashes(text))` in +/// `output/output_ast.ts`: backslashes are doubled first, then backticks and +/// `${` are escaped so a tag sees the same `strings.raw` it would under ngtsc. +/// Line breaks stay literal, as upstream leaves them. +/// /// This function escapes: +/// - Backslashes (doubled, so the cooked value keeps the original escape) /// - Backticks (`) to prevent closing the template literal -/// - `${` to prevent interpolation syntax -/// - Backslashes to preserve escape sequences -/// - Carriage returns and line feeds to their escape sequences -fn cooked_to_raw_text<'a>(allocator: &'a Allocator, cooked: &str) -> Ident<'a> { +/// - `${` as `$\{` to prevent interpolation syntax +pub(crate) fn cooked_to_raw_text<'a>(allocator: &'a Allocator, cooked: &str) -> Ident<'a> { // Fast path: if no escaping needed, return as-is - if !cooked.contains(['`', '$', '\\', '\r', '\n']) { + if !cooked.contains(['`', '$', '\\']) { return Ident::from(allocator.alloc_str(cooked)); } @@ -213,12 +217,10 @@ fn cooked_to_raw_text<'a>(allocator: &'a Allocator, cooked: &str) -> Ident<'a> { match c { '`' => raw.push_str("\\`"), '\\' => raw.push_str("\\\\"), - '\r' => raw.push_str("\\r"), - '\n' => raw.push_str("\\n"), '$' => { // Only escape if followed by { to form ${ if chars.peek() == Some(&'{') { - raw.push_str("\\$"); + raw.push_str("$\\"); } else { raw.push('$'); } @@ -760,6 +762,7 @@ pub fn convert_ast<'a>( body: Box::new_in(body.to_ir(allocator), &allocator), ops: Vec::new_in(&allocator), var_offset: None, + hoisted: false, source_span: convert_source_span(arrow.source_span), }, &allocator, @@ -768,6 +771,32 @@ pub fn convert_ast<'a>( } } +/// Builds a tagged template from the converted parts of a tagged +/// `ResolvedTemplateLiteral`, whose first expression is the tag. +pub(crate) fn tagged_template_literal<'a>( + allocator: &'a Allocator, + mut elements: Vec<'a, crate::output::ast::TemplateLiteralElement<'a>>, + mut expressions: Vec<'a, crate::output::ast::OutputExpression<'a>>, + source_span: Option, +) -> crate::output::ast::OutputExpression<'a> { + let tag = expressions.remove(0); + // The elements hold cooked text; a tagged template is emitted from its raw text. + for element in &mut elements { + element.raw_text = cooked_to_raw_text(allocator, &element.text); + } + crate::output::ast::OutputExpression::TaggedTemplateLiteral(Box::new_in( + crate::output::ast::TaggedTemplateLiteralExpr { + tag: Box::new_in(tag, &allocator), + template: Box::new_in( + crate::output::ast::TemplateLiteralExpr { elements, expressions, source_span }, + &allocator, + ), + source_span, + }, + &allocator, + )) +} + /// Converts an Angular expression with interpolation support. /// /// If the expression is an Interpolation, it returns an IR Interpolation. @@ -883,16 +912,16 @@ mod tests { "path\\\\to\\\\file" ); - // Escape newlines and carriage returns + // Newlines and carriage returns stay literal, matching upstream assert_eq!( super::cooked_to_raw_text(&allocator, "line1\nline2\rline3").as_str(), - "line1\\nline2\\rline3" + "line1\nline2\rline3" ); - // Escape ${ + // Escape ${ as $\{, matching upstream's escapeForTemplateLiteral assert_eq!( super::cooked_to_raw_text(&allocator, "value is ${x}").as_str(), - "value is \\${x}" + "value is $\\{x}" ); // Do not escape $ without { diff --git a/crates/oxc_angular_compiler/src/pipeline/emit.rs b/crates/oxc_angular_compiler/src/pipeline/emit.rs index 516b28e44..0aafadee6 100644 --- a/crates/oxc_angular_compiler/src/pipeline/emit.rs +++ b/crates/oxc_angular_compiler/src/pipeline/emit.rs @@ -315,50 +315,6 @@ pub fn compile_template<'a>( TemplateCompilationResult { template_fn, declarations } } -/// Emit pool constants that were added after `compile_template`. -/// -/// This is used to emit constants that are added during definition generation -/// (e.g., attrs array from selectors), which happens after `compile_template` -/// drains the pool to `declarations`. -/// -/// # Arguments -/// -/// * `allocator` - Memory allocator -/// * `job` - The compilation job with the constant pool -/// * `start_index` - Index of the first constant to emit (constants before this are skipped) -/// -/// # Returns -/// -/// A vector of output statements for the new constants. -pub fn emit_additional_pool_constants<'a>( - allocator: &'a Allocator, - job: &mut ComponentCompilationJob<'a>, - start_index: usize, -) -> OxcVec<'a, OutputStatement<'a>> { - use crate::output::ast::DeclareVarStmt; - - let mut declarations = OxcVec::new_in(&allocator); - - // Only emit constants starting from start_index - let constants = job.pool.constants_mut(); - for constant in constants.iter_mut().skip(start_index) { - let value = emit_pooled_constant_value(allocator, &mut constant.kind); - - declarations.push(OutputStatement::DeclareVar(Box::new_in( - DeclareVarStmt { - name: constant.name.clone(), - value: Some(value), - modifiers: StmtModifier::FINAL, - leading_comment: None, - source_span: None, - }, - &allocator, - ))); - } - - declarations -} - /// Converts a pure function body expression to an output expression. /// /// This function handles `PureFunctionParameterExpr` by converting them to @@ -1236,6 +1192,15 @@ fn convert_pure_function_body<'a>( let operator = match unary.operator { crate::ir::expression::IrUnaryOperator::Plus => UnaryOperator::Plus, crate::ir::expression::IrUnaryOperator::Minus => UnaryOperator::Minus, + crate::ir::expression::IrUnaryOperator::Spread => { + return OutputExpression::SpreadElement(Box::new_in( + crate::output::ast::SpreadElementExpr { + expr: Box::new_in(inner, &allocator), + source_span: None, + }, + &allocator, + )); + } }; OutputExpression::UnaryOperator(Box::new_in( UnaryOperatorExpr { @@ -1280,7 +1245,12 @@ fn convert_pure_function_body<'a>( for elem in rtl.elements.iter() { elements.push(crate::output::ast::TemplateLiteralElement { text: elem.text.clone(), - raw_text: elem.text.clone(), + // elem.text is cooked; the emitter prints raw_text verbatim, + // so it must be re-escaped like upstream's + // escapeForTemplateLiteral(escapeSlashes(text)). + raw_text: crate::pipeline::conversion::cooked_to_raw_text( + allocator, &elem.text, + ), source_span: elem.source_span, }); } @@ -1289,6 +1259,15 @@ fn convert_pure_function_body<'a>( expressions.push(convert_pure_function_body(allocator, expr, params)); } + if rtl.tagged { + return crate::pipeline::conversion::tagged_template_literal( + allocator, + elements, + expressions, + rtl.source_span, + ); + } + OutputExpression::TemplateLiteral(Box::new_in( crate::output::ast::TemplateLiteralExpr { elements, @@ -1328,7 +1307,11 @@ fn convert_pure_function_body<'a>( for elem in ttl.elements.iter() { elements.push(crate::output::ast::TemplateLiteralElement { text: elem.text.clone(), - raw_text: elem.text.clone(), + // See ResolvedTemplateLiteral above: cooked text must be + // re-escaped for emission as raw text. + raw_text: crate::pipeline::conversion::cooked_to_raw_text( + allocator, &elem.text, + ), source_span: elem.source_span, }); } diff --git a/crates/oxc_angular_compiler/src/pipeline/ingest.rs b/crates/oxc_angular_compiler/src/pipeline/ingest.rs index a84b53950..ffec2b9d9 100644 --- a/crates/oxc_angular_compiler/src/pipeline/ingest.rs +++ b/crates/oxc_angular_compiler/src/pipeline/ingest.rs @@ -623,6 +623,7 @@ fn convert_ast_to_ir<'a>( crate::ir::expression::ResolvedTemplateLiteralExpr { elements, expressions, + tagged: false, source_span: Some(tl.source_span.to_span()), }, &allocator, @@ -631,54 +632,30 @@ fn convert_ast_to_ir<'a>( ) } - // Convert ArrowFunction - recursively convert the body so the names it reads are - // resolved, then keep reads of the arrow's own parameters as plain variables. - AngularExpression::ArrowFunction(arrow) => { - let arrow = arrow.unbox(); - let source_span = Some(arrow.source_span.to_span()); - let mut params = Vec::with_capacity_in(arrow.parameters.len(), &allocator); - for param in &arrow.parameters { - params.push(crate::output::ast::FnParam { name: param.name }); - } - let body = convert_ast_to_ir(job, arrow.body); - let mut expr = IrExpression::ArrowFunction(Box::new_in( - crate::ir::expression::ArrowFunctionExpr { - params, - body, - ops: Vec::new_in(&allocator), - var_offset: None, - source_span, - }, - &allocator, - )); - update_parameter_references(&mut expr, allocator); - Box::new_in(expr, &allocator) - } - - // Convert TaggedTemplateLiteral - recursively convert the tag and template - // expressions to preserve pipes and arrow functions (ingest.ts convertAst). - AngularExpression::TaggedTemplateLiteral(ttl) => { - let ttl = ttl.unbox(); - let tag = convert_ast_to_ir(job, ttl.tag); - let mut elements = Vec::with_capacity_in(ttl.template.elements.len(), &allocator); - for elem in ttl.template.elements.iter() { + // Convert TaggedTemplateLiteral the same way, with the tag as the first expression. + AngularExpression::TaggedTemplateLiteral(tagged) => { + let tagged = tagged.unbox(); + let source_span = Some(tagged.source_span.to_span()); + let tl = tagged.template; + let mut elements = Vec::with_capacity_in(tl.elements.len(), &allocator); + for elem in &tl.elements { elements.push(crate::ir::expression::IrTemplateLiteralElement { - text: elem.text.clone(), + text: elem.text, source_span: Some(elem.source_span.to_span()), }); } - let mut expressions = Vec::with_capacity_in(ttl.template.expressions.len(), &allocator); - for expr in ttl.template.expressions { - let converted = convert_ast_to_ir(job, expr); - expressions.push(converted.unbox()); + let mut expressions = Vec::with_capacity_in(tl.expressions.len() + 1, &allocator); + expressions.push(convert_ast_to_ir(job, tagged.tag).unbox()); + for expr in tl.expressions { + expressions.push(convert_ast_to_ir(job, expr).unbox()); } Box::new_in( - IrExpression::TaggedTemplateLiteral(Box::new_in( - crate::ir::expression::IrTaggedTemplateLiteralExpr { - tag, + IrExpression::ResolvedTemplateLiteral(Box::new_in( + crate::ir::expression::ResolvedTemplateLiteralExpr { elements, expressions, - source_span: Some(ttl.source_span.to_span()), + tagged: true, + source_span, }, &allocator, )), @@ -686,17 +663,18 @@ fn convert_ast_to_ir<'a>( ) } - // Convert SpreadElement - recursively convert the inner expression so arrows - // and pipes inside spread positions (e.g. call arguments) are preserved - // (ingest.ts convertAst produces o.SpreadElementExpr). + // Convert SpreadElement (a call argument such as `fn(...args)`) - recursively convert + // the operand so the names it reads are resolved, and keep the spread. AngularExpression::SpreadElement(spread) => { let spread = spread.unbox(); + let source_span = Some(spread.source_span.to_span()); let expr = convert_ast_to_ir(job, spread.expression); Box::new_in( - IrExpression::SpreadElement(Box::new_in( - crate::ir::expression::IrSpreadElementExpr { + IrExpression::Unary(oxc_allocator::Box::new_in( + crate::ir::expression::UnaryExpr { + operator: crate::ir::expression::IrUnaryOperator::Spread, expr, - source_span: Some(spread.source_span.to_span()), + source_span, }, &allocator, )), @@ -704,6 +682,31 @@ fn convert_ast_to_ir<'a>( ) } + // Convert ArrowFunction - recursively convert the body so the names it reads are + // resolved, then keep reads of the arrow's own parameters as plain variables. + AngularExpression::ArrowFunction(arrow) => { + let arrow = arrow.unbox(); + let source_span = Some(arrow.source_span.to_span()); + let mut params = Vec::with_capacity_in(arrow.parameters.len(), &allocator); + for param in &arrow.parameters { + params.push(crate::output::ast::FnParam { name: param.name }); + } + let body = convert_ast_to_ir(job, arrow.body); + let mut expr = IrExpression::ArrowFunction(Box::new_in( + crate::ir::expression::ArrowFunctionExpr { + params, + body, + ops: Vec::new_in(&allocator), + var_offset: None, + hoisted: false, + source_span, + }, + &allocator, + )); + update_parameter_references(&mut expr, allocator); + Box::new_in(expr, &allocator) + } + // For all other expressions, store in ExpressionStore and return reference. other => store_and_ref_expr(job, other), } @@ -4045,6 +4048,7 @@ fn host_convert_ast_to_ir<'a>( crate::ir::expression::ResolvedTemplateLiteralExpr { elements, expressions, + tagged: false, source_span: Some(tl.source_span.to_span()), }, &allocator, @@ -4053,43 +4057,30 @@ fn host_convert_ast_to_ir<'a>( ) } - // Empty expression - convert directly to IrExpression::Empty, matching the - // component-side converter. - AngularExpression::Empty(empty) => { - let empty = empty.unbox(); - Box::new_in( - IrExpression::Empty(Box::new_in( - EmptyExpr { source_span: Some(empty.source_span.to_span()) }, - &allocator, - )), - &allocator, - ) - } - - // Convert TaggedTemplateLiteral - recursively convert the tag and template - // expressions to preserve pipes and arrow functions (ingest.ts convertAst). - AngularExpression::TaggedTemplateLiteral(ttl) => { - let ttl = ttl.unbox(); - let tag = host_convert_ast_to_ir(job, ttl.tag); - let mut elements = Vec::with_capacity_in(ttl.template.elements.len(), &allocator); - for elem in ttl.template.elements.iter() { + // Convert TaggedTemplateLiteral the same way, with the tag as the first expression. + AngularExpression::TaggedTemplateLiteral(tagged) => { + let tagged = tagged.unbox(); + let source_span = Some(tagged.source_span.to_span()); + let tl = tagged.template; + let mut elements = Vec::with_capacity_in(tl.elements.len(), &allocator); + for elem in &tl.elements { elements.push(crate::ir::expression::IrTemplateLiteralElement { - text: elem.text.clone(), + text: elem.text, source_span: Some(elem.source_span.to_span()), }); } - let mut expressions = Vec::with_capacity_in(ttl.template.expressions.len(), &allocator); - for expr in ttl.template.expressions { - let converted = host_convert_ast_to_ir(job, expr); - expressions.push(converted.unbox()); + let mut expressions = Vec::with_capacity_in(tl.expressions.len() + 1, &allocator); + expressions.push(host_convert_ast_to_ir(job, tagged.tag).unbox()); + for expr in tl.expressions { + expressions.push(host_convert_ast_to_ir(job, expr).unbox()); } Box::new_in( - IrExpression::TaggedTemplateLiteral(Box::new_in( - crate::ir::expression::IrTaggedTemplateLiteralExpr { - tag, + IrExpression::ResolvedTemplateLiteral(Box::new_in( + crate::ir::expression::ResolvedTemplateLiteralExpr { elements, expressions, - source_span: Some(ttl.source_span.to_span()), + tagged: true, + source_span, }, &allocator, )), @@ -4097,16 +4088,31 @@ fn host_convert_ast_to_ir<'a>( ) } - // Convert SpreadElement - recursively convert the inner expression so arrows - // and pipes inside spread positions (e.g. call arguments) are preserved. + // Empty expression - convert directly to IrExpression::Empty, matching the + // component-side converter. + AngularExpression::Empty(empty) => { + let empty = empty.unbox(); + Box::new_in( + IrExpression::Empty(Box::new_in( + EmptyExpr { source_span: Some(empty.source_span.to_span()) }, + &allocator, + )), + &allocator, + ) + } + + // Convert SpreadElement (a call argument such as `fn(...args)`) - recursively convert + // the operand so the names it reads are resolved, and keep the spread. AngularExpression::SpreadElement(spread) => { let spread = spread.unbox(); + let source_span = Some(spread.source_span.to_span()); let expr = host_convert_ast_to_ir(job, spread.expression); Box::new_in( - IrExpression::SpreadElement(Box::new_in( - crate::ir::expression::IrSpreadElementExpr { + IrExpression::Unary(oxc_allocator::Box::new_in( + crate::ir::expression::UnaryExpr { + operator: crate::ir::expression::IrUnaryOperator::Spread, expr, - source_span: Some(spread.source_span.to_span()), + source_span, }, &allocator, )), @@ -4130,6 +4136,7 @@ fn host_convert_ast_to_ir<'a>( body, ops: Vec::new_in(&allocator), var_offset: None, + hoisted: false, source_span, }, &allocator, diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/allocate_slots.rs b/crates/oxc_angular_compiler/src/pipeline/phases/allocate_slots.rs index c699af889..46a661722 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/allocate_slots.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/allocate_slots.rs @@ -18,6 +18,7 @@ use crate::pipeline::compilation::ComponentCompilationJob; /// Slot indices are used by the Angular runtime to identify elements, templates, /// and other entities during change detection and rendering. pub fn allocate_slots(job: &mut ComponentCompilationJob<'_>) { + super::generate_arrow_functions::collect_arrow_functions(job); // Map of all declarations in all views within the component which require an assigned slot index. // This map is global across all views since it's possible to reference a slot from one view // in an expression within another (e.g., local references work this way). diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/generate_arrow_functions.rs b/crates/oxc_angular_compiler/src/pipeline/phases/generate_arrow_functions.rs index 2c1db128e..7eb198368 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/generate_arrow_functions.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/generate_arrow_functions.rs @@ -1,8 +1,8 @@ //! Generate arrow functions phase. //! -//! Finds arrow functions written by the user and converts them into -//! pipeline-specific IR expressions. Arrow functions in event listeners -//! are preserved in place because they need to access $event. +//! Finds arrow functions written by the user and marks them to be hoisted into +//! shared factories instantiated through `ɵɵarrowFunction`. Arrow functions in +//! event listeners are preserved in place because they need to access $event. //! //! Ported from Angular's `template/pipeline/src/phases/generate_arrow_functions.ts`. @@ -12,22 +12,15 @@ use crate::ir::expression::{ transform_expressions_in_update_op, }; use crate::ir::ops::{CreateOp, Op}; -use crate::output::ast::{ - ArrowFunctionBody, ArrowFunctionExpr as OutputArrowFunctionExpr, FnParam, -}; use crate::pipeline::compilation::{ComponentCompilationJob, HostBindingCompilationJob}; -use oxc_allocator::{Box as AllocBox, Vec as AllocVec}; -/// Finds arrow functions written by the user and converts them into -/// pipeline-specific IR expressions. +/// Finds arrow functions written by the user and marks them as hoisted. /// /// Arrow functions in event listeners (Listener, TwoWayListener, Animation, /// AnimationListener) are preserved in place because: /// 1. They need to be able to access $event. /// 2. We don't need to store them. pub fn generate_arrow_functions(job: &mut ComponentCompilationJob<'_>) { - let allocator = job.allocator; - // Process each view let view_xrefs: Vec<_> = job.all_views().map(|v| v.xref).collect(); @@ -39,9 +32,7 @@ pub fn generate_arrow_functions(job: &mut ComponentCompilationJob<'_>) { if !is_listener_op(op) { transform_expressions_in_create_op( op, - &|expr, flags| { - add_arrow_function(expr, &allocator, flags); - }, + &|expr, flags| add_arrow_function(expr, flags), VisitorContextFlag::NONE, ); } @@ -51,113 +42,71 @@ pub fn generate_arrow_functions(job: &mut ComponentCompilationJob<'_>) { for op in view.update.iter_mut() { transform_expressions_in_update_op( op, - &|expr, flags| { - add_arrow_function(expr, &allocator, flags); - }, + &|expr, flags| add_arrow_function(expr, flags), VisitorContextFlag::NONE, ); } } } - // Collect arrow functions into the functions set for each view - // We need to do this in a separate pass after the transformations - for xref in &view_xrefs { - if let Some(view) = job.view_mut(*xref) { + collect_arrow_functions(job); +} + +/// Rebuilds every view's `functions` list from the hoisted arrow functions currently in +/// its operations. +/// +/// The list holds pointers into the expression tree, and phases that rewrite expressions +/// move arrow functions around, so phases that walk `functions` refresh it first. +pub fn collect_arrow_functions(job: &mut ComponentCompilationJob<'_>) { + let view_xrefs: Vec<_> = job.all_views().map(|v| v.xref).collect(); + for xref in view_xrefs { + if let Some(view) = job.view_mut(xref) { collect_arrow_functions_from_view(view); } } } -/// Check if an operation is a listener type that should preserve arrow functions. +/// Check if an operation keeps the arrow functions written in it in place. +/// +/// Listeners do, as in Angular. So does a `@for` block, whose only create-time expression +/// is `track`: it compiles to a standalone function with no `ctx` in scope, so the +/// `ɵɵarrowFunction(0, arrowFn, ctx)` call Angular emits there throws a ReferenceError. fn is_listener_op(op: &CreateOp<'_>) -> bool { matches!( op.kind(), - OpKind::Animation | OpKind::AnimationListener | OpKind::Listener | OpKind::TwoWayListener + OpKind::Animation + | OpKind::AnimationListener + | OpKind::Listener + | OpKind::TwoWayListener + | OpKind::RepeaterCreate ) } -/// Transform output AST arrow functions into IR arrow functions. +/// Marks a user-written arrow function as hoisted. /// -/// This function is called for each expression in the IR. If the expression -/// is an OutputExpr containing an ArrowFunction, it is converted to an -/// IrExpression::ArrowFunction. -fn add_arrow_function<'a>( - expr: &mut IrExpression<'a>, - allocator: &'a oxc_allocator::Allocator, - flags: VisitorContextFlag, -) { - // Skip if we're inside a child operation (nested arrow function) +/// Arrow functions nested inside another arrow function are part of that function's +/// body and stay in place. +fn add_arrow_function(expr: &mut IrExpression<'_>, flags: VisitorContextFlag) { if flags.contains(VisitorContextFlag::IN_CHILD_OPERATION) { return; } - - // Check if this is an output arrow function expression - if let IrExpression::OutputExpr(output_expr) = expr { - if let crate::output::ast::OutputExpression::ArrowFunction(arrow_fn) = output_expr.as_ref() - { - // Convert output arrow function to IR arrow function - if let Some(ir_arrow_fn) = convert_output_arrow_to_ir(arrow_fn.as_ref(), &allocator) { - *expr = IrExpression::ArrowFunction(AllocBox::new_in(ir_arrow_fn, &allocator)); - } - } + if let IrExpression::ArrowFunction(arrow_fn) = expr { + arrow_fn.hoisted = true; } } -/// Convert an output AST arrow function to an IR arrow function. -/// -/// Returns None if the arrow function has a multi-line body (Statement[]), -/// which is not supported in template expressions. -fn convert_output_arrow_to_ir<'a>( - arrow_fn: &OutputArrowFunctionExpr<'a>, - allocator: &'a oxc_allocator::Allocator, -) -> Option> { - // Check for multi-line body - not supported in templates - let body = match &arrow_fn.body { - ArrowFunctionBody::Expression(expr) => { - // Wrap the output expression in an IrExpression::OutputExpr - // It will be converted later in the reify phase - IrExpression::OutputExpr(AllocBox::new_in(expr.clone_in(allocator), &allocator)) - } - ArrowFunctionBody::Statements(_) => { - // The expression syntax doesn't support multi-line arrow functions, - // but the output AST does. We don't need to handle them here if - // the user isn't able to write one. Skip conversion and return None. - return None; - } - }; - - // Clone parameters - let mut params: AllocVec<'a, FnParam<'a>> = AllocVec::new_in(&allocator); - for param in arrow_fn.params.iter() { - params.push(FnParam { name: param.name.clone() }); - } - - Some(ArrowFunctionExpr { - params, - body: AllocBox::new_in(body, &allocator), - ops: AllocVec::new_in(&allocator), - var_offset: None, - source_span: arrow_fn.source_span, - }) -} - /// Generate arrow functions for host binding compilation. /// /// Angular runs this phase for Kind.Both, meaning it applies to both /// template and host compilations. Host bindings can contain arrow /// function expressions (e.g., in @HostBinding values or event handlers). pub fn generate_arrow_functions_for_host(job: &mut HostBindingCompilationJob<'_>) { - let allocator = job.allocator; - // Process create operations (skip listeners) for op in job.root.create.iter_mut() { if !is_listener_op(op) { transform_expressions_in_create_op( op, - &|expr, flags| { - add_arrow_function(expr, &allocator, flags); - }, + &|expr, flags| add_arrow_function(expr, flags), VisitorContextFlag::NONE, ); } @@ -167,94 +116,64 @@ pub fn generate_arrow_functions_for_host(job: &mut HostBindingCompilationJob<'_> for op in job.root.update.iter_mut() { transform_expressions_in_update_op( op, - &|expr, flags| { - add_arrow_function(expr, &allocator, flags); - }, + &|expr, flags| add_arrow_function(expr, flags), VisitorContextFlag::NONE, ); } - // Collect arrow functions into the root unit's functions set - let ptrs = collect_arrow_function_ptrs(&job.root.create, &job.root.update); - for ptr in ptrs { - // SAFETY: see collect_arrow_functions_from_view. - unsafe { (*ptr).var_offset = Some(u32::MAX) }; + // Collect arrow functions into the root unit's functions set. + // Only hoisted arrows are collected, matching the template path. + collect_arrow_functions_for_host(job); +} + +/// Rebuilds the root unit's `functions` list from the hoisted arrow functions +/// currently in its operations, mirroring [`collect_arrow_functions`] for +/// template jobs. +pub fn collect_arrow_functions_for_host(job: &mut HostBindingCompilationJob<'_>) { + job.root.functions.clear(); + for ptr in collect_hoisted_arrow_functions(&mut job.root.create, &mut job.root.update) { job.root.functions.push(ptr); } } -/// Collect arrow functions from a view's operations into its functions set. -/// -/// Only top-level arrows are collected: arrows inside child operations (listener -/// handlers, nested arrow bodies) are skipped, matching upstream where -/// `unit.functions` only holds `ir.ArrowFunctionExpr`s created by -/// `addArrowFunctions` for non-listener, non-nested positions. -fn collect_arrow_functions_from_view( - view: &mut crate::pipeline::compilation::ViewCompilationUnit<'_>, +/// Collect hoisted arrow functions from a view's operations into its functions set. +fn collect_arrow_functions_from_view<'a>( + view: &mut crate::pipeline::compilation::ViewCompilationUnit<'a>, ) { - // Clear existing functions view.functions.clear(); - let ptrs = collect_arrow_function_ptrs(&view.create, &view.update); - for ptr in ptrs { - // SAFETY: The pointer targets an ArrowFunctionExpr allocated in this - // job's allocator, which outlives the pipeline. - unsafe { (*ptr).var_offset = Some(u32::MAX) }; + for ptr in collect_hoisted_arrow_functions(&mut view.create, &mut view.update) { view.functions.push(ptr); } } -/// Collect arrow function pointers from create and update op lists. +/// Return pointers to the hoisted (top-level) arrow functions in the given op lists. /// -/// Only top-level arrows are collected: arrows inside child operations (listener -/// handlers, nested arrow bodies) are skipped, matching upstream where -/// `unit.functions` only holds `ir.ArrowFunctionExpr`s created by -/// `addArrowFunctions` for non-listener, non-nested positions. Collected arrows -/// are marked with a sentinel var_offset (u32::MAX) so var_counting can tell -/// them apart from arrows preserved in place. -fn collect_arrow_function_ptrs<'a>( - create: &crate::ir::list::CreateOpList<'a>, - update: &crate::ir::list::UpdateOpList<'a>, +/// The transforming visitors are used, rather than the read-only ones, because they +/// also walk into the statements that variable optimization turns some ops into. +fn collect_hoisted_arrow_functions<'a>( + create: &mut crate::ir::list::CreateOpList<'a>, + update: &mut crate::ir::list::UpdateOpList<'a>, ) -> std::vec::Vec<*mut ArrowFunctionExpr<'a>> { // We use RefCell to allow mutable access from within the visitor closure use std::cell::RefCell; let collected: RefCell>> = RefCell::new(std::vec::Vec::new()); + let collect = |expr: &mut IrExpression<'a>, _flags: VisitorContextFlag| { + if let IrExpression::ArrowFunction(arrow_fn) = expr + && arrow_fn.hoisted + { + collected.borrow_mut().push(std::ptr::from_mut(arrow_fn.as_mut())); + } + }; - // Collect from create ops - for op in create.iter() { - crate::ir::expression::visit_expressions_in_create_op( - op, - &|expr, flags| { - if flags.contains(VisitorContextFlag::IN_CHILD_OPERATION) { - return; - } - if let IrExpression::ArrowFunction(arrow_fn) = expr { - let ptr = arrow_fn.as_ref() as *const ArrowFunctionExpr<'a> - as *mut ArrowFunctionExpr<'a>; - collected.borrow_mut().push(ptr); - } - }, - VisitorContextFlag::NONE, - ); + for op in create.iter_mut() { + if !is_listener_op(op) { + transform_expressions_in_create_op(op, &collect, VisitorContextFlag::NONE); + } } - - // Collect from update ops - for op in update.iter() { - crate::ir::expression::visit_expressions_in_update_op( - op, - &|expr, flags| { - if flags.contains(VisitorContextFlag::IN_CHILD_OPERATION) { - return; - } - if let IrExpression::ArrowFunction(arrow_fn) = expr { - let ptr = arrow_fn.as_ref() as *const ArrowFunctionExpr<'a> - as *mut ArrowFunctionExpr<'a>; - collected.borrow_mut().push(ptr); - } - }, - VisitorContextFlag::NONE, - ); + for op in update.iter_mut() { + transform_expressions_in_update_op(op, &collect, VisitorContextFlag::NONE); } collected.into_inner() diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/generate_variables.rs b/crates/oxc_angular_compiler/src/pipeline/phases/generate_variables.rs index 885c8dce1..d1d4aeea3 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/generate_variables.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/generate_variables.rs @@ -81,6 +81,7 @@ struct Scope<'a, 'b> { /// Following Angular's generate_variables.ts, this phase recursively processes views /// with a scope chain, so nested views can access @let declarations from parent views. pub fn generate_variables(job: &mut ComponentCompilationJob<'_>) { + super::generate_arrow_functions::collect_arrow_functions(job); // Process views recursively starting from root, building scope chains recursively_process_view(job, job.root.xref, None); } @@ -518,6 +519,7 @@ fn generate_variables_in_scope_for_view<'a>( new_ops.push(create_context_let_reference_variable( &allocator, xref, + scope.view, decl.variable_name.clone(), decl.target_id, decl.target_slot, @@ -609,29 +611,20 @@ fn prepend_variables_to_arrow_functions<'a, 'b>( scope: &Scope<'a, 'b>, _parent_scope: Option<&'b Scope<'a, 'b>>, ) { - // Check if there are functions in the view - let has_functions = { + let function_count = { let is_root = view_xref == job.root.xref; let view = if is_root { Some(&job.root) } else { job.views.get(&view_xref).map(|v| v.as_ref()) }; - view.map(|v| !v.functions.is_empty()).unwrap_or(false) + view.map_or(0, |v| v.functions.len()) }; - if !has_functions { - return; + // Each arrow function gets its own variables, as a listener does. + let mut variables_per_function = Vec::with_capacity(function_count); + for _ in 0..function_count { + variables_per_function + .push(generate_variables_in_scope_for_view(job, view_xref, scope, true)); } - // Generate variables for the arrow function context with is_callback=true - // Angular creates a fresh scope for each arrow function, but since we're - // iterating through all functions in the same view, they share the same scope. - let variables = generate_variables_in_scope_for_view(job, view_xref, scope, true); - - if variables.is_empty() { - return; - } - - // Get mutable access to the view - let allocator = job.allocator; let is_root = view_xref == job.root.xref; let view = if is_root { &mut job.root @@ -641,17 +634,12 @@ fn prepend_variables_to_arrow_functions<'a, 'b>( return; }; - // Prepend variables to each arrow function's ops - // We need to clone the variables for each function since they're consumed - for func_ptr in view.functions.iter() { + for (func_ptr, variables) in view.functions.iter().zip(variables_per_function) { // SAFETY: These pointers are valid as they point to ArrowFunctionExpr // allocated in the allocator and stored in the view's functions vec. let func = unsafe { &mut **func_ptr }; - - // Clone variables for this function - for var in variables.iter().rev() { - let cloned = clone_update_op(allocator, var); - func.ops.insert(0, cloned); + for var in variables.into_iter().rev() { + func.ops.insert(0, var); } } } @@ -796,6 +784,7 @@ fn create_reference_variable<'a>( fn create_context_let_reference_variable<'a>( allocator: &'a oxc_allocator::Allocator, xref: XrefId, + view: XrefId, name: Ident<'a>, target_id: XrefId, target_slot: Option, @@ -817,7 +806,7 @@ fn create_context_let_reference_variable<'a>( name, initializer: Box::new_in(initializer, &allocator), flags: VariableFlags::NONE, - view: None, + view: Some(view), local: false, }) } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/has_const_expression_collection.rs b/crates/oxc_angular_compiler/src/pipeline/phases/has_const_expression_collection.rs index 805143e8c..c5fa0ce4f 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/has_const_expression_collection.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/has_const_expression_collection.rs @@ -32,6 +32,7 @@ use crate::pipeline::phases::reify::ir_expression::convert_ir_expression; /// Pass 1: Transform ConstCollectedExpr to ConstReference, collecting expressions /// Pass 2: Add collected expressions to const pool (indices are pre-allocated) pub fn collect_const_expressions(job: &mut ComponentCompilationJob<'_>) { + super::generate_arrow_functions::collect_arrow_functions(job); let allocator = job.allocator; let root_xref = job.root.xref; diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/naming.rs b/crates/oxc_angular_compiler/src/pipeline/phases/naming.rs index 5c168e1c0..d6220ee2f 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/naming.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/naming.rs @@ -251,6 +251,7 @@ fn make_semantic_key<'a>( /// - Parent: `ComponentName_Conditional_0` /// - Child: `ComponentName_Conditional_0_ng_container_1` pub fn name_functions_and_variables(job: &mut ComponentCompilationJob<'_>) { + super::generate_arrow_functions::collect_arrow_functions(job); let allocator = job.allocator; let component_name = job.component_name.as_str(); @@ -283,12 +284,14 @@ pub fn name_functions_and_variables(job: &mut ComponentCompilationJob<'_>) { // Second pass: Propagate variable names to ReadVariableExpr expressions propagate_variable_names_in_view(&mut job.root.create, &mut job.root.update, &var_names); + propagate_variable_names_in_functions(&job.root.functions, &var_names); // Process embedded views let view_xrefs: Vec<_> = job.views.keys().copied().collect(); for xref in view_xrefs { if let Some(view) = job.view_mut(xref) { propagate_variable_names_in_view(&mut view.create, &mut view.update, &var_names); + propagate_variable_names_in_functions(&view.functions, &var_names); } } } @@ -399,7 +402,7 @@ fn process_view_ops_depth_first<'a>( // Phase 0: Process function ops FIRST (matches TypeScript ops() generator order) // Arrow functions have their own ops lists that contain Variable ops prepended // by the generate_variables phase. These must be named before create/update ops. - process_function_ops_in_view(job, view_xref, &allocator, state, var_names); + process_function_ops_in_view(job, view_xref, &allocator, state, var_names, semantic_var_names); // Phase 1: Collect info about child views with their create op indices // We need to collect upfront to avoid borrow issues during iteration @@ -948,7 +951,9 @@ fn process_function_ops_in_view<'a>( allocator: &'a oxc_allocator::Allocator, state: &mut NamingState, var_names: &mut FxHashMap>, + semantic_var_names: &mut FxHashMap, Ident<'a>>, ) { + let own_view = view_xref.unwrap_or(job.root.xref); let functions = match view_xref { None => &job.root.functions, Some(xref) => { @@ -975,16 +980,18 @@ fn process_function_ops_in_view<'a>( let mut arrow_fn_semantic_var_names: FxHashMap, Ident<'a>> = FxHashMap::default(); - // Process Variable ops in this arrow function + // Process Variable ops in this arrow function. Only the scope of the arrow + // function's own view is fresh (`getScopeForView(view, parentScope)`); variables + // that come from parent scopes are the same objects the view's other op lists + // use, so they share a name with them. for op in func.ops.iter_mut() { if let UpdateOp::Variable(var_op) = op { - name_variable_op( - var_op, - &allocator, - state, - var_names, - &mut arrow_fn_semantic_var_names, - ); + let semantic_names = if var_op.view == Some(own_view) { + &mut arrow_fn_semantic_var_names + } else { + &mut *semantic_var_names + }; + name_variable_op(var_op, &allocator, state, var_names, semantic_names); } } } @@ -1125,6 +1132,28 @@ fn propagate_variable_names_in_view<'a>( } } +/// Propagates variable names into the ops of a view's hoisted arrow functions, which the +/// expression visitors do not walk into. +fn propagate_variable_names_in_functions<'a>( + functions: &oxc_allocator::Vec<'a, *mut crate::ir::expression::ArrowFunctionExpr<'a>>, + var_names: &FxHashMap>, +) { + for func_ptr in functions.iter() { + // SAFETY: These pointers are valid as they point to ArrowFunctionExpr + // allocated in the allocator and stored in the view's functions vec. + let func = unsafe { &mut **func_ptr }; + for op in func.ops.iter_mut() { + transform_expressions_in_update_op( + op, + &|expr, _flags| { + propagate_name_to_expression(expr, var_names); + }, + VisitorContextFlag::NONE, + ); + } + } +} + /// Propagates a variable name to a ReadVariableExpr expression. fn propagate_name_to_expression<'a>( expr: &mut IrExpression<'a>, diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/next_context_merging.rs b/crates/oxc_angular_compiler/src/pipeline/phases/next_context_merging.rs index 473dff783..779061bd9 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/next_context_merging.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/next_context_merging.rs @@ -25,6 +25,7 @@ use crate::pipeline::compilation::ComponentCompilationJob; /// This optimization reduces the number of runtime calls by combining /// multiple nextContext(1) calls into a single nextContext(n) call. pub fn merge_next_context_expressions(job: &mut ComponentCompilationJob<'_>) { + super::generate_arrow_functions::collect_arrow_functions(job); // Collect view xrefs let view_xrefs: Vec<_> = job.all_views().map(|v| v.xref).collect(); diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/reify/angular_expression.rs b/crates/oxc_angular_compiler/src/pipeline/phases/reify/angular_expression.rs index dcacb502e..18510ad7c 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/reify/angular_expression.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/reify/angular_expression.rs @@ -839,7 +839,9 @@ fn convert_angular_expression_with_ctx<'a>( for elem in ttl.template.elements.iter() { elements.push(TemplateLiteralElement { text: elem.text.clone(), - raw_text: elem.text.clone(), + raw_text: crate::pipeline::conversion::cooked_to_raw_text( + allocator, &elem.text, + ), source_span: Some(elem.source_span.to_span()), }); } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/reify/ir_expression.rs b/crates/oxc_angular_compiler/src/pipeline/phases/reify/ir_expression.rs index a336806d1..4770af9e0 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/reify/ir_expression.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/reify/ir_expression.rs @@ -1080,6 +1080,15 @@ pub fn convert_ir_expression<'a>( crate::ir::expression::IrUnaryOperator::Minus => { crate::output::ast::UnaryOperator::Minus } + crate::ir::expression::IrUnaryOperator::Spread => { + return OutputExpression::SpreadElement(Box::new_in( + SpreadElementExpr { + expr: Box::new_in(expr, &allocator), + source_span: unary.source_span, + }, + &allocator, + )); + } }; OutputExpression::UnaryOperator(Box::new_in( crate::output::ast::UnaryOperatorExpr { @@ -1137,7 +1146,12 @@ pub fn convert_ir_expression<'a>( for elem in rtl.elements.iter() { elements.push(crate::output::ast::TemplateLiteralElement { text: elem.text.clone(), - raw_text: elem.text.clone(), + // elem.text is cooked; the emitter prints raw_text verbatim, + // so it must be re-escaped like upstream's + // escapeForTemplateLiteral(escapeSlashes(text)). + raw_text: crate::pipeline::conversion::cooked_to_raw_text( + allocator, &elem.text, + ), source_span: elem.source_span, }); } @@ -1152,6 +1166,15 @@ pub fn convert_ir_expression<'a>( )); } + if rtl.tagged { + return crate::pipeline::conversion::tagged_template_literal( + allocator, + elements, + output_expressions, + rtl.source_span, + ); + } + OutputExpression::TemplateLiteral(Box::new_in( crate::output::ast::TemplateLiteralExpr { elements, @@ -1171,7 +1194,11 @@ pub fn convert_ir_expression<'a>( for elem in ttl.elements.iter() { elements.push(crate::output::ast::TemplateLiteralElement { text: elem.text.clone(), - raw_text: elem.text.clone(), + // See ResolvedTemplateLiteral above: cooked text must be + // re-escaped for emission as raw text. + raw_text: crate::pipeline::conversion::cooked_to_raw_text( + allocator, &elem.text, + ), source_span: elem.source_span, }); } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs b/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs index b7df7c844..086a3d3f9 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs @@ -17,10 +17,14 @@ use oxc_allocator::{Box, Vec as OxcVec}; use oxc_diagnostics::OxcDiagnostic; use oxc_str::Ident; use rustc_hash::FxHashMap; +use std::cell::RefCell; use crate::ast::expression::AngularExpression; use crate::ir::enums::BindingKind; -use crate::ir::expression::IrExpression; +use crate::ir::expression::{ + IrExpression, VisitorContextFlag, transform_expressions_in_create_op, + transform_expressions_in_update_op, +}; use crate::ir::ops::{CreateOp, UpdateOp, XrefId}; use crate::output::ast::{ ArrowFunctionBody, ArrowFunctionExpr, FnParam, FunctionExpr, InvokeFunctionExpr, LiteralExpr, @@ -33,6 +37,7 @@ use crate::pipeline::compilation::{ }; use crate::pipeline::constant_pool::ConstantPool; use crate::pipeline::expression_store::ExpressionStore; +use crate::r3::Identifiers; use angular_expression::convert_angular_expression; use ir_expression::convert_ir_expression; @@ -215,6 +220,125 @@ pub fn reify(job: &mut ComponentCompilationJob<'_>) { job.diagnostics.extend(diagnostics); } +/// Settings shared by every operation reified for one compilation job. +struct ArrowFunctionReifier<'a, 'b> { + allocator: &'a oxc_allocator::Allocator, + core_namespace: &'a str, + expressions: &'b ExpressionStore<'a>, + root_xref: XrefId, + mode: TemplateCompilationMode, + supports_value_interpolation: bool, + supports_dom_property: bool, + pool: RefCell<&'b mut ConstantPool<'a>>, + diagnostics: RefCell<&'b mut Vec>, +} + +impl<'a> ArrowFunctionReifier<'a, '_> { + /// Replaces a hoisted arrow function with `ɵɵarrowFunction(varOffset, factory, ctx)`, + /// where `factory` is a shared constant `(ctx, view) => (params) => body`. + /// + /// Ported from the `ArrowFunction` case of `reifyIrExpression` and from + /// `getArrowFunctionFactory` in Angular's `reify.ts`. + fn reify(&self, expr: &mut IrExpression<'a>) { + let IrExpression::ArrowFunction(arrow_fn) = expr else { return }; + if !arrow_fn.hoisted { + return; + } + let allocator = self.allocator; + let Some(var_offset) = arrow_fn.var_offset else { + self.diagnostics.borrow_mut().push(OxcDiagnostic::error( + "AssertionError: variable offset was not assigned to arrow function", + )); + return; + }; + + let mut statements = OxcVec::new_in(&allocator); + for op in &arrow_fn.ops { + if let UpdateOp::Statement(stmt_op) = op { + statements.push(convert_statement_ir_nodes( + allocator, + self.core_namespace, + &stmt_op.statement, + self.expressions, + self.root_xref, + &mut self.diagnostics.borrow_mut(), + )); + } else if let Some(stmt) = reify_update_op( + allocator, + self.core_namespace, + op, + self.expressions, + self.root_xref, + self.mode, + &mut self.diagnostics.borrow_mut(), + self.supports_value_interpolation, + self.supports_dom_property, + ) { + statements.push(stmt); + } + } + let value = convert_ir_expression( + allocator, + self.core_namespace, + &arrow_fn.body, + self.expressions, + self.root_xref, + ); + let body = if statements.is_empty() { + ArrowFunctionBody::Expression(Box::new_in(value, &allocator)) + } else { + statements.push(OutputStatement::Return(Box::new_in( + ReturnStatement { value, source_span: None }, + &allocator, + ))); + ArrowFunctionBody::Statements(statements) + }; + + let mut params = OxcVec::with_capacity_in(arrow_fn.params.len(), &allocator); + for param in &arrow_fn.params { + params.push(FnParam { name: param.name }); + } + let inner = OutputExpression::ArrowFunction(Box::new_in( + ArrowFunctionExpr { params, body, source_span: arrow_fn.source_span }, + &allocator, + )); + + let mut factory_params = OxcVec::with_capacity_in(2, &allocator); + factory_params.push(FnParam { name: Ident::from("ctx") }); + factory_params.push(FnParam { name: Ident::from("view") }); + let factory = OutputExpression::ArrowFunction(Box::new_in( + ArrowFunctionExpr { + params: factory_params, + body: ArrowFunctionBody::Expression(Box::new_in(inner, &allocator)), + source_span: None, + }, + &allocator, + )); + let factory_ref = + self.pool.borrow_mut().get_shared_function_reference(factory, "arrowFn", true); + + let mut args = OxcVec::with_capacity_in(3, &allocator); + args.push(OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::Number(f64::from(var_offset)), source_span: None }, + &allocator, + ))); + args.push(factory_ref); + args.push(OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: Ident::from("ctx"), source_span: None }, + &allocator, + ))); + *expr = IrExpression::OutputExpr(Box::new_in( + utils::create_instruction_call_expr( + allocator, + self.core_namespace, + Identifiers::ARROW_FUNCTION, + args, + ), + &allocator, + )); + } +} + /// Reify a single view's operations to statements (without modifying the view). fn reify_view_to_stmts<'a>( allocator: &'a oxc_allocator::Allocator, @@ -229,6 +353,43 @@ fn reify_view_to_stmts<'a>( let mut update_stmts = std::vec::Vec::new(); let core_namespace = ctx.core_namespace; + // Hoist arrow functions before the operations that contain them are reified. + { + let reifier = ArrowFunctionReifier { + allocator, + core_namespace, + expressions, + root_xref, + mode: ctx.mode, + supports_value_interpolation: ctx.supports_value_interpolation, + supports_dom_property: ctx.supports_dom_property, + pool: RefCell::new(&mut *pool), + diagnostics: RefCell::new(&mut *diagnostics), + }; + for op in view.create.iter_mut() { + if !matches!( + op, + CreateOp::Listener(_) + | CreateOp::TwoWayListener(_) + | CreateOp::Animation(_) + | CreateOp::AnimationListener(_) + ) { + transform_expressions_in_create_op( + op, + &|expr, _flags| reifier.reify(expr), + VisitorContextFlag::NONE, + ); + } + } + for op in view.update.iter_mut() { + transform_expressions_in_update_op( + op, + &|expr, _flags| reifier.reify(expr), + VisitorContextFlag::NONE, + ); + } + } + // Reify create operations // Use iter_mut() so we can take ownership of expressions that can't be cloned for op in view.create.iter_mut() { @@ -1476,6 +1637,28 @@ pub fn reify_host(job: &mut HostBindingCompilationJob<'_>) { let core_namespace = job.core_namespace.as_str(); let mut diagnostics = Vec::new(); + // Hoist arrow functions before the operations that contain them are reified. + { + let reifier = ArrowFunctionReifier { + allocator, + core_namespace, + expressions: &job.expressions, + root_xref, + mode: TemplateCompilationMode::Full, + supports_value_interpolation, + supports_dom_property, + pool: RefCell::new(&mut job.pool), + diagnostics: RefCell::new(&mut diagnostics), + }; + for op in job.root.update.iter_mut() { + transform_expressions_in_update_op( + op, + &|expr, _flags| reifier.reify(expr), + VisitorContextFlag::NONE, + ); + } + } + // Reify create operations (listeners) for op in job.root.create.iter() { let stmt = reify_host_create_op( diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_contexts.rs b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_contexts.rs index 6d0142280..4e7ba4e24 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_contexts.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_contexts.rs @@ -35,6 +35,7 @@ enum ContextAccess { /// - `ContextExpr` for current view → kept as ContextExpr (reify handles ctx) /// - `ContextExpr` for ancestor view → ReadVariableExpr referencing saved context pub fn resolve_contexts(job: &mut ComponentCompilationJob<'_>) { + super::generate_arrow_functions::collect_arrow_functions(job); let allocator = job.allocator; let root_xref = job.root.xref; @@ -54,13 +55,9 @@ pub fn resolve_contexts(job: &mut ComponentCompilationJob<'_>) { // SAFETY: The pointer is valid because it was populated by generate_arrow_functions // and the allocator keeps the data alive. let arrow_fn = unsafe { &mut **fn_ptr }; - process_lexical_scope_update_vec( - &allocator, - view_xref, - is_root, - &mut arrow_fn.ops, - &errors, - ); + arrow_fn.with_handler(allocator, |ops, body| { + process_listener_handler_ops(allocator, view_xref, is_root, ops, body, &errors); + }); } process_lexical_scope_create(allocator, view_xref, is_root, &mut view.create, &errors); diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs index 3c750a149..ddd9be122 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs @@ -44,6 +44,7 @@ struct SavedView { /// - `RestoreViewExpr` → resolved to the saved view variable /// - `ExpressionRef` → `ReadVariable` if stored expression is PropertyRead(ImplicitReceiver, name) in scope pub fn resolve_names(job: &mut ComponentCompilationJob<'_>) { + super::generate_arrow_functions::collect_arrow_functions(job); let root_xref = job.root.xref; let allocator = job.allocator; @@ -62,6 +63,32 @@ pub fn resolve_names(job: &mut ComponentCompilationJob<'_>) { // SAFETY: We're only reading from expression_store, not modifying it let expressions = unsafe { &*expression_store_ptr }; + // Hoisted arrow functions are their own lexical scope, built from the variables + // prepended to their ops (resolve_names.ts: `processLexicalScope(unit, expr.ops, null)`). + for fn_ptr in view.functions.iter() { + // SAFETY: The pointer was collected from this view's operations at the start + // of this phase, and the allocator keeps the data alive. + let arrow_fn = unsafe { &mut **fn_ptr }; + let scope = build_scope_from_handler_ops(arrow_fn.ops.iter()); + for op in arrow_fn.ops.iter_mut() { + transform_expressions_in_update_op( + op, + &|expr, _flags| { + resolve_expression(expr, &scope, root_xref, None, &allocator, expressions); + }, + VisitorContextFlag::NONE, + ); + } + resolve_expression( + arrow_fn.body.as_mut(), + &scope, + root_xref, + None, + &allocator, + expressions, + ); + } + // Process create ops with their own scope (no update scope merged in) process_lexical_scope_create(root_xref, &mut view.create, None, &allocator, expressions); process_lexical_scope_update(root_xref, &mut view.update, None, &allocator, expressions); @@ -1250,6 +1277,7 @@ fn resolve_angular_expression_with_params<'a>( crate::ir::expression::ResolvedTemplateLiteralExpr { elements, expressions: resolved_exprs, + tagged: false, source_span: Some(tl.source_span.to_span()), }, &allocator, @@ -1709,6 +1737,7 @@ fn resolve_angular_expression_with_params<'a>( body: Box::new_in(body, &allocator), ops: oxc_allocator::Vec::new_in(&allocator), var_offset: None, + hoisted: false, source_span: Some(arrow.source_span.to_span()), }, &allocator, @@ -1802,10 +1831,41 @@ fn resolve_angular_expression_with_params<'a>( /// /// Host version - only processes the root unit (no embedded views). pub fn resolve_names_for_host(job: &mut HostBindingCompilationJob<'_>) { + // Upstream resolveNames iterates unit.functions for all job kinds; refresh + // the pointer list before reading it. + super::generate_arrow_functions::collect_arrow_functions_for_host(job); let allocator = job.allocator; let root_xref = job.root.xref; let expression_store_ptr = &job.expressions as *const ExpressionStore<'_>; + // Hoisted arrow functions are their own lexical scope, built from the + // variables prepended to their ops, same as in `resolve_names`. + for fn_ptr in job.root.functions.iter() { + // SAFETY: The pointer was collected from this unit's operations at the + // start of this phase, and the allocator keeps the data alive. + let arrow_fn = unsafe { &mut **fn_ptr }; + // SAFETY: We're only reading from expression_store, not modifying it + let expressions = unsafe { &*expression_store_ptr }; + let scope = build_scope_from_handler_ops(arrow_fn.ops.iter()); + for op in arrow_fn.ops.iter_mut() { + transform_expressions_in_update_op( + op, + &|expr, _flags| { + resolve_expression(expr, &scope, root_xref, None, &allocator, expressions); + }, + VisitorContextFlag::NONE, + ); + } + resolve_expression( + arrow_fn.body.as_mut(), + &scope, + root_xref, + None, + &allocator, + expressions, + ); + } + // SAFETY: We're only reading from expression_store, not modifying it let expressions = unsafe { &*expression_store_ptr }; diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/save_restore_view.rs b/crates/oxc_angular_compiler/src/pipeline/phases/save_restore_view.rs index 15c48bdd8..379ec4267 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/save_restore_view.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/save_restore_view.rs @@ -44,6 +44,7 @@ use crate::pipeline::compilation::ComponentCompilationJob; /// /// This order is critical for XrefId allocation which affects variable naming. pub fn save_and_restore_view(job: &mut ComponentCompilationJob<'_>) { + super::generate_arrow_functions::collect_arrow_functions(job); let allocator = job.allocator; let root_xref = job.root.xref; @@ -211,10 +212,18 @@ fn process_arrow_functions_in_view( // Insert at the beginning of the arrow function's ops func.ops.insert(0, restore_var); - // Note: Arrow functions don't have return statements to wrap - // because they are single-expression functions in templates. - // The ResetView wrapping only applies to listener handlers - // which can have explicit return statements. + // The body is the arrow function's return value: reset the view around it. + func.with_handler(allocator, |_ops, body| { + if let Some(expr) = body.take() { + *body = Some(Box::new_in( + IrExpression::ResetView(Box::new_in( + ResetViewExpr { expr, source_span: None }, + &allocator, + )), + &allocator, + )); + } + }); } } } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/strip_nonrequired_parentheses.rs b/crates/oxc_angular_compiler/src/pipeline/phases/strip_nonrequired_parentheses.rs index 5f6914189..77781346c 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/strip_nonrequired_parentheses.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/strip_nonrequired_parentheses.rs @@ -683,10 +683,12 @@ fn ir_is_unary(expr: &IrExpression<'_>) -> bool { // Look through nested parens: in `((-1)) ** 2` the outer wrapper is // the required one (the inner then strips to `(-1)`). IrExpression::Parenthesized(p) => ir_is_unary(&p.expr), - IrExpression::Unary(_) - | IrExpression::Not(_) - | IrExpression::Typeof(_) - | IrExpression::Void(_) => true, + IrExpression::Unary(u) => { + // IrUnaryOperator::Spread is only ever a call argument, never a + // `**` base — `(…x) ** 2` does not parse. + u.operator != crate::ir::expression::IrUnaryOperator::Spread + } + IrExpression::Not(_) | IrExpression::Typeof(_) | IrExpression::Void(_) => true, IrExpression::Ast(ast) => matches!( ast.as_ref(), AngularExpression::Unary(_) diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/temporary_variables.rs b/crates/oxc_angular_compiler/src/pipeline/phases/temporary_variables.rs index f37195191..0084d9098 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/temporary_variables.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/temporary_variables.rs @@ -60,6 +60,7 @@ use crate::pipeline::compilation::{ComponentCompilationJob, HostBindingCompilati /// This also recursively processes handler_ops for Listener, AnimationListener, and TwoWayListener, /// as well as track_by_ops for RepeaterCreate operations. pub fn generate_temporary_variables(job: &mut ComponentCompilationJob<'_>) { + super::generate_arrow_functions::collect_arrow_functions(job); let allocator = job.allocator; // Process all views - both create and update ops (matching TypeScript behavior) @@ -69,6 +70,17 @@ pub fn generate_temporary_variables(job: &mut ComponentCompilationJob<'_>) { let mut update_stmts = generate_temporaries_for_update(&mut view.update, &allocator); view.update.prepend(&mut update_stmts); + + for fn_ptr in view.functions.iter() { + // SAFETY: The pointer was collected from this view's operations at the start + // of this phase, and the allocator keeps the data alive. + let arrow_fn = unsafe { &mut **fn_ptr }; + arrow_fn.with_handler(allocator, |ops, body| { + let mut stmts = + generate_temporaries_for_handler_ops_with_expression(ops, body, &allocator); + prepend_update_ops(ops, &mut stmts, &allocator); + }); + } } } @@ -583,9 +595,13 @@ fn assign_temp_names<'a>( assign_temp_names(&mut spread.expr, tracker, &allocator); } - IrExpression::ArrowFunction(arrow_fn) => { - assign_temp_names(&mut arrow_fn.body, tracker, &allocator); - } + // Arrow functions form their own operation scope: the op visitors reach + // their internals only under IN_CHILD_OPERATION, which this phase skips. + // Hoisted arrows are handled separately through `unit.functions` (which + // holds their `ops`, matching upstream `generateTemporaries(expr.ops)`), + // so naming their temporaries here would give them the containing op's + // index and leave dead declarations in the outer scope. + IrExpression::ArrowFunction(_) => {} IrExpression::Parenthesized(paren) => { assign_temp_names(&mut paren.expr, tracker, &allocator); } @@ -676,6 +692,9 @@ impl TempVarTracker { /// Host version - only processes the root unit (no embedded views). /// Processes both create and update ops to match TypeScript behavior. pub fn generate_temporary_variables_for_host(job: &mut HostBindingCompilationJob<'_>) { + // Upstream's generateTemporaryVariables is Kind.Both, so host units process + // `unit.functions` too; refresh the pointer list before reading it. + super::generate_arrow_functions::collect_arrow_functions_for_host(job); let allocator = job.allocator; let mut create_stmts = generate_temporaries_for_create(&mut job.root.create, &allocator); @@ -683,4 +702,15 @@ pub fn generate_temporary_variables_for_host(job: &mut HostBindingCompilationJob let mut update_stmts = generate_temporaries_for_update(&mut job.root.update, &allocator); job.root.update.prepend(&mut update_stmts); + + for fn_ptr in job.root.functions.iter() { + // SAFETY: The pointer was collected from this unit's operations at the + // start of this phase, and the allocator keeps the data alive. + let arrow_fn = unsafe { &mut **fn_ptr }; + arrow_fn.with_handler(allocator, |ops, body| { + let mut stmts = + generate_temporaries_for_handler_ops_with_expression(ops, body, &allocator); + prepend_update_ops(ops, &mut stmts, &allocator); + }); + } } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/var_counting.rs b/crates/oxc_angular_compiler/src/pipeline/phases/var_counting.rs index e5cf59681..4a54e0ee1 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/var_counting.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/var_counting.rs @@ -652,18 +652,16 @@ fn assign_var_offsets_in_expr( } IrExpression::ArrowFunction(arrow_fn) => { - // Collected arrows (members of unit.functions, marked by - // generate_arrow_functions with a u32::MAX sentinel) consume a var - // slot upstream (var_counting.ts varsUsedByIrExpression), even though - // they are emitted in place here since `ɵɵarrowFunction` hoisting is - // not implemented. Arrows preserved in place (var_offset still None) - // consume nothing. Process ops and body for inner var consumers. - let is_collected = arrow_fn.var_offset == Some(u32::MAX); + // A hoisted arrow function consumes a var slot for `ɵɵarrowFunction`; one + // emitted in place does not. Var consumers inside the arrow's own ops + // and body are counted first — upstream's visitor transforms children + // before the node, so ngtsc numbers e.g. an inner pipe before the + // arrow's own offset (`pipeBind1(1,3, arrowFunction(2,...))`). for op in arrow_fn.ops.iter_mut() { assign_var_offsets_in_op(op, var_count, pure_functions_only); } assign_var_offsets_in_expr(&mut arrow_fn.body, var_count, pure_functions_only); - if is_collected && !pure_functions_only { + if arrow_fn.hoisted && !pure_functions_only { arrow_fn.var_offset = Some(*var_count); *var_count += 1; } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/variable_optimization.rs b/crates/oxc_angular_compiler/src/pipeline/phases/variable_optimization.rs index 1d70a6852..7d0f7d707 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/variable_optimization.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/variable_optimization.rs @@ -414,6 +414,7 @@ fn collect_create_op_fences(op: &CreateOp<'_>) -> Fence { /// yet, unused ReadVariable references in handler_ops inflate the usage counts of /// variables declared in create ops (e.g., SavedView), preventing their removal. pub fn optimize_variables(job: &mut ComponentCompilationJob<'_>) { + super::generate_arrow_functions::collect_arrow_functions(job); // Step 1: Inline AlwaysInline variables unconditionally // Per TypeScript's variable_optimization.ts lines 33-51 inline_always_inline_variables(job); @@ -1683,6 +1684,9 @@ fn optimize_arrow_function_ops<'a>(job: &mut ComponentCompilationJob<'a>) { let allocator = job.allocator; let mut local_diagnostics = Vec::new(); + // The inlining steps above rebuild expressions, so find the arrow functions again. + super::generate_arrow_functions::collect_arrow_functions(job); + // Collect view xrefs let view_xrefs: Vec = std::iter::once(job.root.xref).chain(job.views.keys().copied()).collect(); @@ -1703,12 +1707,21 @@ fn optimize_arrow_function_ops<'a>(job: &mut ComponentCompilationJob<'a>) { // allocated in the allocator and stored in the view's functions vec. let func = unsafe { &mut **func_ptr }; - // Step 1: Optimize variables in the arrow function's ops - // (No handler_expression for arrow functions) - optimize_handler_ops(&mut func.ops, None, &allocator, &mut local_diagnostics); + // The body plays the role a listener's handler_expression does. + func.with_handler(allocator, |ops, body| { + inline_always_inline_in_handler_ops_and_expr(ops, body.as_mut(), &allocator); + + // Step 1: Optimize variables in the arrow function's ops + optimize_handler_ops( + ops, + body.as_ref().map(|e| e.as_ref()), + &allocator, + &mut local_diagnostics, + ); - // Step 2: Apply save/restore view optimization - optimize_save_restore_view(&mut func.ops, &mut None, &allocator); + // Step 2: Apply save/restore view optimization + optimize_save_restore_view(ops, body, &allocator); + }); } } @@ -3763,7 +3776,12 @@ where expressions.push(transform_expression(e, &allocator, transform)); } IrExpression::ResolvedTemplateLiteral(OxcBox::new_in( - ResolvedTemplateLiteralExpr { elements, expressions, source_span: rtl.source_span }, + ResolvedTemplateLiteralExpr { + elements, + expressions, + tagged: rtl.tagged, + source_span: rtl.source_span, + }, &allocator, )) } @@ -3820,9 +3838,9 @@ where ArrowFunctionExpr { params, body: OxcBox::new_in(body, &allocator), - // ops are not transformed as they are transient data - ops: OxcVec::new_in(&allocator), + ops: crate::ir::expression::clone_arrow_function_ops(&arrow_fn.ops, allocator), var_offset: arrow_fn.var_offset, + hoisted: arrow_fn.hoisted, source_span: arrow_fn.source_span, }, &allocator, diff --git a/crates/oxc_angular_compiler/src/r3/identifiers.rs b/crates/oxc_angular_compiler/src/r3/identifiers.rs index 4b89ff0f0..8bde55d01 100644 --- a/crates/oxc_angular_compiler/src/r3/identifiers.rs +++ b/crates/oxc_angular_compiler/src/r3/identifiers.rs @@ -866,6 +866,9 @@ impl Identifiers { /// Store a let value. pub const STORE_LET: &'static str = "ɵɵstoreLet"; + /// Instantiate a hoisted arrow function. + pub const ARROW_FUNCTION: &'static str = "ɵɵarrowFunction"; + /// Read a context let. pub const READ_CONTEXT_LET: &'static str = "ɵɵreadContextLet"; diff --git a/crates/oxc_angular_compiler/tests/arrow_function_scope_test.rs b/crates/oxc_angular_compiler/tests/arrow_function_scope_test.rs index c7053caa7..9e7885aa1 100644 --- a/crates/oxc_angular_compiler/tests/arrow_function_scope_test.rs +++ b/crates/oxc_angular_compiler/tests/arrow_function_scope_test.rs @@ -2,10 +2,13 @@ //! parameter (or an enclosing arrow's) before template variables and the //! component context: `open.update((o) => !o)` must not emit `(o) => !ctx.o`. //! +//! Arrows in listeners stay in place; everywhere else they are hoisted to a +//! shared factory and instantiated through `ɵɵarrowFunction`. +//! //! Ported from Angular's `updateParameterReferences` in -//! `template/pipeline/src/ingest.ts`. The expected strings are ngtsc's output -//! for the same templates, in this emitter's spelling (parenthesized params -//! and binary operands). +//! `template/pipeline/src/ingest.ts` and its `generateArrowFunctions` phase. +//! The expected strings are ngtsc's output for the same templates, in this +//! emitter's spelling (parenthesized params and binary operands). use oxc_allocator::Allocator; use oxc_angular_compiler::{ @@ -154,76 +157,152 @@ fn nested_arrow_parameters_leak_to_later_reads_like_ngtsc() { ); } -// Outside listeners ngtsc hoists the arrow through `ɵɵarrowFunction`, which is not -// implemented here: the arrow is emitted in place. Its body must still match the -// body of ngtsc's hoisted function. +// Outside listeners the arrow is hoisted to a shared factory, `(ctx, view) => arrow`, +// and instantiated with `ɵɵarrowFunction(varOffset, factory, ctx)`. + +#[track_caller] +fn assert_hoists(template: &str, factory: &str, usage: &str) { + let code = compile_tpl(template); + assert!(code.contains(factory), "expected `{factory}` for `{template}`, got:\n{code}"); + assert!(code.contains(usage), "expected `{usage}` for `{template}`, got:\n{code}"); +} #[test] fn property_binding_arrow_reads_its_parameter() { - assert_emits( + assert_hoists( r#"
"#, - r#"i0.ɵɵproperty("title",ctx.run((o)=>!o));"#, + "constarrowFn0=(ctx,view)=>(o)=>!o;", + r#"i0.ɵɵproperty("title",ctx.run(i0.ɵɵarrowFunction(1,arrowFn0,ctx)));"#, ); - assert_emits( + assert_hoists( r#"
"#, - r#"i0.ɵɵproperty("title",ctx.run((open)=>!open));"#, - ); - assert_emits( - r#"
"#, - r#"i0.ɵɵproperty("title",ctx.run((a)=>(b)=>((a+b)+ctx.other)));"#, + "constarrowFn0=(ctx,view)=>(open)=>!open;", + r#"i0.ɵɵproperty("title",ctx.run(i0.ɵɵarrowFunction(1,arrowFn0,ctx)));"#, ); } #[test] fn property_binding_arrow_without_parameters_reads_the_component() { - assert_emits( + assert_hoists( r#"
"#, - r#"i0.ɵɵproperty("title",ctx.run(()=>!ctx.open()));"#, + "constarrowFn0=(ctx,view)=>()=>!ctx.open();", + r#"i0.ɵɵproperty("title",ctx.run(i0.ɵɵarrowFunction(1,arrowFn0,ctx)));"#, + ); +} + +/// Only the outermost arrow is hoisted; the arrow it returns stays in place. +#[test] +fn nested_arrow_stays_inside_the_hoisted_arrow() { + assert_hoists( + r#"
"#, + "constarrowFn0=(ctx,view)=>(a)=>(b)=>((a+b)+ctx.other);", + r#"i0.ɵɵproperty("title",ctx.run(i0.ɵɵarrowFunction(1,arrowFn0,ctx)));"#, ); } #[test] fn property_binding_parameter_shadows_template_variables() { - assert_emits( + assert_hoists( r#"@let v = 1;
{{ v }}"#, - r#"i0.ɵɵproperty("title",ctx.run((v)=>(v+ctx.other)));"#, + "constarrowFn0=(ctx,view)=>(v)=>(v+ctx.other);", + r#"i0.ɵɵproperty("title",ctx.run(i0.ɵɵarrowFunction(2,arrowFn0,ctx)));"#, ); - assert_emits( - r#"@for (item of items; track item) {
}"#, - r#"i0.ɵɵproperty("title",ctx_r0.run((item)=>(item+ctx_r0.other)));"#, - ); - assert_emits( + assert_hoists( r#"
"#, - r#"i0.ɵɵproperty("title",ctx.run((el)=>(el+ctx.other)));"#, + "constarrowFn0=(ctx,view)=>(el)=>(el+ctx.other);", + r#"i0.ɵɵproperty("title",ctx.run(i0.ɵɵarrowFunction(1,arrowFn0,ctx)));"#, ); } +/// In an embedded view the hoisted arrow restores the view it was created in, then +/// walks to the component context itself. #[test] -fn property_binding_arrow_reads_template_variables_that_are_not_parameters() { - assert_emits( - r#"@for (item of items; track item) {
}"#, - "constitem_r1=ctx.$implicit;constctx_r1=i0.ɵɵnextContext();constel_r3=i0.ɵɵreference(1);", +fn hoisted_arrow_in_an_embedded_view_restores_the_view() { + assert_hoists( + r#"@for (item of items; track item) {
}"#, + "constarrowFn0=(ctx,view)=>(item)=>{i0.ɵɵrestoreView(view);\ + constctx_r0=i0.ɵɵnextContext();returni0.ɵɵresetView((item+ctx_r0.other));};", + r#"constctx_r0=i0.ɵɵnextContext();i0.ɵɵproperty("title",ctx_r0.run(i0.ɵɵarrowFunction(1,arrowFn0,ctx)));"#, ); - assert_emits( - r#"@for (item of items; track item) {
}"#, - "ctx_r1.run((x)=>(((x+item_r1)+el_r3.value)+ctx_r1.other))", +} + +#[test] +fn hoisted_arrow_reads_template_variables_that_are_not_parameters() { + assert_hoists( + r#"@for (item of items; track item) {
}"#, + "constarrowFn0=(ctx,view)=>(x)=>{constitem_r1=i0.ɵɵrestoreView(view).$implicit;\ + constctx_r1=i0.ɵɵnextContext();constel_r3=i0.ɵɵreference(1);\ + returni0.ɵɵresetView((((x+item_r1)+el_r3.value)+ctx_r1.other));};", + r#"constctx_r1=i0.ɵɵnextContext();i0.ɵɵproperty("title",ctx_r1.run(i0.ɵɵarrowFunction(1,arrowFn0,ctx)));"#, ); } #[test] fn interpolation_arrow_reads_its_parameters() { - assert_emits("{{ run((o) => !o) }}", "i0.ɵɵtextInterpolate(ctx.run((o)=>!o));"); - assert_emits("{{ run((a, b) => a + b) }}", "i0.ɵɵtextInterpolate(ctx.run((a,b)=>(a+b)));"); - assert_emits( + assert_hoists( + "{{ run((a, b) => a + b) }}", + "constarrowFn0=(ctx,view)=>(a,b)=>(a+b);", + "i0.ɵɵtextInterpolate(ctx.run(i0.ɵɵarrowFunction(1,arrowFn0,ctx)));", + ); + assert_hoists( "{{ run((o) => o || fallback()) }}", - "i0.ɵɵtextInterpolate(ctx.run((o)=>(o||ctx.fallback())));", + "constarrowFn0=(ctx,view)=>(o)=>(o||ctx.fallback());", + "i0.ɵɵtextInterpolate(ctx.run(i0.ɵɵarrowFunction(1,arrowFn0,ctx)));", + ); + assert_hoists( + "{{ run(() => !open()) }}", + "constarrowFn0=(ctx,view)=>()=>!ctx.open();", + "i0.ɵɵtextInterpolate(ctx.run(i0.ɵɵarrowFunction(1,arrowFn0,ctx)));", + ); +} + +/// Equivalent arrows share one factory but each gets its own var slot. +#[test] +fn identical_arrows_share_a_factory() { + let code = compile_tpl("{{ run((o) => !o) }}{{ run((o) => !o) }}"); + assert_eq!(code.matches("constarrowFn").count(), 1, "got:\n{code}"); + assert!(code.contains("i0.ɵɵarrowFunction(2,arrowFn0,ctx)"), "got:\n{code}"); + assert!(code.contains("i0.ɵɵarrowFunction(3,arrowFn0,ctx)"), "got:\n{code}"); +} + +/// ngtsc hoists an arrow in a `track` expression into the track function, where `ctx` +/// is not in scope, so that call throws. The arrow is kept in place instead. +#[test] +fn track_expression_arrow_stays_in_place() { + let code = compile_tpl("@for (item of items; track run((a) => a)) {}"); + assert!(code.contains("returnthis.run((a)=>a);"), "got:\n{code}"); + assert!(!code.contains("arrowFn"), "got:\n{code}"); +} + +/// An `@let` that the optimizer turns into a plain statement still has its arrow +/// function named, and the arrow keeps its `restoreView` statement. +#[test] +fn let_arrows_reading_other_lets() { + let code = compile_tpl("@let a = other; @let f = () => a; @let g = () => f() + a; {{ g() }}"); + assert!( + code.contains( + "constarrowFn0=(ctx,view)=>()=>{i0.ɵɵrestoreView(view);\ + consta_r1=i0.ɵɵreadContextLet(0);returni0.ɵɵresetView(a_r1);};" + ), + "got:\n{code}" + ); + assert!( + code.contains( + "constarrowFn1=(ctx,view)=>()=>{i0.ɵɵrestoreView(view);\ + consta_r2=i0.ɵɵreadContextLet(0);constf_r3=i0.ɵɵreadContextLet(2);\ + returni0.ɵɵresetView((f_r3()+a_r2));};" + ), + "got:\n{code}" ); - assert_emits("{{ run(() => !open()) }}", "i0.ɵɵtextInterpolate(ctx.run(()=>!ctx.open()));"); } #[test] -fn let_declaration_arrow_reads_its_parameter() { - assert_emits("@let fn = (a) => a + other; {{ fn(1) }}", "constfn_r1=(a)=>(a+ctx.other);"); +fn let_declaration_arrow_is_hoisted() { + assert_hoists( + "@let fn = (a) => a + other; {{ fn(1) }}", + "constarrowFn0=(ctx,view)=>(a)=>(a+ctx.other);", + "constfn_r1=i0.ɵɵarrowFunction(1,arrowFn0,ctx);", + ); } #[test] @@ -253,3 +332,33 @@ export class D { "got:\n{code}" ); } + +#[test] +fn directive_host_binding_arrow_is_hoisted() { + let allocator = Allocator::default(); + let options = + TransformOptions { compilation_mode: CompilationMode::Full, ..Default::default() }; + let source = "import {Directive} from '@angular/core'; +@Directive({ + selector: '[d]', + host: {'[attr.with-context]': '((a, b) => a / b + componentProp)(6, 12)'}, +}) +export class D { + componentProp = 1; +} +"; + let result = transform_angular_file(&allocator, "test.ts", source, Some(&options), None); + assert!(!result.has_errors(), "should not have errors, got: {:?}", result.diagnostics); + let code: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + code.contains("constarrowFn0=(ctx,view)=>(a,b)=>((a/b)+ctx.componentProp);"), + "got:\n{code}" + ); + assert!( + code.contains( + r#"i0.ɵɵattribute("with-context",i0.ɵɵarrowFunction(1,arrowFn0,ctx)(6,12));"# + ), + "got:\n{code}" + ); + assert!(code.contains("hostVars:2"), "got:\n{code}"); +} diff --git a/crates/oxc_angular_compiler/tests/directive_host_binding_constants_test.rs b/crates/oxc_angular_compiler/tests/directive_host_binding_constants_test.rs new file mode 100644 index 000000000..0b9a2666f --- /dev/null +++ b/crates/oxc_angular_compiler/tests/directive_host_binding_constants_test.rs @@ -0,0 +1,26 @@ +//! Constants pooled while compiling a `@Directive`'s host bindings must be declared in +//! the output: `'[attr.x]': 'f([a, b])'` emits `ɵɵpureFunction2(1, _c0, ...)`, and +//! `_c0` has to exist. Components already declared theirs. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{CompilationMode, TransformOptions, transform_angular_file}; + +#[test] +fn directive_host_binding_pure_function_is_declared() { + let allocator = Allocator::default(); + let options = + TransformOptions { compilation_mode: CompilationMode::Full, ..Default::default() }; + let source = "import {Directive} from '@angular/core'; +@Directive({selector: '[d]', host: {'[attr.x]': 'f([a, b])'}}) +export class D { + a = 1; + b = 2; + f(x: unknown) { return x; } +} +"; + let result = transform_angular_file(&allocator, "test.ts", source, Some(&options), None); + assert!(!result.has_errors(), "should not have errors, got: {:?}", result.diagnostics); + let code: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!(code.contains("i0.ɵɵpureFunction2(1,_c0,ctx.a,ctx.b)"), "got:\n{code}"); + assert!(code.contains("const_c0=(a0,a1)=>[a0,a1];exportclassD{"), "got:\n{code}"); +} diff --git a/crates/oxc_angular_compiler/tests/spread_and_tagged_template_test.rs b/crates/oxc_angular_compiler/tests/spread_and_tagged_template_test.rs new file mode 100644 index 000000000..4d207ac14 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/spread_and_tagged_template_test.rs @@ -0,0 +1,115 @@ +//! Spread call arguments and tagged template literals keep their shape and resolve the +//! names they read: `f(1, ...item)` inside a `@for` must not emit `ctx_r0.f(1, ctx.item)`, +//! and `` tag`a${item}` `` must not read `tag` and `item` off the embedded view's `ctx`. +//! +//! The expected strings are ngtsc's output for the same templates, in this emitter's +//! spelling. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{ + output::emitter::JsEmitter, + parser::html::HtmlParser, + pipeline::{emit::compile_template, ingest::ingest_component}, + transform::html_to_r3::{HtmlToR3Transform, TransformOptions as HtmlTransformOptions}, +}; +use oxc_str::Ident; + +/// Compile a template through the IR pipeline, with whitespace removed. +fn compile_tpl(template: &str) -> String { + let allocator = Allocator::default(); + let parser = HtmlParser::with_expansion_forms(&allocator, template, "test.html"); + let html_result = parser.parse(); + assert!(html_result.errors.is_empty()); + let transformer = HtmlToR3Transform::new( + &allocator, + template, + HtmlTransformOptions { angular_version: None, ..HtmlTransformOptions::default() }, + ); + let r3_result = transformer.transform(&html_result.nodes); + assert!(r3_result.errors.is_empty()); + let mut job = ingest_component(&allocator, Ident::from("TestComponent"), r3_result.nodes); + let result = compile_template(&mut job); + let emitter = JsEmitter::new(); + let mut output = String::new(); + for decl in &result.declarations { + output.push_str(&emitter.emit_statement(decl)); + } + output.push_str(&emitter.emit_statements(&result.template_fn.statements)); + output.chars().filter(|c| !c.is_whitespace()).collect() +} + +#[track_caller] +fn assert_emits(template: &str, expected: &str) { + let code = compile_tpl(template); + assert!(code.contains(expected), "expected `{expected}` for `{template}`, got:\n{code}"); +} + +#[test] +fn spread_argument_is_kept() { + assert_emits("{{ f(...items) }}", "i0.ɵɵtextInterpolate(ctx.f(...ctx.items));"); + assert_emits( + "{{ f(1, ...items, other, ...other) }}", + "i0.ɵɵtextInterpolate(ctx.f(1,...ctx.items,ctx.other,...ctx.other));", + ); +} + +#[test] +fn spread_argument_reads_a_template_variable() { + assert_emits( + "@for (item of items; track item) {{{ f(1, ...item) }}}", + "constitem_r1=ctx.$implicit;constctx_r1=i0.ɵɵnextContext();\ + i0.ɵɵtextInterpolate(ctx_r1.f(1,...item_r1));", + ); +} + +#[test] +fn spread_argument_of_a_safe_call() { + assert_emits("{{ f?.(...items) }}", "ctx.f(...ctx.items)"); +} + +#[test] +fn spread_argument_in_a_listener() { + assert_emits( + r#""#, + "returnctx.f(...ctx.items,$event);", + ); +} + +#[test] +fn spread_argument_reads_an_arrow_function_parameter() { + assert_emits( + r#""#, + "returnctx.f((o)=>ctx.f(1,...o));", + ); +} + +#[test] +fn tagged_template_is_emitted_natively() { + assert_emits("{{ tag`a${other}b` }}", "i0.ɵɵtextInterpolate(ctx.tag`a${ctx.other}b`);"); + assert_emits("{{ tag`plain` }}", "i0.ɵɵtextInterpolate(ctx.tag`plain`);"); +} + +#[test] +fn tagged_template_reads_template_variables() { + assert_emits( + "@for (item of items; track item) {{{ tag`a${item}b${other}` }}}", + "constitem_r1=ctx.$implicit;constctx_r1=i0.ɵɵnextContext();\ + i0.ɵɵtextInterpolate(ctx_r1.tag`a${item_r1}b${ctx_r1.other}`);", + ); +} + +#[test] +fn tagged_template_reads_arrow_function_parameters() { + assert_emits( + r#""#, + "returnctx.f((o)=>(p)=>ctx.tag`${o}${p}${ctx.other}`);", + ); +} + +/// The elements hold cooked text, so text that would end the literal or start an +/// interpolation is escaped again when the tagged template is printed, matching +/// ngtsc's `escapeForTemplateLiteral` spelling (`${` becomes `$\{`). +#[test] +fn tagged_template_text_is_escaped() { + assert_emits(r"{{ tag`a\`b\${c}d\\e` }}", r"ctx.tag`a\`b$\{c}d\\e`"); +} diff --git a/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts b/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts index 3de24beb5..23c73f3d0 100644 --- a/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts +++ b/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts @@ -324,4 +324,8 @@ export const KNOWN_DIFFERENCES: Record = { fields: ['OnPushNoneStyleComponent.ɵcmp'], reasons: [CHANGE_DETECTION_V22], }, + 'templates/ng-arrow-function-pipe': { + fields: ['TestPipe.ɵfac'], + reasons: [FACTORY], + }, } diff --git a/napi/angular-compiler/e2e/compare/fixtures/templates/arrow-functions.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/templates/arrow-functions.fixture.ts index 5abc853b4..8eb715d12 100644 --- a/napi/angular-compiler/e2e/compare/fixtures/templates/arrow-functions.fixture.ts +++ b/napi/angular-compiler/e2e/compare/fixtures/templates/arrow-functions.fixture.ts @@ -4,8 +4,8 @@ * A name read inside an arrow body resolves to the arrow's own parameter (or * an enclosing arrow's) before template variables and the component context. * - * Only event bindings are covered: there the arrow stays in place. Elsewhere - * Angular hoists it through ɵɵarrowFunction, which is not implemented yet. + * In event bindings the arrow stays in place. Everywhere else it is hoisted to + * a constant and instantiated through ɵɵarrowFunction. * * The `ng-*` fixtures are ported from Angular's compliance suite: * packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_arrow_functions @@ -166,4 +166,342 @@ export class TestDir { `.trim(), expectedFeatures: ['ɵɵlistener'], }, + + // Property bindings, interpolations and @let: the arrow is hoisted via ɵɵarrowFunction. + arrow( + 'arrow-property-one-param', + 'Property binding arrow reading its own parameter', + `
`, + ), + arrow( + 'arrow-property-no-param', + 'Property binding arrow reading only the component', + `
`, + ), + arrow( + 'arrow-property-param-shadows-property', + 'Property binding arrow parameter named like a component property', + `
`, + ), + arrow( + 'arrow-property-param-and-property', + 'Property binding arrow reading a parameter and a component property', + `
`, + ), + arrow( + 'arrow-property-nested', + 'Nested property binding arrow reading the outer parameter', + `
`, + ), + arrow( + 'arrow-property-param-shadows-let', + 'Property binding arrow parameter shadowing an @let', + `@let v = 1;
{{ v }}`, + ), + arrow( + 'arrow-property-param-shadows-for-item', + 'Property binding arrow parameter shadowing a @for item', + `@for (item of items; track item) {
}`, + ), + arrow( + 'arrow-property-param-shadows-ref', + 'Property binding arrow parameter shadowing a template reference', + `
`, + ), + arrow( + 'arrow-property-reads-template-variables', + 'Property binding arrow reading a parameter, a @for item, a reference and the component', + `@for (item of items; track item) {
}`, + ), + arrow( + 'arrow-property-several-bindings', + 'Arrows in class, style and attribute bindings on one element', + `
`, + ), + arrow( + 'arrow-control-flow-expressions', + 'Arrows in @if, @for and @switch expressions', + `@if (run((o) => !o)) { a } @for (i of run((o) => o); track i) { {{ run((j) => j + i) }} } @switch (run((o) => o)) { @case (run((p) => p)) { b } }`, + ), + arrow( + 'arrow-interpolation-one-param', + 'Interpolation arrow reading its own parameter', + `{{ run((o) => !o) }}`, + ), + arrow( + 'arrow-interpolation-multiple-params', + 'Interpolation arrow reading several parameters', + `{{ run((a, b) => a + b) }}`, + ), + arrow( + 'arrow-interpolation-param-and-property', + 'Interpolation arrow reading a parameter and a component property', + `{{ run((o) => o || fallback()) }}`, + ), + arrow( + 'arrow-interpolation-no-param', + 'Interpolation arrow reading only the component', + `{{ run(() => !open()) }}`, + ), + arrow( + 'arrow-interpolation-identical-arrows', + 'Two identical arrows share one hoisted constant', + `{{ run((o) => !o) }}{{ run((o) => !o) }}`, + ), + arrow( + 'arrow-hoisted-root-reference', + 'Hoisted arrow reading a template reference in the root view', + `
`, + ), + arrow( + 'arrow-hoisted-if-alias', + 'Hoisted arrow reading an @if alias', + `@if (open(); as alias) {
}`, + ), + arrow( + 'arrow-hoisted-ng-template-variables', + 'Hoisted arrow reading ng-template context variables', + `
`, + ), + arrow( + 'arrow-hoisted-two-in-one-view', + 'Two hoisted arrows in one embedded view', + `@for (item of items; track item) {
}`, + ), + arrow( + 'arrow-hoisted-nested-views', + 'Hoisted arrow three views deep reading a @for item and a reference', + `@for (item of items; track item) { @if (other) { @if (open()) { {{ run((p) => p + item + el.value + other) }} } } }`, + ), + arrow( + 'arrow-hoisted-same-arrow-in-two-views', + 'Identical arrows in different views share one constant', + `{{ run((o) => !o) }} @if (other) { {{ run((o) => !o) }} }`, + ), + arrow( + 'arrow-hoisted-order', + 'Hoisted constants are numbered in template order across views', + `
@if (other) { {{ run((b) => b) }} }
@for (i of items; track i) { {{ run((d) => d + i) }} }`, + ), + arrow( + 'arrow-hoisted-i18n', + 'Hoisted arrow in an i18n interpolation', + `
Hello {{ run((o) => o + other) }}
`, + ), + arrow( + 'arrow-let-reading-let', + '@let arrow reading another @let', + `@let base = 2; @let fn = (a) => a + base; {{ fn(1) }}`, + ), + arrow( + 'arrow-let-chain', + '@let arrows calling each other', + `@let a = other; @let f = () => a; @let g = () => f() + a; {{ g() }}`, + ), + arrow( + 'arrow-let-in-for', + '@let arrow declared in a @for and used in a child view', + `@for (item of items; track item) { @let f = () => item; {{ f() }} @if (other) { {{ f() }} } }`, + ), + + // Hoisted cases ported from Angular's compliance suite. + arrow( + 'ng-arrow-function-top-level-context', + 'arrow_function_top_level_context.ts', + `{{(param => param + value + 1)('param')}}`, + ` + value = 0; +`, + ), + arrow( + 'ng-arrow-function-this-access', + 'arrow_function_this_access.ts', + `{{((a, b) => a + this.a + b + this.b)(1, 3)}}`, + ` + a = 2; + b = 4; +`, + ), + arrow( + 'ng-arrow-function-defined-let', + 'arrow_function_defined_let.ts', + ` + @let fn = (a, b) => componentValue + a + b; + One: {{fn(0, 1)}} + @if (true) { + Two: {{fn(1, 1)}} + + } + `, + ` + componentValue = 0; +`, + ), + arrow( + 'ng-arrow-function-loop-variables', + 'arrow_function_loop_variables.ts', + ` + @for (item of items; track $index; let outerEven = $even) { + @for (subitem of item.subItems; track $index) { + {{(() => outerEven || $even || $index)()}} + } + } + `, + ` + items = [{ name: 'one', subItems: ['sub one', 'sub two'] }]; +`, + ), + arrow( + 'ng-arrow-function-let-nested', + 'arrow_function_let_nested.ts', + ` + @let a = 1; + @if (true) { + @let b = 2; + @if (true) { + @let c = 3; + {{(() => a + b + c)()}} + } + } + `, + ` + +`, + ), + arrow( + 'ng-arrow-function-inside-pure-value', + 'arrow_function_inside_pure_value.ts', + ` + {{[(a) => a + 1][0](1000)}} + {{[(a) => a + 1 + componentProp][0](1000)}} + `, + ` + componentProp = 0; +`, + ), + arrow( + 'ng-arrow-function-pure-return-values', + 'arrow_function_pure_return_values.ts', + ` + {{(a => ({foo: a, bar: componentProp}))(1).foo}} + `, + ` + componentProp = 0; +`, + ), + arrow( + 'ng-arrow-function-returning-arrow-function-no-context', + 'arrow_function_returning_arrow_function_no_context.ts', + `{{(a => b => c => d => a + b + c + d)(1)(2)(3)(4)}}`, + ` + +`, + ), + arrow( + 'ng-arrow-function-returning-arrow-function-top-level-context', + 'arrow_function_returning_arrow_function_top_level_context.ts', + `{{(a => b => c => d => a + b + c + d + componentProp)(1)(2)(3)(4)}}`, + ` + componentProp = 0; +`, + ), + arrow( + 'ng-arrow-function-returning-arrow-function-nested-context', + 'arrow_function_returning_arrow_function_nested_context.ts', + ` + @let topLevelLet = 1; + @if (true) { + @let nestedLet = 2; + @if (true) { + {{(a => b => c => d => a + b + c + d + componentProp + topLevelLet + nestedLet)(1)(2)(3)(4)}} + } + } + `, + ` + componentProp = 0; +`, + ), + arrow( + 'ng-arrow-function-safe-access', + 'arrow_function_safe_access.ts', + ` + {{(value => value?.a?.b?.c?.()?.()?.()?.())(componentProp)}} +
+ {{() => componentProp?.a?.b?.c?.()?.()?.()?.()}} + `, + ` + componentProp: {a?: {b?: {c?: () => () => () => () => string}}} = {}; +`, + ), + arrow( + 'ng-arrow-function-safe-access-nested-views', + 'arrow_function_safe_access_nested_views.ts', + ` + @if (true) { + @if (true) { + @if (true) { + {{() => componentProp?.a?.b?.c?.()?.()?.()?.()}} + } + } + } + `, + ` + componentProp: {a?: {b?: {c?: () => () => () => () => string}}} = {}; +`, + ), + { + name: 'ng-arrow-function-pipe', + category: 'templates', + description: 'arrow_function_pipe.ts', + className: 'TestComp', + type: 'full-transform', + sourceCode: ` +import { Component, Pipe } from '@angular/core'; + +@Pipe({ name: 'test', standalone: true }) +export class TestPipe { + transform(value: Function) { + return value; + } +} + +@Component({ + selector: 'app-test-comp', + standalone: true, + template: \` + {{(a, b) => a + b | test}} +
+ {{(a, b) => a + b + componentProp | test}} + \`, + imports: [TestPipe], +}) +export class TestComp { + componentProp = 0; +} + `.trim(), + expectedFeatures: ['ɵɵarrowFunction'], + }, + { + name: 'ng-arrow-function-host-binding', + category: 'templates', + description: 'arrow_function_host_binding.ts', + className: 'TestDir', + type: 'full-transform', + sourceCode: ` +import { Directive } from '@angular/core'; + +@Directive({ + selector: '[appTestDir]', + standalone: true, + host: { + '[attr.no-context]': '((a, b) => a / b)(5, 10)', + '[attr.with-context]': '((a, b) => a / b + componentProp)(6, 12)', + }, +}) +export class TestDir { + componentProp = 1; +} + `.trim(), + expectedFeatures: ['ɵɵarrowFunction'], + }, ] diff --git a/napi/angular-compiler/e2e/compare/fixtures/templates/spread-and-tagged-templates.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/templates/spread-and-tagged-templates.fixture.ts new file mode 100644 index 000000000..151b79024 --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/templates/spread-and-tagged-templates.fixture.ts @@ -0,0 +1,128 @@ +/** + * Spread call arguments and tagged template literals. + * + * Both must keep their shape (`...arg`, the tag) and resolve the names they + * read like any other expression: template variables first, then the component. + */ +import type { Fixture } from '../types.js' + +/** Escapes text so it can sit inside a backtick-quoted string in the generated source. */ +function escapeForTemplateLiteral(text: string): string { + return text.replaceAll('\\', '\\\\').replaceAll('`', '\\`').replaceAll('${', '\\${') +} + +function fixture(name: string, description: string, template: string): Fixture { + const className = `${name.replace(/(^|-)(\w)/g, (_, _dash, c) => c.toUpperCase())}Component` + return { + name, + category: 'templates', + description, + className, + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; +import { JsonPipe, SlicePipe } from '@angular/common'; + +@Component({ + selector: 'app-${name}', + standalone: true, + imports: [JsonPipe, SlicePipe], + template: \`${escapeForTemplateLiteral(template)}\`, +}) +export class ${className} { + items: any[] = []; + other: any = 1; + f(...args: any[]): any {} + tag(...args: any[]): any {} +} + `.trim(), + expectedFeatures: [], + } +} + +export const fixtures: Fixture[] = [ + fixture('spread-argument', 'Spread call argument', '{{ f(...items) }}'), + fixture( + 'spread-argument-mixed', + 'Spread call argument among other arguments', + '{{ f(1, ...items, other, ...other) }}', + ), + fixture( + 'spread-argument-template-variable', + 'Spread call argument reading a @for item', + '@for (item of items; track item) { {{ f(1, ...item) }} }', + ), + fixture( + 'spread-argument-safe-call', + 'Spread argument of a safe call', + '@for (item of items; track item) { {{ f?.(...item) }} }', + ), + fixture( + 'spread-argument-listener', + 'Spread call argument in a listener', + '@for (item of items; track item) { }', + ), + fixture( + 'spread-argument-arrow-parameter', + 'Spread call argument reading an arrow function parameter', + '{{ f((p) => f(...p, other)) }}', + ), + fixture('tagged-template', 'Tagged template literal', '{{ tag`a${other}b` }}'), + fixture( + 'tagged-template-template-variable', + 'Tagged template literal reading a @for item', + '@for (item of items; track item) { {{ tag`a${item}b${other}` }} }', + ), + fixture( + 'tagged-template-listener', + 'Tagged template literal in a listener', + '@for (item of items; track item) { }', + ), + fixture( + 'tagged-template-arrow-parameter', + 'Tagged template literal reading an arrow function parameter', + '{{ f((q) => tag`${q}`) }}', + ), + fixture( + 'spread-argument-pipe', + 'Spread call argument whose operand uses a pipe', + '{{ f(...(items | slice: 1)) }}', + ), + fixture( + 'tagged-template-escapes', + 'Tagged template literal whose text needs escaping', + '{{ tag`a\\`b\\${c}d\\\\e ${other} $x` }}', + ), + fixture( + 'tagged-template-pipe', + 'Tagged template literal whose expression uses a pipe', + '{{ tag`a${other | json}b` }}', + ), + { + name: 'spread-and-tagged-template-host', + category: 'templates', + description: 'Spread arguments and tagged templates in host bindings', + className: 'SpreadAndTaggedHostDirective', + type: 'full-transform', + sourceCode: ` +import { Directive } from '@angular/core'; + +@Directive({ + selector: '[appSpreadAndTaggedHost]', + standalone: true, + host: { + '[attr.a]': 'f(...items)', + '[attr.b]': 'tag\`a\${other}\`', + '(click)': 'f((o) => tag\`\${o}\`, (p) => \`\${p}\`, ...items)', + }, +}) +export class SpreadAndTaggedHostDirective { + other: any = 1; + items: any[] = []; + f(...args: any[]): any {} + tag(...args: any[]): any {} +} + `.trim(), + expectedFeatures: [], + }, +]