Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/oxc_angular_compiler/src/directive/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::ptr::NonNull<UpdateOp<'_>>> = {
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<AnimationInfo<'_>> = Vec::new();
// Second pass: process each AnimationBindingOp (String kind only)
let mut strings_to_create: Vec<AnimationStringInfo<'_>> = Vec::new();

for ptr in binding_ptrs {
Expand All @@ -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
Expand All @@ -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() },
Expand Down
78 changes: 78 additions & 0 deletions crates/oxc_angular_compiler/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<ng-content></ng-content>',
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: \`<ng-content></ng-content>\`,
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: \`<ng-content></ng-content>\`,
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',
Expand Down Expand Up @@ -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'],
Expand Down
Loading