Skip to content

Commit ca3c458

Browse files
fix: emit syntheticHostListener for legacy animation host listeners (#256)
Legacy animation host listeners (`@HostListener('@trigger.phase')` and `host: { '(@trigger.phase)': '...' }`) were being compiled as plain `ɵɵlistener` calls, so `@trigger` events were never routed through the animation renderer. Match Angular's reference compiler by classifying these as `LegacyAnimation`, ordering them before regular listeners, and emitting `ɵɵsyntheticHostListener` with the correct `ComponentName_animation_<trigger>_<phase>_HostBindingHandler` name. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5383b03 commit ca3c458

10 files changed

Lines changed: 684 additions & 66 deletions

File tree

crates/oxc_angular_compiler/src/component/transform.rs

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3413,16 +3413,19 @@ fn convert_host_metadata_to_input<'a>(
34133413
// Check for target prefix (window:, document:, body:)
34143414
let (final_event_name, target) = parse_event_target(event_name);
34153415

3416+
let (effective_name, event_type, phase) =
3417+
parse_legacy_animation_event(final_event_name, allocator);
3418+
34163419
// Parse the handler expression
34173420
let value_str = allocator.alloc_str(value.as_str());
34183421
let parse_result = binding_parser.parse_event(value_str, empty_span);
34193422

34203423
events.push(R3BoundEvent {
3421-
name: Ident::from_in(final_event_name, allocator),
3422-
event_type: ParsedEventType::Regular,
3424+
name: Ident::from_in(effective_name, allocator),
3425+
event_type,
34233426
handler: parse_result.ast,
34243427
target: target.map(|t| Ident::from_in(t, allocator)),
3425-
phase: None,
3428+
phase,
34263429
source_span: empty_span,
34273430
handler_span: empty_span,
34283431
key_span: empty_span,
@@ -3504,6 +3507,49 @@ fn parse_host_property_name(name: &str) -> (BindingType, &str, Option<&str>) {
35043507
}
35053508
}
35063509

3510+
/// Classify a host event name as a legacy animation event.
3511+
///
3512+
/// Mirrors Angular's `parseLegacyAnimationEventName` (binding_parser.ts) +
3513+
/// `splitAtPeriod` (util.ts): `@` is stripped, the name is split on the first `.`,
3514+
/// **both halves are trimmed**, and the **phase is lowercased** via `.toLowerCase()`.
3515+
///
3516+
/// - `@trigger.phase` → (`"trigger"`, `LegacyAnimation`, `Some("phase")`)
3517+
/// - `@anim.START` → (`"anim"`, `LegacyAnimation`, `Some("start")`)
3518+
/// - `@anim. start ` → (`"anim"`, `LegacyAnimation`, `Some("start")`)
3519+
/// - `@anim.foo` → (`"anim"`, `LegacyAnimation`, `Some("foo")`) Angular reports
3520+
/// an error for invalid phases; we drop the diagnostic to match
3521+
/// this codebase's convention for host metadata (see
3522+
/// `binding_parser.parse_event` callers below — `parse_result.errors`
3523+
/// is also discarded). Code output still matches Angular byte-for-byte.
3524+
/// - `@trigger` → (`"trigger"`, `LegacyAnimation`, `None`)
3525+
/// - `click` → (`"click"`, `Regular`, `None`)
3526+
///
3527+
/// Keep in sync with the identical helper in `directive/compiler.rs`.
3528+
fn parse_legacy_animation_event<'a>(
3529+
event_name: &'a str,
3530+
allocator: &'a Allocator,
3531+
) -> (&'a str, ParsedEventType, Option<Ident<'a>>) {
3532+
use oxc_allocator::FromIn;
3533+
let Some(without_at) = event_name.strip_prefix('@') else {
3534+
return (event_name, ParsedEventType::Regular, None);
3535+
};
3536+
let (trigger_raw, phase_raw) = match without_at.find('.') {
3537+
Some(dot) => (&without_at[..dot], Some(&without_at[dot + 1..])),
3538+
None => (without_at, None),
3539+
};
3540+
let trigger_trimmed = trigger_raw.trim();
3541+
let trigger: &'a str = if trigger_trimmed.len() == trigger_raw.len() {
3542+
trigger_trimmed
3543+
} else {
3544+
allocator.alloc_str(trigger_trimmed)
3545+
};
3546+
let phase = phase_raw.map(|p| {
3547+
let normalized = p.trim().to_lowercase();
3548+
Ident::from_in(normalized.as_str(), allocator)
3549+
});
3550+
(trigger, ParsedEventType::LegacyAnimation, phase)
3551+
}
3552+
35073553
/// Parse event name to extract target (window:, document:, body:).
35083554
///
35093555
/// Examples:

