diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index bfd019801..68d7e7c03 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -6688,6 +6688,13 @@ fn convert_host_metadata_to_input<'a>( let (effective_name, event_type, phase) = parse_legacy_animation_event(final_event_name, &allocator); + // Angular's `_parseRegularEvent` checks the name before splitting off the target. + let event_type = + if event_type == ParsedEventType::Regular && event_name.starts_with("animate.") { + ParsedEventType::Animation + } else { + event_type + }; // Parse the handler expression let value_str = allocator.alloc_str(value.as_str()); diff --git a/crates/oxc_angular_compiler/src/directive/compiler.rs b/crates/oxc_angular_compiler/src/directive/compiler.rs index bdb061d60..70cc79e19 100644 --- a/crates/oxc_angular_compiler/src/directive/compiler.rs +++ b/crates/oxc_angular_compiler/src/directive/compiler.rs @@ -706,6 +706,13 @@ fn convert_r3_host_metadata_to_input<'a>( let (effective_name, event_type, phase) = parse_legacy_animation_event(final_event_name, &allocator); + // Angular's `_parseRegularEvent` checks the name before splitting off the target. + let event_type = + if event_type == ParsedEventType::Regular && event_name.starts_with("animate.") { + ParsedEventType::Animation + } else { + event_type + }; // Parse the handler expression let value_str = allocator.alloc_str(value.as_str()); diff --git a/crates/oxc_angular_compiler/src/pipeline/ingest.rs b/crates/oxc_angular_compiler/src/pipeline/ingest.rs index 3901735f8..aa4d45afe 100644 --- a/crates/oxc_angular_compiler/src/pipeline/ingest.rs +++ b/crates/oxc_angular_compiler/src/pipeline/ingest.rs @@ -31,7 +31,8 @@ use crate::ast::r3::{ R3TextAttribute, SecurityContext, }; use crate::ir::enums::{ - BindingKind, DeferOpModifierKind, DeferTriggerKind, Namespace, TDeferDetailsFlags, TemplateKind, + AnimationKind, BindingKind, DeferOpModifierKind, DeferTriggerKind, Namespace, + TDeferDetailsFlags, TemplateKind, }; use crate::ir::expression::{ BinaryExpr, ConditionalCaseExpr, EmptyExpr, IrBinaryOperator, IrExpression, LexicalReadExpr, @@ -40,12 +41,12 @@ use crate::ir::expression::{ TwoWayBindingSetExpr, VisitorContextFlag, transform_expressions_in_expression, }; use crate::ir::ops::{ - BindingOp, ConditionalBranchCreateOp, ConditionalOp, ConditionalUpdateOp, CreateOp, - CreateOpBase, DeclareLetOp, DeferOnOp, DeferOp, DeferWhenOp, ElementEndOp, ElementStartOp, - ExtractedAttributeOp, I18nAttributesOp, I18nEndOp, I18nPlaceholder, I18nSlotHandle, - I18nStartOp, IcuEndOp, IcuStartOp, InterpolateTextOp, ListenerOp, LocalRef, ProjectionOp, - RepeaterCreateOp, RepeaterOp, RepeaterVarNames, SlotId, StatementOp, StoreLetOp, TemplateOp, - TextOp, TwoWayListenerOp, UpdateOp, UpdateOpBase, XrefId, + AnimationListenerOp, BindingOp, ConditionalBranchCreateOp, ConditionalOp, ConditionalUpdateOp, + CreateOp, CreateOpBase, DeclareLetOp, DeferOnOp, DeferOp, DeferWhenOp, ElementEndOp, + ElementStartOp, ExtractedAttributeOp, I18nAttributesOp, I18nEndOp, I18nPlaceholder, + I18nSlotHandle, I18nStartOp, IcuEndOp, IcuStartOp, InterpolateTextOp, ListenerOp, LocalRef, + ProjectionOp, RepeaterCreateOp, RepeaterOp, RepeaterVarNames, SlotId, StatementOp, StoreLetOp, + TemplateOp, TextOp, TwoWayListenerOp, UpdateOp, UpdateOpBase, XrefId, }; use crate::output::ast::{OutputExpression, ReadVarExpr}; use crate::pipeline::compilation::{AliasVariable, ContextVariable}; @@ -1893,16 +1894,29 @@ fn ingest_listener_owned<'a>( } } + if output.event_type == ParsedEventType::Animation { + let op = create_animation_listener_op( + allocator, + element_xref, + tag, + false, + output.name, + handler_ops, + handler_expr, + output.source_span, + ); + if let Some(view) = job.view_mut(view_xref) { + view.create.push(op); + } + return; + } + // Match Angular's createListenerOp: // isLegacyAnimationListener = legacyAnimationPhase !== null // The raw phase string is preserved verbatim so that bogus phases (e.g. // `@anim.foo`) still emit `ɵɵsyntheticHostListener("@anim.foo", fn)`, - // matching Angular's reference output. ParsedEventType::Animation has no - // phase and is currently flagged as an animation listener for parity with - // the prior behavior; it should be dispatched to AnimationListenerOp by a - // separate template-path fix. + // matching Angular's reference output. let (is_animation_listener, legacy_animation_phase) = match output.event_type { - ParsedEventType::Animation => (true, None), ParsedEventType::LegacyAnimation => { let phase = output.phase.clone(); (phase.is_some(), phase) @@ -1933,6 +1947,55 @@ fn ingest_listener_owned<'a>( } } +/// Creates the `AnimationListenerOp` for an `(animate.enter)` / `(animate.leave)` event. +/// +/// The last handler expression is returned, as in Angular's `makeListenerHandlerOps`. +#[allow(clippy::too_many_arguments)] +fn create_animation_listener_op<'a>( + allocator: &'a oxc_allocator::Allocator, + target: XrefId, + tag: Option>, + host_listener: bool, + name: Ident<'a>, + mut handler_ops: Vec<'a, UpdateOp<'a>>, + handler_expr: Option>>, + source_span: oxc_span::Span, +) -> CreateOp<'a> { + if let Some(expr) = handler_expr { + let return_stmt = crate::output::ast::OutputStatement::Return(Box::new_in( + crate::output::ast::ReturnStatement { + value: OutputExpression::WrappedIrNode(Box::new_in( + crate::output::ast::WrappedIrExpr { + node: expr, + source_span: Some(source_span), + }, + &allocator, + )), + source_span: Some(source_span), + }, + &allocator, + )); + handler_ops.push(UpdateOp::Statement(StatementOp { + base: UpdateOpBase::default(), + statement: return_stmt, + })); + } + let phase = + if name.as_str().ends_with("enter") { AnimationKind::Enter } else { AnimationKind::Leave }; + CreateOp::AnimationListener(AnimationListenerOp { + base: CreateOpBase { source_span: Some(source_span), ..Default::default() }, + target, + target_slot: SlotId(0), // Will be set during slot allocation + tag, + host_listener, + name, + handler_ops, + handler_fn_name: None, + phase, + consumes_dollar_event: false, // Set during resolve_dollar_event phase + }) +} + /// Checks if a template is an explicit `` (as opposed to a structural directive). /// Ported from Angular's `isPlainTemplate` in `ingest.ts`. fn is_plain_template(tag_name: &Option) -> bool { @@ -2212,6 +2275,12 @@ fn ingest_template<'a>( for output in outputs { if template_kind == TemplateKind::NgTemplate { // For explicit , create listeners at the template level. + // Angular's `ingestTemplateBindings` makes a plain listener for every output here, + // `(animate.enter)` / `(animate.leave)` included. + let mut output = output; + if output.event_type == ParsedEventType::Animation { + output.event_type = ParsedEventType::Regular; + } ingest_listener_owned(job, view_xref, xref, tag_name.clone(), output); } else { // For structural directives, create ExtractedAttributeOp for directive matching. @@ -4500,10 +4569,25 @@ fn ingest_host_event<'a>(job: &mut HostBindingCompilationJob<'a>, event: R3Bound // not silently dropped — they round-trip into the emitted instruction so the // output matches Angular byte-for-byte. // - // For host events the binding parser only produces Regular or LegacyAnimation - // (Animation is template-only), and a LegacyAnimation event with no phase (e.g. - // `@HostListener('@anim')`) leaves is_animation_listener=false so reify emits a - // plain ɵɵlistener — matching Angular's `isLegacyAnimationListener=false` path. + // A LegacyAnimation event with no phase (e.g. `@HostListener('@anim')`) leaves + // is_animation_listener=false so reify emits a plain ɵɵlistener — matching + // Angular's `isLegacyAnimationListener=false` path. + if event.event_type == ParsedEventType::Animation { + let root_xref = job.root.xref; + let op = create_animation_listener_op( + allocator, + root_xref, + None, + true, + event.name, + handler_ops, + handler_expr, + event.source_span, + ); + job.root.create.push(op); + return; + } + let (legacy_animation_phase, target) = match event.event_type { ParsedEventType::LegacyAnimation => (event.phase.clone(), None), _ => (None, event.target.clone()), diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/naming.rs b/crates/oxc_angular_compiler/src/pipeline/phases/naming.rs index d6220ee2f..b218b7b5c 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/naming.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/naming.rs @@ -864,7 +864,7 @@ fn process_single_create_op_ref<'a>( CreateOp::AnimationListener(listener) => { if listener.handler_fn_name.is_none() { let slot = listener.target_slot.0; - let animation_kind = listener.name.as_str().replace('.', ""); + let animation_kind = listener.name.as_str().replacen('.', "", 1); let fn_name_str = fn_name.map(|n| n.as_str()).unwrap_or(base_name); let handler_name = if listener.host_listener { @@ -1220,7 +1220,7 @@ pub fn name_functions_and_variables_for_host(job: &mut HostBindingCompilationJob } CreateOp::AnimationListener(listener) => { if listener.handler_fn_name.is_none() && listener.host_listener { - let animation_kind = listener.name.as_str().replace('.', ""); + let animation_kind = listener.name.as_str().replacen('.', "", 1); let handler_name = format!("{sanitized_base}_{animation_kind}_HostBindingHandler"); let sanitized = sanitize_identifier(&handler_name); 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 7994489ad..81afb1c1e 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs @@ -909,7 +909,7 @@ fn reify_create_op<'a>( )) } CreateOp::AnimationListener(listener) => { - // Emit syntheticHostListener instruction for animation listeners + // Emit ɵɵanimateEnterListener / ɵɵanimateLeaveListener let mut handler_stmts = OxcVec::new_in(&allocator); for handler_op in listener.handler_ops.iter() { if let Some(stmt) = reify_update_op( @@ -929,7 +929,6 @@ fn reify_create_op<'a>( Some(create_animation_listener_stmt( &allocator, core_namespace, - &listener.name, listener.phase, handler_stmts, listener.handler_fn_name.as_ref(), @@ -1803,7 +1802,7 @@ fn reify_host_create_op<'a>( } } CreateOp::AnimationListener(listener) => { - // Emit syntheticHostListener for animation listeners + // Emit ɵɵanimateEnterListener / ɵɵanimateLeaveListener let mut handler_stmts = OxcVec::new_in(&allocator); for handler_op in listener.handler_ops.iter() { // Host bindings use Full mode (not DomOnly) @@ -1824,13 +1823,23 @@ fn reify_host_create_op<'a>( Some(create_animation_listener_stmt( &allocator, core_namespace, - &listener.name, listener.phase, handler_stmts, listener.handler_fn_name.as_ref(), listener.consumes_dollar_event, )) } + CreateOp::AnimationString(anim) => { + // Emit ɵɵanimateEnter or ɵɵanimateLeave for a static `animate.enter` / `animate.leave` + let expr = convert_ir_expression( + allocator, + core_namespace, + &anim.expression, + expressions, + root_xref, + ); + Some(create_animation_string_stmt(allocator, core_namespace, anim.animation_kind, expr)) + } // ExtractedAttribute ops are handled separately for hostAttrs CreateOp::ExtractedAttribute(_) => None, // Other create ops are not relevant for host bindings diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/misc.rs b/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/misc.rs index 1342d285d..b69cc0be5 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/misc.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/misc.rs @@ -269,18 +269,16 @@ pub fn create_synthetic_host_listener_stmt<'a>( ) } -/// Creates an ɵɵsyntheticHostListener() call statement for animation listeners. +/// Creates an ɵɵanimateEnterListener() or ɵɵanimateLeaveListener() call statement for an +/// `(animate.enter)` / `(animate.leave)` listener. /// -/// Animation listeners have event names like "@trigger.start" or "@trigger.done". -/// The AnimationKind enum currently has Enter/Leave for direction, but animation -/// callbacks use start/done for timing. For now, we map Enter -> start, Leave -> done. +/// Ported from Angular's `animationListener` in `instruction.ts`. /// /// The `handler_fn_name` parameter sets the name of the handler function expression. /// The `consumes_dollar_event` parameter controls whether the `$event` parameter is included. pub fn create_animation_listener_stmt<'a>( allocator: &'a oxc_allocator::Allocator, core_namespace: &'a str, - name: &Ident<'a>, phase: crate::ir::enums::AnimationKind, handler_stmts: OxcVec<'a, OutputStatement<'a>>, handler_fn_name: Option<&Ident<'a>>, @@ -288,18 +286,6 @@ pub fn create_animation_listener_stmt<'a>( ) -> OutputStatement<'a> { let mut args = OxcVec::new_in(&allocator); - // Build the full event name: "@{name}.{phase}" - // AnimationKind::Enter maps to "start", AnimationKind::Leave maps to "done" - let phase_str = match phase { - crate::ir::enums::AnimationKind::Enter => "start", - crate::ir::enums::AnimationKind::Leave => "done", - }; - let full_name = allocator.alloc_str(&format!("@{}.{}", name.as_str(), phase_str)); - args.push(OutputExpression::Literal(Box::new_in( - LiteralExpr { value: LiteralValue::String(Ident::from(full_name)), source_span: None }, - &allocator, - ))); - // Handler function: function name($event) { ... } or function name() { ... } let mut params = OxcVec::new_in(&allocator); if consumes_dollar_event { @@ -317,12 +303,11 @@ pub fn create_animation_listener_stmt<'a>( )); args.push(handler_fn); - create_instruction_call_stmt( - allocator, - core_namespace, - Identifiers::SYNTHETIC_HOST_LISTENER, - args, - ) + let instruction = match phase { + crate::ir::enums::AnimationKind::Enter => Identifiers::ANIMATION_ENTER_LISTENER, + crate::ir::enums::AnimationKind::Leave => Identifiers::ANIMATION_LEAVE_LISTENER, + }; + create_instruction_call_stmt(allocator, core_namespace, instruction, args) } /// Creates an ɵɵanimateEnter() or ɵɵanimateLeave() call statement for animation string bindings. diff --git a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs index cb46cca31..0712e1f99 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -3641,10 +3641,16 @@ impl<'a> HtmlToR3Transform<'a> { let handler_span = self.calculate_event_handler_span(attr); let handler = self.parse_event_expression(&attr.value, handler_span); let name_atom = Ident::from(self.allocator.alloc_str(name)); + // `on-animate.enter` is an animation event, like `(animate.enter)` + let event_type = if name.starts_with("animate.") { + ParsedEventType::Animation + } else { + ParsedEventType::Regular + }; R3BoundEvent { name: name_atom, - event_type: ParsedEventType::Regular, + event_type, handler, target: None, phase: None, diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 9aea69118..b594d9bcf 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -3359,6 +3359,92 @@ fn test_animate_enter_and_leave_together() { ); } +#[test] +fn test_animate_enter_listener() { + let js = compile_template_to_js(r#"
"#, "TestComponent"); + assert!( + js.contains("i0.ɵɵanimateEnterListener(function TestComponent_Template_div_animateenter_0_listener($event)"), + "Expected ɵɵanimateEnterListener.\nGot:\n{js}" + ); + assert!(js.contains("return ctx.fn($event);"), "Got:\n{js}"); + assert!( + !js.contains("@animate"), + "Must not compile to a legacy animation listener.\nGot:\n{js}" + ); +} + +#[test] +fn test_animate_leave_listener_with_statements() { + let js = compile_template_to_js( + r#"
"#, + "TestComponent", + ); + assert!(js.contains(r#"i0.ɵɵanimateLeave("fade")"#), "Got:\n{js}"); + assert!( + js.contains("i0.ɵɵanimateLeaveListener(function TestComponent_Template_div_animateleave_0_listener($event)"), + "Expected ɵɵanimateLeaveListener.\nGot:\n{js}" + ); + assert!(js.contains("ctx.a = 1") && js.contains("return ctx.fn($event);"), "Got:\n{js}"); + assert!(!js.contains("@animate"), "Got:\n{js}"); +} + +#[test] +fn test_host_animate_listener_and_attribute() { + let source = r" +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-fade', + template: '', + host: { '(animate.leave)': 'done($event)', 'animate.enter': 'fade-in' }, +}) +export class FadeComponent { done(e: any) {} } +"; + let allocator = Allocator::default(); + let result = transform_angular_file(&allocator, "fade.component.ts", source, None, None); + assert!(!result.has_errors(), "{:?}", result.diagnostics); + let js = &result.code; + assert!( + js.contains("i0.ɵɵanimateLeaveListener(function FadeComponent_animateleave_HostBindingHandler($event)"), + "Expected ɵɵanimateLeaveListener in hostBindings.\nGot:\n{js}" + ); + assert!(js.contains(r#"i0.ɵɵanimateEnter("fade-in")"#), "Got:\n{js}"); + assert!(!js.contains(r#""animate.leave""#), "Must not listen to a DOM event.\nGot:\n{js}"); +} + +#[test] +fn test_animate_listener_canonical_on_prefix() { + let js = compile_template_to_js(r#"
"#, "TestComponent"); + assert!( + js.contains("i0.ɵɵanimateEnterListener(function TestComponent_Template_div_animateenter_0_listener()"), + "Expected ɵɵanimateEnterListener.\nGot:\n{js}" + ); + assert!(!js.contains(r#""animate.enter""#), "Must not listen to a DOM event.\nGot:\n{js}"); +} + +#[test] +fn test_animate_listener_name_with_extra_dot() { + // Like Angular's `name.replace('.', '')`, only the first dot is dropped. + let js = compile_template_to_js(r#"
"#, "TestComponent"); + assert!(js.contains("TestComponent_Template_div_animateenter_x_0_listener"), "Got:\n{js}"); +} + +#[test] +fn test_animate_listener_on_ng_template_is_plain_listener() { + // Angular's `ingestTemplateBindings` makes a plain listener for outputs on an . + let js = compile_template_to_js( + r#""#, + "TestComponent", + ); + assert!( + js.contains( + r#"ɵɵlistener("animate.leave",function TestComponent_Template_ng_template_animate_leave_0_listener($event)"# + ), + "Got:\n{js}" + ); + assert!(!js.contains("ɵɵanimateLeaveListener"), "Got:\n{js}"); +} + #[test] fn test_host_animation_trigger_binding() { // Component with animation trigger in host property should emit ɵɵsyntheticHostProperty diff --git a/napi/angular-compiler/e2e/compare/fixtures/animations/animate-listener.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/animations/animate-listener.fixture.ts new file mode 100644 index 000000000..a679a5f17 --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/animations/animate-listener.fixture.ts @@ -0,0 +1,127 @@ +/** + * `(animate.enter)` / `(animate.leave)` listeners, in templates and host bindings. + */ +import type { Fixture } from '../types.js' + +export const fixtures: Fixture[] = [ + { + name: 'animate-enter-listener', + category: 'animations', + description: '(animate.enter) listener on an element', + className: 'AnimateEnterListenerComponent', + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-animate-enter-listener', + template: \`
Content
\`, +}) +export class AnimateEnterListenerComponent { + onEnter(event: unknown) {} +} + `.trim(), + expectedFeatures: ['ɵɵanimateEnterListener'], + }, + { + name: 'animate-leave-listener-in-if', + category: 'animations', + description: '(animate.leave) listener next to a static animate.leave inside @if', + className: 'AnimateLeaveListenerInIfComponent', + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-animate-leave-listener-in-if', + template: \` + @if (visible) { +
Content
+ } + \`, +}) +export class AnimateLeaveListenerInIfComponent { + visible = true; + left = false; + onLeave(event: unknown) {} +} + `.trim(), + expectedFeatures: ['ɵɵanimateLeave', 'ɵɵanimateLeaveListener'], + }, + { + name: 'animate-host-listener-and-attribute', + category: 'animations', + description: 'host (animate.leave) listener and static animate.enter attribute', + className: 'AnimateHostComponent', + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-animate-host', + template: '', + host: { '(animate.leave)': 'onLeave($event)', 'animate.enter': 'fade-in' }, +}) +export class AnimateHostComponent { + onLeave(event: unknown) {} +} + `.trim(), + expectedFeatures: ['ɵɵanimateLeaveListener', 'ɵɵanimateEnter'], + }, + { + name: 'animate-host-listener-decorator', + category: 'animations', + description: '@HostListener on animate.enter in a directive', + className: 'AnimateHostListenerDirective', + type: 'full-transform', + sourceCode: ` +import { Directive, HostListener } from '@angular/core'; + +@Directive({ selector: '[appAnimateHost]' }) +export class AnimateHostListenerDirective { + @HostListener('animate.enter', ['$event']) + onEnter(event: unknown) {} +} + `.trim(), + expectedFeatures: ['ɵɵanimateEnterListener'], + }, + { + name: 'animate-listener-canonical-on-prefix', + category: 'animations', + description: 'on-animate.enter, the canonical form of (animate.enter)', + className: 'AnimateOnPrefixComponent', + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-animate-on-prefix', + template: \`
Content
\`, +}) +export class AnimateOnPrefixComponent { + onEnter(event: unknown) {} +} + `.trim(), + expectedFeatures: ['ɵɵanimateEnterListener'], + }, + { + name: 'animate-listener-on-ng-template', + category: 'animations', + description: '(animate.leave) on an ng-template stays a plain listener', + className: 'AnimateNgTemplateComponent', + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-animate-ng-template', + template: \`\`, +}) +export class AnimateNgTemplateComponent { + onLeave(event: unknown) {} + onClick() {} +} + `.trim(), + expectedFeatures: ['ɵɵdomListener'], + }, +]