diff --git a/crates/oxc_angular_compiler/src/directive/compiler.rs b/crates/oxc_angular_compiler/src/directive/compiler.rs index c88b5ed8e..cc73373d8 100644 --- a/crates/oxc_angular_compiler/src/directive/compiler.rs +++ b/crates/oxc_angular_compiler/src/directive/compiler.rs @@ -743,6 +743,9 @@ fn parse_host_property_name(name: &str) -> (BindingType, &str, Option<&str>) { } } else if let Some(rest) = name.strip_prefix("attr.") { (BindingType::Attribute, rest, None) + } else if name.starts_with('@') { + // Animation binding like @triggerName + (BindingType::Animation, name, None) } else { (BindingType::Property, name, None) } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/convert_animations.rs b/crates/oxc_angular_compiler/src/pipeline/phases/convert_animations.rs index a89942192..0b1e0bc73 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/convert_animations.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/convert_animations.rs @@ -273,19 +273,23 @@ fn create_placeholder_expression<'a>( pub fn convert_animations_for_host(job: &mut HostBindingCompilationJob<'_>) { let allocator = job.allocator; - // First pass: collect all AnimationBindingOp pointers + // First pass: collect all AnimationBindingOp pointers that need conversion. + // Skip AnimationBindingKind::Value ops — these are [@trigger] host bindings that + // should remain in the update list and be reified as ɵɵsyntheticHostProperty. + // Only AnimationBindingKind::String ops (animate.enter/animate.leave) are converted. let binding_ptrs: Vec>> = { let mut ptrs = Vec::new(); for op in job.root.update.iter() { - if matches!(op, UpdateOp::AnimationBinding(_)) { - ptrs.push(std::ptr::NonNull::from(op)); + if let UpdateOp::AnimationBinding(binding) = op { + if matches!(binding.kind, AnimationBindingKind::String) { + ptrs.push(std::ptr::NonNull::from(op)); + } } } ptrs }; - // Second pass: process each AnimationBindingOp - let mut animations_to_create: Vec> = Vec::new(); + // Second pass: process each AnimationBindingOp (String kind only) let mut strings_to_create: Vec> = Vec::new(); for ptr in binding_ptrs { @@ -295,7 +299,6 @@ pub fn convert_animations_for_host(job: &mut HostBindingCompilationJob<'_>) { let target = binding.target; let source_span = binding.base.source_span; let name = binding.name.clone(); - let kind = binding.kind; let animation_kind = get_animation_kind(name.as_str()); // Extract expression by replacing with placeholder @@ -316,63 +319,17 @@ pub fn convert_animations_for_host(job: &mut HostBindingCompilationJob<'_>) { // SAFETY: ptr was obtained from this list unsafe { job.root.update.remove(ptr) }; - match kind { - AnimationBindingKind::String => { - strings_to_create.push(AnimationStringInfo { - target, - name, - animation_kind, - expression, - source_span, - }); - } - AnimationBindingKind::Value => { - // Create handler_ops with a return statement containing the expression - let mut handler_ops = OxcVec::new_in(allocator); - - let wrapped_expr = OutputExpression::WrappedIrNode(Box::new_in( - WrappedIrExpr { node: expression, source_span }, - allocator, - )); - - let return_stmt = OutputStatement::Return(Box::new_in( - ReturnStatement { value: wrapped_expr, source_span: None }, - allocator, - )); - - handler_ops.push(UpdateOp::Statement(StatementOp { - base: UpdateOpBase { source_span, ..Default::default() }, - statement: return_stmt, - })); - - animations_to_create.push(AnimationInfo { - target, - name, - animation_kind, - handler_ops, - source_span, - }); - } - } + strings_to_create.push(AnimationStringInfo { + target, + name, + animation_kind, + expression, + source_span, + }); } } - // Third pass: add Animation CreateOps to create list - // Host bindings don't have element targets, so we just push to the create list - for info in animations_to_create { - job.root.create.push(CreateOp::Animation(AnimationOp { - base: CreateOpBase { source_span: info.source_span, ..Default::default() }, - target: info.target, - name: info.name, - animation_kind: info.animation_kind, - handler_ops: info.handler_ops, - handler_fn_name: None, - i18n_message: None, - security_context: SecurityContext::None, - sanitizer: None, - })); - } - + // Third pass: add AnimationString CreateOps to create list for info in strings_to_create { job.root.create.push(CreateOp::AnimationString(AnimationStringOp { base: CreateOpBase { source_span: info.source_span, ..Default::default() }, diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 79960280c..1769ca26b 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -2299,6 +2299,84 @@ fn test_animate_enter_and_leave_together() { ); } +#[test] +fn test_host_animation_trigger_binding() { + // Component with animation trigger in host property should emit ɵɵsyntheticHostProperty + let source = r#" +import { Component } from '@angular/core'; +import { trigger, transition, style, animate } from '@angular/animations'; + +@Component({ + selector: 'app-slide', + template: '', + animations: [trigger('slideIn', [transition(':enter', [style({ width: 0 }), animate('200ms')])])], + host: { + '[@slideIn]': 'animationState', + } +}) +export class SlideComponent { + animationState = 'active'; +} +"#; + let allocator = Allocator::default(); + let result = transform_angular_file(&allocator, "slide.component.ts", source, None, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + let code = &result.code; + + // Should have ɵɵsyntheticHostProperty in the hostBindings update block + assert!( + code.contains("syntheticHostProperty"), + "Expected ɵɵsyntheticHostProperty for host animation trigger.\nGot:\n{code}" + ); + assert!( + code.contains(r#"syntheticHostProperty("@slideIn""#), + "Expected syntheticHostProperty with @slideIn name.\nGot:\n{code}" + ); + + // Should NOT have ɵɵanimateEnter/ɵɵanimateLeave for [@trigger] bindings + assert!( + !code.contains("animateEnter") && !code.contains("animateLeave"), + "Host [@trigger] bindings should not use animateEnter/animateLeave.\nGot:\n{code}" + ); +} + +#[test] +fn test_directive_host_animation_trigger_binding() { + // Directive with animation trigger in host property should emit ɵɵsyntheticHostProperty + let source = r#" +import { Directive } from '@angular/core'; +import { trigger, transition, style, animate } from '@angular/animations'; + +@Directive({ + selector: '[appSlide]', + host: { + '[@slideIn]': 'animationState', + } +}) +export class SlideDirective { + animationState = 'active'; +} +"#; + let allocator = Allocator::default(); + let result = transform_angular_file(&allocator, "slide.directive.ts", source, None, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + let code = &result.code; + + // Should have ɵɵsyntheticHostProperty in the hostBindings update block + assert!( + code.contains(r#"syntheticHostProperty("@slideIn""#), + "Expected syntheticHostProperty with @slideIn name for directive.\nGot:\n{code}" + ); + + // Should NOT use regular hostProperty for animation triggers + assert!( + !code.contains(r#"hostProperty("@slideIn""#), + "Should not use hostProperty for animation triggers.\nGot:\n{code}" + ); +} + /// Test that multiple components with host bindings in the same file have unique constant names. /// /// This test simulates the real-world scenario from Material Angular's fab.ts where diff --git a/napi/angular-compiler/e2e/compare/fixtures/animations/animation-host.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/animations/animation-host.fixture.ts index 1fa65664f..32b8fe620 100644 --- a/napi/angular-compiler/e2e/compare/fixtures/animations/animation-host.fixture.ts +++ b/napi/angular-compiler/e2e/compare/fixtures/animations/animation-host.fixture.ts @@ -4,6 +4,74 @@ import type { Fixture } from '../types.js' export const fixtures: Fixture[] = [ + { + name: 'animation-host-property-trigger', + category: 'animations', + description: 'Animation trigger binding in host property', + className: 'AnimationHostTriggerComponent', + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; +import { trigger, transition, style, animate } from '@angular/animations'; + +@Component({ + selector: 'app-animation-host-trigger', + standalone: true, + template: \`\`, + animations: [ + trigger('slideIn', [ + transition(':enter', [ + style({ width: 0, opacity: 0 }), + animate('200ms ease-out', style({ width: '*', opacity: 1 })), + ]), + transition(':leave', [ + animate('200ms ease-in', style({ width: 0, opacity: 0 })), + ]), + ]), + ], + host: { + '[@slideIn]': 'animationState', + } +}) +export class AnimationHostTriggerComponent { + animationState = 'active'; +} + `.trim(), + expectedFeatures: ['ɵɵsyntheticHostProperty'], + }, + { + name: 'animation-host-property-trigger-with-style', + category: 'animations', + description: 'Animation trigger binding combined with style binding in host property', + className: 'AnimationHostTriggerWithStyleComponent', + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; +import { trigger, transition, style, animate } from '@angular/animations'; + +@Component({ + selector: 'app-animation-host-trigger-with-style', + standalone: true, + template: \`\`, + animations: [ + trigger('slideIn', [ + transition(':enter', [ + style({ opacity: 0 }), + animate('200ms ease-out', style({ opacity: 1 })), + ]), + ]), + ], + host: { + '[@slideIn]': 'animationState', + '[style.overflow]': '"hidden"', + } +}) +export class AnimationHostTriggerWithStyleComponent { + animationState = 'active'; +} + `.trim(), + expectedFeatures: ['ɵɵsyntheticHostProperty', 'ɵɵstyleProp'], + }, { name: 'animation-on-component', category: 'animations', @@ -67,6 +135,28 @@ import { Component } from '@angular/core'; }) export class AnimationParamsComponent { state = 'initial'; +} + `.trim(), + expectedFeatures: ['ɵɵsyntheticHostProperty'], + }, + { + name: 'animation-directive-host-property-trigger', + category: 'animations', + description: 'Animation trigger binding in directive host property', + className: 'SlideDirective', + type: 'full-transform', + sourceCode: ` +import { Directive } from '@angular/core'; +import { trigger, transition, style, animate } from '@angular/animations'; + +@Directive({ + selector: '[appSlide]', + host: { + '[@slideIn]': 'animationState', + } +}) +export class SlideDirective { + animationState = 'active'; } `.trim(), expectedFeatures: ['ɵɵsyntheticHostProperty'],