crates/oxc_angular_compiler/src/directive/compiler.rs

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -671,16 +671,19 @@ fn convert_r3_host_metadata_to_input<'a>(
671671
// Check for target prefix (window:, document:, body:)
672672
let (final_event_name, target) = parse_event_target(event_name);
673673

674+
let (effective_name, event_type, phase) =
675+
parse_legacy_animation_event(final_event_name, allocator);
676+
674677
// Parse the handler expression
675678
let value_str = allocator.alloc_str(value.as_str());
676679
let parse_result = binding_parser.parse_event(value_str, empty_span);
677680

678681
events.push(R3BoundEvent {
679-
name: Ident::from_in(final_event_name, allocator),
680-
event_type: ParsedEventType::Regular,
682+
name: Ident::from_in(effective_name, allocator),
683+
event_type,
681684
handler: parse_result.ast,
682685
target: target.map(|t| Ident::from_in(t, allocator)),
683-
phase: None,
686+
phase,
684687
source_span: empty_span,
685688
handler_span: empty_span,
686689
key_span: empty_span,
@@ -752,6 +755,49 @@ fn parse_host_property_name(name: &str) -> (BindingType, &str, Option<&str>) {
752755
}
753756
}
754757

758+
/// Classify a host event name as a legacy animation event.
759+
///
760+
/// Mirrors Angular's `parseLegacyAnimationEventName` (binding_parser.ts) +
761+
/// `splitAtPeriod` (util.ts): `@` is stripped, the name is split on the first `.`,
762+
/// **both halves are trimmed**, and the **phase is lowercased** via `.toLowerCase()`.
763+
///
764+
/// - `@trigger.phase` → (`"trigger"`, `LegacyAnimation`, `Some("phase")`)
765+
/// - `@anim.START` → (`"anim"`, `LegacyAnimation`, `Some("start")`)
766+
/// - `@anim. start ` → (`"anim"`, `LegacyAnimation`, `Some("start")`)
767+
/// - `@anim.foo` → (`"anim"`, `LegacyAnimation`, `Some("foo")`) Angular reports
768+
/// an error for invalid phases; we drop the diagnostic to match
769+
/// this codebase's host-metadata convention (`parse_result.errors`
770+
/// from `binding_parser.parse_event` is also discarded). Code
771+
/// output still matches Angular byte-for-byte.
772+
/// - `@trigger` → (`"trigger"`, `LegacyAnimation`, `None`)
773+
/// - `click` → (`"click"`, `Regular`, `None`)
774+
///
775+
/// Keep in sync with the identical helper in `component/transform.rs`.
776+
fn parse_legacy_animation_event<'a>(
777+
event_name: &'a str,
778+
allocator: &'a Allocator,
779+
) -> (&'a str, ParsedEventType, Option<Ident<'a>>) {
780+
use oxc_allocator::FromIn;
781+
let Some(without_at) = event_name.strip_prefix('@') else {
782+
return (event_name, ParsedEventType::Regular, None);
783+
};
784+
let (trigger_raw, phase_raw) = match without_at.find('.') {
785+
Some(dot) => (&without_at[..dot], Some(&without_at[dot + 1..])),
786+
None => (without_at, None),
787+
};
788+
let trigger_trimmed = trigger_raw.trim();
789+
let trigger: &'a str = if trigger_trimmed.len() == trigger_raw.len() {
790+
trigger_trimmed
791+
} else {
792+
allocator.alloc_str(trigger_trimmed)
793+
};
794+
let phase = phase_raw.map(|p| {
795+
let normalized = p.trim().to_lowercase();
796+
Ident::from_in(normalized.as_str(), allocator)
797+
});
798+
(trigger, ParsedEventType::LegacyAnimation, phase)
799+
}
800+
755801
/// Parse an event name to extract target prefix (window:, document:, body:).
756802
fn parse_event_target(event_name: &str) -> (&str, Option<&str>) {
757803
if let Some(rest) = event_name.strip_prefix("window:") {

crates/oxc_angular_compiler/src/ir/enums.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,16 @@ pub enum AnimationKind {
460460
Leave,
461461
}
462462

463+
impl AnimationKind {
464+
/// Returns the phase string used in legacy animation event names ("start" or "done").
465+
pub fn legacy_phase_str(self) -> &'static str {
466+
match self {
467+
AnimationKind::Enter => "start",
468+
AnimationKind::Leave => "done",
469+
}
470+
}
471+
}
472+
463473
/// Kinds of animation bindings.
464474
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465475
pub enum AnimationBindingKind {
@@ -491,3 +501,18 @@ impl TDeferDetailsFlags {
491501
/// Has hydrate triggers.
492502
pub const HAS_HYDRATE_TRIGGERS: Self = Self(1 << 0);
493503
}
504+
505+
#[cfg(test)]
506+
mod tests {
507+
use super::AnimationKind;
508+
509+
#[test]
510+
fn animation_kind_enter_maps_to_start() {
511+
assert_eq!(AnimationKind::Enter.legacy_phase_str(), "start");
512+
}
513+
514+
#[test]
515+
fn animation_kind_leave_maps_to_done() {
516+
assert_eq!(AnimationKind::Leave.legacy_phase_str(), "done");
517+
}
518+
}

crates/oxc_angular_compiler/src/ir/ops.rs

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -964,14 +964,28 @@ pub struct ListenerOp<'a> {
964964
pub consume_fn_name: Option<Ident<'a>>,
965965
/// Whether this is an animation listener.
966966
pub is_animation_listener: bool,
967-
/// Animation phase.
968-
pub animation_phase: Option<AnimationKind>,
967+
/// Raw legacy animation phase string (e.g. `"start"`, `"done"`, or any other token
968+
/// the user wrote — Angular only validates `start`/`done` as a diagnostic but still
969+
/// emits `ɵɵsyntheticHostListener` with the user-supplied phase verbatim).
970+
/// Lowercased and trimmed by the parser to match Angular's `splitAtPeriod` +
971+
/// `.toLowerCase()` semantics.
972+
pub legacy_animation_phase: Option<Ident<'a>>,
969973
/// Event target (window, document, body).
970974
pub event_target: Option<Ident<'a>>,
971975
/// Whether this listener uses $event.
972976
pub consumes_dollar_event: bool,
973977
}
974978

979+
impl<'a> ListenerOp<'a> {
980+
/// Returns true when this is a legacy animation host listener (`@trigger.phase`).
981+
///
982+
/// Mirrors Angular's `isLegacyAnimationListener = legacyAnimationPhase !== null`
983+
/// (compiler/src/template/pipeline/ir/src/ops/create.ts).
984+
pub fn is_legacy_animation(&self) -> bool {
985+
self.legacy_animation_phase.is_some()
986+
}
987+
}
988+
975989
/// Create a pipe instance.
976990
#[derive(Debug)]
977991
pub struct PipeOp<'a> {
@@ -1989,3 +2003,63 @@ pub struct LocalRef<'a> {
19892003
/// Target directive/component.
19902004
pub target: Ident<'a>,
19912005
}
2006+
2007+
#[cfg(test)]
2008+
mod tests {
2009+
use oxc_allocator::{Allocator, Vec as AllocVec};
2010+
use oxc_str::Ident;
2011+
2012+
use super::*;
2013+
2014+
fn make_listener_op<'a>(
2015+
allocator: &'a Allocator,
2016+
legacy_animation_phase: Option<Ident<'a>>,
2017+
) -> ListenerOp<'a> {
2018+
ListenerOp {
2019+
base: CreateOpBase::default(),
2020+
target: XrefId(0),
2021+
target_slot: SlotId(0),
2022+
tag: None,
2023+
host_listener: true,
2024+
name: Ident::from(""),
2025+
handler_expression: None,
2026+
handler_ops: AllocVec::new_in(allocator),
2027+
handler_fn_name: None,
2028+
consume_fn_name: None,
2029+
is_animation_listener: legacy_animation_phase.is_some(),
2030+
legacy_animation_phase,
2031+
event_target: None,
2032+
consumes_dollar_event: false,
2033+
}
2034+
}
2035+
2036+
#[test]
2037+
fn is_legacy_animation_true_when_phase_is_done() {
2038+
let allocator = Allocator::default();
2039+
let op = make_listener_op(&allocator, Some(Ident::from("done")));
2040+
assert!(op.is_legacy_animation());
2041+
}
2042+
2043+
#[test]
2044+
fn is_legacy_animation_true_when_phase_is_start() {
2045+
let allocator = Allocator::default();
2046+
let op = make_listener_op(&allocator, Some(Ident::from("start")));
2047+
assert!(op.is_legacy_animation());
2048+
}
2049+
2050+
#[test]
2051+
fn is_legacy_animation_true_when_phase_is_bogus() {
2052+
// Mirrors Angular: a non-start/done phase still produces a legacy animation
2053+
// listener (an error is reported separately, but isLegacyAnimationListener stays true).
2054+
let allocator = Allocator::default();
2055+
let op = make_listener_op(&allocator, Some(Ident::from("foo")));
2056+
assert!(op.is_legacy_animation());
2057+
}
2058+
2059+
#[test]
2060+
fn is_legacy_animation_false_when_no_phase() {
2061+
let allocator = Allocator::default();
2062+
let op = make_listener_op(&allocator, None);
2063+
assert!(!op.is_legacy_animation());
2064+
}
2065+
}

crates/oxc_angular_compiler/src/pipeline/ingest.rs

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ use crate::ast::r3::{
3131
SecurityContext,
3232
};
3333
use crate::ir::enums::{
34-
AnimationKind, BindingKind, DeferOpModifierKind, DeferTriggerKind, Namespace, TemplateKind,
34+
BindingKind, DeferOpModifierKind, DeferTriggerKind, Namespace, TemplateKind,
3535
};
3636
use crate::ir::expression::{
3737
BinaryExpr, ConditionalCaseExpr, EmptyExpr, IrBinaryOperator, IrExpression, LexicalReadExpr,
@@ -1775,18 +1775,19 @@ fn ingest_listener_owned<'a>(
17751775
}
17761776
}
17771777

1778-
// Determine if this is an animation listener and extract animation phase
1779-
let (is_animation_listener, animation_phase) = match output.event_type {
1778+
// Match Angular's createListenerOp:
1779+
// isLegacyAnimationListener = legacyAnimationPhase !== null
1780+
// The raw phase string is preserved verbatim so that bogus phases (e.g.
1781+
// `@anim.foo`) still emit `ɵɵsyntheticHostListener("@anim.foo", fn)`,
1782+
// matching Angular's reference output. ParsedEventType::Animation has no
1783+
// phase and is currently flagged as an animation listener for parity with
1784+
// the prior behavior; it should be dispatched to AnimationListenerOp by a
1785+
// separate template-path fix.
1786+
let (is_animation_listener, legacy_animation_phase) = match output.event_type {
17801787
ParsedEventType::Animation => (true, None),
17811788
ParsedEventType::LegacyAnimation => {
1782-
// For legacy animations, parse the phase from the output
1783-
// Phase can be "start" or "done"
1784-
let phase = output.phase.as_ref().and_then(|p| match p.as_str() {
1785-
"start" => Some(AnimationKind::Enter),
1786-
"done" => Some(AnimationKind::Leave),
1787-
_ => None,
1788-
});
1789-
(true, phase)
1789+
let phase = output.phase.clone();
1790+
(phase.is_some(), phase)
17901791
}
17911792
_ => (false, None),
17921793
};
@@ -1803,7 +1804,7 @@ fn ingest_listener_owned<'a>(
18031804
handler_fn_name: None,
18041805
consume_fn_name: None,
18051806
is_animation_listener,
1806-
animation_phase,
1807+
legacy_animation_phase,
18071808
event_target: output.target,
18081809
consumes_dollar_event: false, // Set during resolve_dollar_event phase
18091810
})
@@ -4147,7 +4148,6 @@ fn ingest_host_attribute<'a>(
41474148
/// statements to ExpressionStatement ops and the last statement to the return.
41484149
fn ingest_host_event<'a>(job: &mut HostBindingCompilationJob<'a>, event: R3BoundEvent<'a>) {
41494150
use crate::ast::expression::ParsedEventType;
4150-
use crate::ir::enums::AnimationKind;
41514151

41524152
let allocator = job.allocator;
41534153

@@ -4200,22 +4200,24 @@ fn ingest_host_event<'a>(job: &mut HostBindingCompilationJob<'a>, event: R3Bound
42004200
}
42014201
}
42024202

4203-
// Determine event target and animation phase based on event type
4204-
let (animation_phase, target) = match event.event_type {
4205-
ParsedEventType::LegacyAnimation => {
4206-
// Convert phase string to AnimationKind
4207-
let phase = event.phase.as_ref().and_then(|p| match p.as_str() {
4208-
"start" => Some(AnimationKind::Enter),
4209-
"done" => Some(AnimationKind::Leave),
4210-
_ => None,
4211-
});
4212-
(phase, None)
4213-
}
4203+
// Match Angular's createListenerOp (compiler/src/template/pipeline/ir/src/ops/create.ts):
4204+
// isLegacyAnimationListener = legacyAnimationPhase !== null
4205+
//
4206+
// The raw phase string is preserved verbatim. Angular's binding parser already
4207+
// lowercased + trimmed it via `splitAtPeriod` + `.toLowerCase()`, so `@anim.START`
4208+
// arrives here as `start`. Phases other than `start`/`done` (e.g. `@anim.foo`) are
4209+
// not silently dropped — they round-trip into the emitted instruction so the
4210+
// output matches Angular byte-for-byte.
4211+
//
4212+
// For host events the binding parser only produces Regular or LegacyAnimation
4213+
// (Animation is template-only), and a LegacyAnimation event with no phase (e.g.
4214+
// `@HostListener('@anim')`) leaves is_animation_listener=false so reify emits a
4215+
// plain ɵɵlistener — matching Angular's `isLegacyAnimationListener=false` path.
4216+
let (legacy_animation_phase, target) = match event.event_type {
4217+
ParsedEventType::LegacyAnimation => (event.phase.clone(), None),
42144218
_ => (None, event.target.clone()),
42154219
};
4216-
4217-
// Check if this is an animation event
4218-
let is_animation = matches!(event.event_type, ParsedEventType::Animation);
4220+
let is_animation = legacy_animation_phase.is_some();
42194221

42204222
let op = CreateOp::Listener(ListenerOp {
42214223
base: CreateOpBase { source_span: Some(event.source_span), ..Default::default() },
@@ -4229,7 +4231,7 @@ fn ingest_host_event<'a>(job: &mut HostBindingCompilationJob<'a>, event: R3Bound
42294231
handler_fn_name: None,
42304232
consume_fn_name: None,
42314233
is_animation_listener: is_animation,
4232-
animation_phase,
4234+
legacy_animation_phase,
42334235
event_target: target,
42344236
consumes_dollar_event: false, // Set during resolve_dollar_event phase
42354237
});

0 commit comments

Comments
 (0)