diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index d42778678..745fbd526 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -3413,16 +3413,19 @@ fn convert_host_metadata_to_input<'a>( // Check for target prefix (window:, document:, body:) let (final_event_name, target) = parse_event_target(event_name); + let (effective_name, event_type, phase) = + parse_legacy_animation_event(final_event_name, allocator); + // Parse the handler expression let value_str = allocator.alloc_str(value.as_str()); let parse_result = binding_parser.parse_event(value_str, empty_span); events.push(R3BoundEvent { - name: Ident::from_in(final_event_name, allocator), - event_type: ParsedEventType::Regular, + name: Ident::from_in(effective_name, allocator), + event_type, handler: parse_result.ast, target: target.map(|t| Ident::from_in(t, allocator)), - phase: None, + phase, source_span: empty_span, handler_span: empty_span, key_span: empty_span, @@ -3504,6 +3507,49 @@ fn parse_host_property_name(name: &str) -> (BindingType, &str, Option<&str>) { } } +/// Classify a host event name as a legacy animation event. +/// +/// Mirrors Angular's `parseLegacyAnimationEventName` (binding_parser.ts) + +/// `splitAtPeriod` (util.ts): `@` is stripped, the name is split on the first `.`, +/// **both halves are trimmed**, and the **phase is lowercased** via `.toLowerCase()`. +/// +/// - `@trigger.phase` → (`"trigger"`, `LegacyAnimation`, `Some("phase")`) +/// - `@anim.START` → (`"anim"`, `LegacyAnimation`, `Some("start")`) +/// - `@anim. start ` → (`"anim"`, `LegacyAnimation`, `Some("start")`) +/// - `@anim.foo` → (`"anim"`, `LegacyAnimation`, `Some("foo")`) Angular reports +/// an error for invalid phases; we drop the diagnostic to match +/// this codebase's convention for host metadata (see +/// `binding_parser.parse_event` callers below — `parse_result.errors` +/// is also discarded). Code output still matches Angular byte-for-byte. +/// - `@trigger` → (`"trigger"`, `LegacyAnimation`, `None`) +/// - `click` → (`"click"`, `Regular`, `None`) +/// +/// Keep in sync with the identical helper in `directive/compiler.rs`. +fn parse_legacy_animation_event<'a>( + event_name: &'a str, + allocator: &'a Allocator, +) -> (&'a str, ParsedEventType, Option>) { + use oxc_allocator::FromIn; + let Some(without_at) = event_name.strip_prefix('@') else { + return (event_name, ParsedEventType::Regular, None); + }; + let (trigger_raw, phase_raw) = match without_at.find('.') { + Some(dot) => (&without_at[..dot], Some(&without_at[dot + 1..])), + None => (without_at, None), + }; + let trigger_trimmed = trigger_raw.trim(); + let trigger: &'a str = if trigger_trimmed.len() == trigger_raw.len() { + trigger_trimmed + } else { + allocator.alloc_str(trigger_trimmed) + }; + let phase = phase_raw.map(|p| { + let normalized = p.trim().to_lowercase(); + Ident::from_in(normalized.as_str(), allocator) + }); + (trigger, ParsedEventType::LegacyAnimation, phase) +} + /// Parse event name to extract target (window:, document:, body:). /// /// Examples: diff --git a/crates/oxc_angular_compiler/src/directive/compiler.rs b/crates/oxc_angular_compiler/src/directive/compiler.rs index 0ca47b1ac..6e9957750 100644 --- a/crates/oxc_angular_compiler/src/directive/compiler.rs +++ b/crates/oxc_angular_compiler/src/directive/compiler.rs @@ -671,16 +671,19 @@ fn convert_r3_host_metadata_to_input<'a>( // Check for target prefix (window:, document:, body:) let (final_event_name, target) = parse_event_target(event_name); + let (effective_name, event_type, phase) = + parse_legacy_animation_event(final_event_name, allocator); + // Parse the handler expression let value_str = allocator.alloc_str(value.as_str()); let parse_result = binding_parser.parse_event(value_str, empty_span); events.push(R3BoundEvent { - name: Ident::from_in(final_event_name, allocator), - event_type: ParsedEventType::Regular, + name: Ident::from_in(effective_name, allocator), + event_type, handler: parse_result.ast, target: target.map(|t| Ident::from_in(t, allocator)), - phase: None, + phase, source_span: empty_span, handler_span: empty_span, key_span: empty_span, @@ -752,6 +755,49 @@ fn parse_host_property_name(name: &str) -> (BindingType, &str, Option<&str>) { } } +/// Classify a host event name as a legacy animation event. +/// +/// Mirrors Angular's `parseLegacyAnimationEventName` (binding_parser.ts) + +/// `splitAtPeriod` (util.ts): `@` is stripped, the name is split on the first `.`, +/// **both halves are trimmed**, and the **phase is lowercased** via `.toLowerCase()`. +/// +/// - `@trigger.phase` → (`"trigger"`, `LegacyAnimation`, `Some("phase")`) +/// - `@anim.START` → (`"anim"`, `LegacyAnimation`, `Some("start")`) +/// - `@anim. start ` → (`"anim"`, `LegacyAnimation`, `Some("start")`) +/// - `@anim.foo` → (`"anim"`, `LegacyAnimation`, `Some("foo")`) Angular reports +/// an error for invalid phases; we drop the diagnostic to match +/// this codebase's host-metadata convention (`parse_result.errors` +/// from `binding_parser.parse_event` is also discarded). Code +/// output still matches Angular byte-for-byte. +/// - `@trigger` → (`"trigger"`, `LegacyAnimation`, `None`) +/// - `click` → (`"click"`, `Regular`, `None`) +/// +/// Keep in sync with the identical helper in `component/transform.rs`. +fn parse_legacy_animation_event<'a>( + event_name: &'a str, + allocator: &'a Allocator, +) -> (&'a str, ParsedEventType, Option>) { + use oxc_allocator::FromIn; + let Some(without_at) = event_name.strip_prefix('@') else { + return (event_name, ParsedEventType::Regular, None); + }; + let (trigger_raw, phase_raw) = match without_at.find('.') { + Some(dot) => (&without_at[..dot], Some(&without_at[dot + 1..])), + None => (without_at, None), + }; + let trigger_trimmed = trigger_raw.trim(); + let trigger: &'a str = if trigger_trimmed.len() == trigger_raw.len() { + trigger_trimmed + } else { + allocator.alloc_str(trigger_trimmed) + }; + let phase = phase_raw.map(|p| { + let normalized = p.trim().to_lowercase(); + Ident::from_in(normalized.as_str(), allocator) + }); + (trigger, ParsedEventType::LegacyAnimation, phase) +} + /// Parse an event name to extract target prefix (window:, document:, body:). fn parse_event_target(event_name: &str) -> (&str, Option<&str>) { if let Some(rest) = event_name.strip_prefix("window:") { diff --git a/crates/oxc_angular_compiler/src/ir/enums.rs b/crates/oxc_angular_compiler/src/ir/enums.rs index 6f10bb5f4..d32afc795 100644 --- a/crates/oxc_angular_compiler/src/ir/enums.rs +++ b/crates/oxc_angular_compiler/src/ir/enums.rs @@ -460,6 +460,16 @@ pub enum AnimationKind { Leave, } +impl AnimationKind { + /// Returns the phase string used in legacy animation event names ("start" or "done"). + pub fn legacy_phase_str(self) -> &'static str { + match self { + AnimationKind::Enter => "start", + AnimationKind::Leave => "done", + } + } +} + /// Kinds of animation bindings. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AnimationBindingKind { @@ -491,3 +501,18 @@ impl TDeferDetailsFlags { /// Has hydrate triggers. pub const HAS_HYDRATE_TRIGGERS: Self = Self(1 << 0); } + +#[cfg(test)] +mod tests { + use super::AnimationKind; + + #[test] + fn animation_kind_enter_maps_to_start() { + assert_eq!(AnimationKind::Enter.legacy_phase_str(), "start"); + } + + #[test] + fn animation_kind_leave_maps_to_done() { + assert_eq!(AnimationKind::Leave.legacy_phase_str(), "done"); + } +} diff --git a/crates/oxc_angular_compiler/src/ir/ops.rs b/crates/oxc_angular_compiler/src/ir/ops.rs index fd5373382..94359640e 100644 --- a/crates/oxc_angular_compiler/src/ir/ops.rs +++ b/crates/oxc_angular_compiler/src/ir/ops.rs @@ -964,14 +964,28 @@ pub struct ListenerOp<'a> { pub consume_fn_name: Option>, /// Whether this is an animation listener. pub is_animation_listener: bool, - /// Animation phase. - pub animation_phase: Option, + /// Raw legacy animation phase string (e.g. `"start"`, `"done"`, or any other token + /// the user wrote — Angular only validates `start`/`done` as a diagnostic but still + /// emits `ɵɵsyntheticHostListener` with the user-supplied phase verbatim). + /// Lowercased and trimmed by the parser to match Angular's `splitAtPeriod` + + /// `.toLowerCase()` semantics. + pub legacy_animation_phase: Option>, /// Event target (window, document, body). pub event_target: Option>, /// Whether this listener uses $event. pub consumes_dollar_event: bool, } +impl<'a> ListenerOp<'a> { + /// Returns true when this is a legacy animation host listener (`@trigger.phase`). + /// + /// Mirrors Angular's `isLegacyAnimationListener = legacyAnimationPhase !== null` + /// (compiler/src/template/pipeline/ir/src/ops/create.ts). + pub fn is_legacy_animation(&self) -> bool { + self.legacy_animation_phase.is_some() + } +} + /// Create a pipe instance. #[derive(Debug)] pub struct PipeOp<'a> { @@ -1989,3 +2003,63 @@ pub struct LocalRef<'a> { /// Target directive/component. pub target: Ident<'a>, } + +#[cfg(test)] +mod tests { + use oxc_allocator::{Allocator, Vec as AllocVec}; + use oxc_str::Ident; + + use super::*; + + fn make_listener_op<'a>( + allocator: &'a Allocator, + legacy_animation_phase: Option>, + ) -> ListenerOp<'a> { + ListenerOp { + base: CreateOpBase::default(), + target: XrefId(0), + target_slot: SlotId(0), + tag: None, + host_listener: true, + name: Ident::from(""), + handler_expression: None, + handler_ops: AllocVec::new_in(allocator), + handler_fn_name: None, + consume_fn_name: None, + is_animation_listener: legacy_animation_phase.is_some(), + legacy_animation_phase, + event_target: None, + consumes_dollar_event: false, + } + } + + #[test] + fn is_legacy_animation_true_when_phase_is_done() { + let allocator = Allocator::default(); + let op = make_listener_op(&allocator, Some(Ident::from("done"))); + assert!(op.is_legacy_animation()); + } + + #[test] + fn is_legacy_animation_true_when_phase_is_start() { + let allocator = Allocator::default(); + let op = make_listener_op(&allocator, Some(Ident::from("start"))); + assert!(op.is_legacy_animation()); + } + + #[test] + fn is_legacy_animation_true_when_phase_is_bogus() { + // Mirrors Angular: a non-start/done phase still produces a legacy animation + // listener (an error is reported separately, but isLegacyAnimationListener stays true). + let allocator = Allocator::default(); + let op = make_listener_op(&allocator, Some(Ident::from("foo"))); + assert!(op.is_legacy_animation()); + } + + #[test] + fn is_legacy_animation_false_when_no_phase() { + let allocator = Allocator::default(); + let op = make_listener_op(&allocator, None); + assert!(!op.is_legacy_animation()); + } +} diff --git a/crates/oxc_angular_compiler/src/pipeline/ingest.rs b/crates/oxc_angular_compiler/src/pipeline/ingest.rs index a6a318972..551de83d3 100644 --- a/crates/oxc_angular_compiler/src/pipeline/ingest.rs +++ b/crates/oxc_angular_compiler/src/pipeline/ingest.rs @@ -31,7 +31,7 @@ use crate::ast::r3::{ SecurityContext, }; use crate::ir::enums::{ - AnimationKind, BindingKind, DeferOpModifierKind, DeferTriggerKind, Namespace, TemplateKind, + BindingKind, DeferOpModifierKind, DeferTriggerKind, Namespace, TemplateKind, }; use crate::ir::expression::{ BinaryExpr, ConditionalCaseExpr, EmptyExpr, IrBinaryOperator, IrExpression, LexicalReadExpr, @@ -1775,18 +1775,19 @@ fn ingest_listener_owned<'a>( } } - // Determine if this is an animation listener and extract animation phase - let (is_animation_listener, animation_phase) = match output.event_type { + // 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. + let (is_animation_listener, legacy_animation_phase) = match output.event_type { ParsedEventType::Animation => (true, None), ParsedEventType::LegacyAnimation => { - // For legacy animations, parse the phase from the output - // Phase can be "start" or "done" - let phase = output.phase.as_ref().and_then(|p| match p.as_str() { - "start" => Some(AnimationKind::Enter), - "done" => Some(AnimationKind::Leave), - _ => None, - }); - (true, phase) + let phase = output.phase.clone(); + (phase.is_some(), phase) } _ => (false, None), }; @@ -1803,7 +1804,7 @@ fn ingest_listener_owned<'a>( handler_fn_name: None, consume_fn_name: None, is_animation_listener, - animation_phase, + legacy_animation_phase, event_target: output.target, consumes_dollar_event: false, // Set during resolve_dollar_event phase }) @@ -4147,7 +4148,6 @@ fn ingest_host_attribute<'a>( /// statements to ExpressionStatement ops and the last statement to the return. fn ingest_host_event<'a>(job: &mut HostBindingCompilationJob<'a>, event: R3BoundEvent<'a>) { use crate::ast::expression::ParsedEventType; - use crate::ir::enums::AnimationKind; let allocator = job.allocator; @@ -4200,22 +4200,24 @@ fn ingest_host_event<'a>(job: &mut HostBindingCompilationJob<'a>, event: R3Bound } } - // Determine event target and animation phase based on event type - let (animation_phase, target) = match event.event_type { - ParsedEventType::LegacyAnimation => { - // Convert phase string to AnimationKind - let phase = event.phase.as_ref().and_then(|p| match p.as_str() { - "start" => Some(AnimationKind::Enter), - "done" => Some(AnimationKind::Leave), - _ => None, - }); - (phase, None) - } + // Match Angular's createListenerOp (compiler/src/template/pipeline/ir/src/ops/create.ts): + // isLegacyAnimationListener = legacyAnimationPhase !== null + // + // The raw phase string is preserved verbatim. Angular's binding parser already + // lowercased + trimmed it via `splitAtPeriod` + `.toLowerCase()`, so `@anim.START` + // arrives here as `start`. Phases other than `start`/`done` (e.g. `@anim.foo`) are + // 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. + let (legacy_animation_phase, target) = match event.event_type { + ParsedEventType::LegacyAnimation => (event.phase.clone(), None), _ => (None, event.target.clone()), }; - - // Check if this is an animation event - let is_animation = matches!(event.event_type, ParsedEventType::Animation); + let is_animation = legacy_animation_phase.is_some(); let op = CreateOp::Listener(ListenerOp { 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 handler_fn_name: None, consume_fn_name: None, is_animation_listener: is_animation, - animation_phase, + legacy_animation_phase, event_target: target, consumes_dollar_event: false, // Set during resolve_dollar_event phase }); diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/naming.rs b/crates/oxc_angular_compiler/src/pipeline/phases/naming.rs index 53fb9c47e..f8ce41094 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/naming.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/naming.rs @@ -24,9 +24,13 @@ use crate::pipeline::compilation::{ComponentCompilationJob, HostBindingCompilati use crate::pipeline::phases::parse_extracted_styles::hyphenate; /// Sanitizes an identifier by replacing non-word characters with underscores. -/// Matches Angular's `sanitizeIdentifier` in parse_util.ts. +/// +/// Matches Angular's `sanitizeIdentifier` (parse_util.ts) which uses the JavaScript +/// regex `/\W/g`. JS `\W` is ASCII-only — equivalent to `[^A-Za-z0-9_]`. So we use +/// `is_ascii_alphanumeric()` and not Rust's `is_alphanumeric()` (which accepts +/// Unicode letters/digits and would diverge for non-ASCII trigger names). fn sanitize_identifier(name: &str) -> String { - name.chars().map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' }).collect() + name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' }).collect() } /// State for generating unique variable names. @@ -795,14 +799,14 @@ fn process_single_create_op_ref<'a>( // After sanitize: fnName_tag_animation_openClose_start_slot_listener // (the @ becomes _ via sanitize, giving animation_openClose not animation__openClose) let (event_name, animation_prefix) = if listener.is_animation_listener { - // Build the animation event name with @ prefix and phase suffix + // Build the animation event name with @ prefix and phase suffix. + // Use the raw legacy phase string (already trimmed + lowercased by the + // parser) so that bogus phases like `@anim.foo` round-trip into the + // emitted `@anim.foo` literal — matching Angular's reference output. let phase_str = listener - .animation_phase + .legacy_animation_phase .as_ref() - .map(|p| match p { - crate::ir::enums::AnimationKind::Enter => "start", - crate::ir::enums::AnimationKind::Leave => "done", - }) + .map(|p| p.as_str()) .unwrap_or("start"); let new_name = format!("@{}.{}", listener.name.as_str(), phase_str); let name_str = allocator.alloc_str(&new_name); @@ -1157,10 +1161,29 @@ pub fn name_functions_and_variables_for_host(job: &mut HostBindingCompilationJob match op { CreateOp::Listener(listener) => { if listener.handler_fn_name.is_none() && listener.host_listener { - // For host listeners, format is: ComponentName_eventName_HostBindingHandler - // Event name needs dots replaced with underscores (e.g., keydown.enter -> keydown_enter) - let event_name = listener.name.as_str().replace('.', "_"); - let handler_name = format!("{sanitized_base}_{event_name}_HostBindingHandler"); + // LegacyAnimation host listeners: build full "@trigger.phase" name and + // prefix handler with "animation" to match TypeScript's naming: + // handlerFnName = `${componentName}_animation${op.name}_HostBindingHandler` + // (after sanitize: @ and . become _) + let (event_name, animation_prefix) = if listener.is_legacy_animation() { + // Use the raw phase string (already trimmed + lowercased by the + // parser) so bogus phases round-trip into the emitted literal — + // matching Angular's reference output for invalid input. + let phase_str = listener + .legacy_animation_phase + .as_ref() + .map(|p| p.as_str()) + .unwrap_or("start"); + let full_name = format!("@{}.{}", listener.name.as_str(), phase_str); + let name_str = allocator.alloc_str(&full_name); + listener.name = Ident::from(name_str); + (full_name, "animation") + } else { + (listener.name.as_str().replace('.', "_"), "") + }; + let handler_name = format!( + "{sanitized_base}_{animation_prefix}{event_name}_HostBindingHandler" + ); let sanitized = sanitize_identifier(&handler_name); let name_str = allocator.alloc_str(&sanitized); listener.handler_fn_name = Some(Ident::from(name_str)); diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/ordering.rs b/crates/oxc_angular_compiler/src/pipeline/phases/ordering.rs index 423388547..fd26edc89 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/ordering.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/ordering.rs @@ -100,19 +100,27 @@ fn update_op_priority_host(op: &UpdateOp<'_>) -> u32 { /// Ordering priority for create operations. /// Lower values are processed first. /// -/// Matches Angular's CREATE_ORDERING: -/// 1. Legacy animation listeners on host (Listener with hostListener && isLegacyAnimationListener) -/// 2. Basic listeners (Listener, TwoWayListener, Animation, AnimationListener) +/// Matches Angular's CREATE_ORDERING (ordering.ts). +/// +/// LegacyAnimation host listeners are placed before regular listeners to match +/// Angular's reference compiler output. The two instructions differ only in which +/// renderer they use: `ɵɵsyntheticHostListener` calls `loadComponentRenderer` to +/// use the component's own renderer (so @trigger events are handled by the +/// animation engine), while `ɵɵlistener` uses the parent lView's renderer. +/// The ordering itself is a spec requirement — it ensures our output is consistent +/// with TemplateDefinitionBuilder and Angular's compliance tests. fn create_op_priority(op: &CreateOp<'_>) -> u32 { match op { - // Legacy animation listeners on host come first + // LegacyAnimation host listeners before regular listeners (spec compliance) CreateOp::Listener(l) if l.host_listener && l.is_animation_listener => 0, // Basic listeners (Listener, TwoWayListener, Animation, AnimationListener) CreateOp::Listener(_) => 1, CreateOp::TwoWayListener(_) => 1, CreateOp::Animation(_) => 1, CreateOp::AnimationListener(_) => 1, - _ => 100, // Other ops maintain original order + // Any new CreateOp variants default to 100 (maintain original order). + // If a new variant has an ordering constraint, add an explicit arm above. + _ => 100, } } @@ -530,3 +538,60 @@ pub fn order_ops_for_host(job: &mut HostBindingCompilationJob<'_>) { order_create_ops(&mut job.root.create); order_update_ops_host(&mut job.root.update); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ir::ops::{CreateOpBase, ListenerOp, SlotId, XrefId}; + use oxc_allocator::{Allocator, Vec as AllocVec}; + use oxc_str::Ident; + + fn make_listener_op<'a>( + allocator: &'a Allocator, + host_listener: bool, + is_animation_listener: bool, + legacy_animation_phase: Option>, + ) -> CreateOp<'a> { + CreateOp::Listener(ListenerOp { + base: CreateOpBase::default(), + target: XrefId(0), + target_slot: SlotId(0), + tag: None, + host_listener, + name: Ident::from(""), + handler_expression: None, + handler_ops: AllocVec::new_in(allocator), + handler_fn_name: None, + consume_fn_name: None, + is_animation_listener, + legacy_animation_phase, + event_target: None, + consumes_dollar_event: false, + }) + } + + #[test] + fn legacy_animation_host_listener_has_priority_zero() { + let allocator = Allocator::default(); + // LegacyAnimation host listener (host_listener=true, is_animation_listener=true) + // must get priority 0 so it is ordered before regular listeners. + let op = make_listener_op(&allocator, true, true, Some(Ident::from("done"))); + assert_eq!(create_op_priority(&op), 0); + } + + #[test] + fn regular_host_listener_has_priority_one() { + let allocator = Allocator::default(); + let op = make_listener_op(&allocator, true, false, None); + assert_eq!(create_op_priority(&op), 1); + } + + #[test] + fn template_animation_listener_has_priority_one() { + let allocator = Allocator::default(); + // Template-level animation listeners (host_listener=false) are NOT synthetic + // host listeners and should NOT get priority 0. + let op = make_listener_op(&allocator, false, true, Some(Ident::from("start"))); + assert_eq!(create_op_priority(&op), 1); + } +} 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 699fe4cc4..aad4c15df 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs @@ -1327,19 +1327,38 @@ fn reify_host_create_op<'a>( .as_ref() .and_then(|target| GlobalEventTarget::from_str(target.as_str())); - // Host listeners use ɵɵlistener, NOT ɵɵsyntheticHostListener - // syntheticHostListener is only for animation listeners - // Use capture mode only when both host_listener and is_animation_listener are true - let use_capture = listener.host_listener && listener.is_animation_listener; - Some(create_listener_stmt_with_handler( - allocator, - &listener.name, - handler_stmts, - event_target, - use_capture, - listener.handler_fn_name.as_ref(), - listener.consumes_dollar_event, - )) + // LegacyAnimation host listeners (from @HostListener('@trigger.phase') or + // host: { '(@trigger.phase)': '...' }) must use ɵɵsyntheticHostListener so + // Angular's animation renderer handles them. Matches TypeScript: + // syntheticHost = op.hostListener && op.isLegacyAnimationListener + let is_synthetic_host = listener.host_listener && listener.is_legacy_animation(); + if is_synthetic_host { + Some(create_synthetic_host_listener_stmt( + allocator, + &listener.name, + handler_stmts, + listener.handler_fn_name.as_ref(), + listener.consumes_dollar_event, + )) + } else { + // Plain ɵɵlistener path. Angular's reify (compiler/src/template/pipeline/ + // src/phases/reify.ts) only differentiates synthetic vs non-synthetic via + // the function-name swap above; it does not pass a useCapture argument. + // After the ingest fix that aligns is_animation_listener with Angular's + // isLegacyAnimationListener (= phase != null), this expression is + // structurally always false here — kept for defence-in-depth and to make + // the relationship to the synthetic branch explicit. + let use_capture = listener.host_listener && listener.is_animation_listener; + Some(create_listener_stmt_with_handler( + allocator, + &listener.name, + handler_stmts, + event_target, + use_capture, + listener.handler_fn_name.as_ref(), + listener.consumes_dollar_event, + )) + } } CreateOp::AnimationListener(listener) => { // Emit syntheticHostListener for animation listeners 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 caaa346f8..47dcd33d6 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 @@ -226,6 +226,40 @@ pub fn create_two_way_listener_stmt<'a>( create_instruction_call_stmt(allocator, Identifiers::TWO_WAY_LISTENER, args) } +/// Creates an ɵɵsyntheticHostListener() call for a LegacyAnimation host listener. +/// +/// Unlike `create_animation_listener_stmt`, this takes the full pre-built event name +/// (e.g. `"@slideIn.done"`) directly — the naming phase has already set it. +/// Matches TypeScript: `syntheticHost = op.hostListener && op.isLegacyAnimationListener`. +pub fn create_synthetic_host_listener_stmt<'a>( + allocator: &'a oxc_allocator::Allocator, + name: &Ident<'a>, + handler_stmts: OxcVec<'a, OutputStatement<'a>>, + handler_fn_name: Option<&Ident<'a>>, + consumes_dollar_event: bool, +) -> OutputStatement<'a> { + let mut args = OxcVec::new_in(allocator); + args.push(OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::String(name.clone()), source_span: None }, + allocator, + ))); + let mut params = OxcVec::new_in(allocator); + if consumes_dollar_event { + params.push(FnParam { name: Ident::from("$event") }); + } + let handler_fn = OutputExpression::Function(Box::new_in( + FunctionExpr { + name: handler_fn_name.cloned(), + params, + statements: handler_stmts, + source_span: None, + }, + allocator, + )); + args.push(handler_fn); + create_instruction_call_stmt(allocator, Identifiers::SYNTHETIC_HOST_LISTENER, args) +} + /// Creates an ɵɵsyntheticHostListener() call statement for animation listeners. /// /// Animation listeners have event names like "@trigger.start" or "@trigger.done". diff --git a/napi/angular-compiler/test/transform.test.ts b/napi/angular-compiler/test/transform.test.ts index 721c81720..d51778fd0 100644 --- a/napi/angular-compiler/test/transform.test.ts +++ b/napi/angular-compiler/test/transform.test.ts @@ -256,3 +256,287 @@ function OtherComponent_Template(rf, ctx) {} expect(result.templateFunctions.some((f) => f.includes('OtherComponent'))).toBe(false) }) }) + +describe('animation host listeners', () => { + it('should emit syntheticHostListener for @HostListener animation events', async () => { + const source = ` + import { Component, HostListener } from '@angular/core'; + @Component({ selector: 'app-test', template: '' }) + export class TestComponent { + @HostListener('@animation.done', ['$event']) + onDone(event: any) {} + } + ` + const result = await transformAngularFile(source, 'test.component.ts', {}) + expect(result.errors).toHaveLength(0) + expect(result.code).toContain('ɵɵsyntheticHostListener') + expect(result.code).toContain('"@animation.done"') + expect(result.code).not.toMatch(/ɵɵlistener\(["']@animation/) + }) + + it('should emit syntheticHostListener for @HostListener animation start phase', async () => { + const source = ` + import { Component, HostListener } from '@angular/core'; + @Component({ selector: 'app-test', template: '' }) + export class TestComponent { + @HostListener('@animation.start', ['$event']) + onStart(event: any) {} + } + ` + const result = await transformAngularFile(source, 'test.component.ts', {}) + expect(result.errors).toHaveLength(0) + expect(result.code).toContain('ɵɵsyntheticHostListener') + expect(result.code).toContain('"@animation.start"') + expect(result.code).not.toMatch(/ɵɵlistener\(["']@animation/) + }) + + it('should emit syntheticHostListener for host object animation event binding', async () => { + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-test', + template: '', + host: { '(@animation.done)': 'onDone()' }, + }) + export class TestComponent { + onDone() {} + } + ` + const result = await transformAngularFile(source, 'test.component.ts', {}) + expect(result.errors).toHaveLength(0) + expect(result.code).toContain('ɵɵsyntheticHostListener') + expect(result.code).toContain('"@animation.done"') + }) + + it('should emit correct handler function name for animation listener', async () => { + const source = ` + import { Component, HostListener } from '@angular/core'; + @Component({ selector: 'app-test', template: '' }) + export class TestComponent { + @HostListener('@animation.done') + onDone() {} + } + ` + const result = await transformAngularFile(source, 'test.component.ts', {}) + expect(result.errors).toHaveLength(0) + // Handler name must follow Angular's pattern: ComponentName_animation@trigger.phase_HostBindingHandler + // After sanitize: TestComponent_animation_animation_done_HostBindingHandler + expect(result.code).toContain('TestComponent_animation_animation_done_HostBindingHandler') + }) + + it('should emit syntheticHostListener before listener when both are present (ordering)', async () => { + // Declare the regular listener first so that without the ordering fix the + // output order would be wrong (listener before syntheticHostListener). + const source = ` + import { Component, HostListener } from '@angular/core'; + @Component({ selector: 'app-test', template: '' }) + export class TestComponent { + @HostListener('click') + onClick() {} + @HostListener('@animation.done') + onDone() {} + } + ` + const result = await transformAngularFile(source, 'test.component.ts', {}) + expect(result.errors).toHaveLength(0) + expect(result.code).toContain('ɵɵsyntheticHostListener') + expect(result.code).toContain('ɵɵlistener') + // syntheticHostListener must appear before the regular listener in the output + const syntheticIdx = result.code.indexOf('ɵɵsyntheticHostListener') + const listenerIdx = result.code.indexOf('ɵɵlistener') + expect(syntheticIdx).toBeLessThan(listenerIdx) + }) + + it('should emit syntheticHostListener for @Directive animation host listener', async () => { + const source = ` + import { Directive, HostListener } from '@angular/core'; + @Directive({ selector: '[appTest]' }) + export class TestDirective { + @HostListener('@animation.done') + onDone() {} + } + ` + const result = await transformAngularFile(source, 'test.directive.ts', {}) + expect(result.errors).toHaveLength(0) + expect(result.code).toContain('ɵɵsyntheticHostListener') + expect(result.code).toContain('"@animation.done"') + expect(result.code).not.toMatch(/ɵɵlistener\(["']@animation/) + }) + + // Mirrors Angular compliance test: chain_synthetic_listeners.ts + // Angular output: + // ɵɵsyntheticHostListener("@animation.done", fn MyComponent_animation_animation_done_HostBindingHandler) + // ("@animation.start", fn MyComponent_animation_animation_start_HostBindingHandler) + it('should match Angular compliance chain_synthetic_listeners: chained syntheticHostListener with exact handler names', async () => { + const source = ` + import { Component, HostListener } from '@angular/core'; + @Component({ + selector: 'my-comp', + template: '', + host: { '(@animation.done)': 'done()' }, + }) + export class MyComponent { + @HostListener('@animation.start') + start() {} + done() {} + } + ` + const result = await transformAngularFile(source, 'test.component.ts', {}) + expect(result.errors).toHaveLength(0) + // Both phased listeners must use syntheticHostListener — never ɵɵlistener for @animation + expect(result.code).not.toMatch(/ɵɵlistener\(["']@animation/) + // Exact chained structure: ɵɵsyntheticHostListener("@animation.done", fn)("@animation.start", fn) + // (whitespace-insensitive match to allow for formatting differences) + expect(result.code).toMatch( + /ɵɵsyntheticHostListener\("@animation\.done",function MyComponent_animation_animation_done_HostBindingHandler[\s\S]*?\)\s*\(\s*"@animation\.start",function MyComponent_animation_animation_start_HostBindingHandler/, + ) + }) + + // Mirrors Angular compliance test: chain_synthetic_listeners_mixed.ts + // Angular output: + // ɵɵsyntheticHostListener("@animation.done", fn_done)("@animation.start", fn_start); + // ɵɵlistener("mousedown", fn)("mouseup", fn)("click", fn); + it('should match Angular compliance chain_synthetic_listeners_mixed: synthetic chain before regular chain', async () => { + const source = ` + import { Component, HostListener } from '@angular/core'; + @Component({ + selector: 'my-comp', + template: '', + host: { + '(mousedown)': 'mousedown()', + '(@animation.done)': 'done()', + '(mouseup)': 'mouseup()', + }, + }) + export class MyComponent { + @HostListener('@animation.start') + start() {} + @HostListener('click') + click() {} + mousedown() {} + done() {} + mouseup() {} + } + ` + const result = await transformAngularFile(source, 'test.component.ts', {}) + expect(result.errors).toHaveLength(0) + // Synthetic chain: both animation handlers chained on one syntheticHostListener call + expect(result.code).toMatch( + /ɵɵsyntheticHostListener\("@animation\.done",function MyComponent_animation_animation_done_HostBindingHandler[\s\S]*?\)\s*\(\s*"@animation\.start",function MyComponent_animation_animation_start_HostBindingHandler/, + ) + // Regular chain: all three regular handlers chained on one ɵɵlistener call + expect(result.code).toMatch( + /ɵɵlistener\("mousedown",function MyComponent_mousedown_HostBindingHandler/, + ) + expect(result.code).toMatch(/MyComponent_mouseup_HostBindingHandler/) + expect(result.code).toMatch(/MyComponent_click_HostBindingHandler/) + // Synthetic chain must come before the regular chain + const syntheticIdx = result.code.indexOf('ɵɵsyntheticHostListener') + const listenerIdx = result.code.indexOf('ɵɵlistener') + expect(syntheticIdx).toBeLessThan(listenerIdx) + // No animation events via regular ɵɵlistener + expect(result.code).not.toMatch(/ɵɵlistener\(["']@animation/) + }) + + // Mirrors Angular's parseLegacyAnimationEventName: phase is lowercased via `.toLowerCase()`. + // Source `@HostListener('@anim.START')` should compile identically to `@HostListener('@anim.start')`. + it('should lowercase phase to match Angular parser', async () => { + const source = ` + import { Component, HostListener } from '@angular/core'; + @Component({ selector: 'app-test', template: '' }) + export class TestComponent { + @HostListener('@anim.START') + onStart() {} + } + ` + const result = await transformAngularFile(source, 'test.component.ts', {}) + expect(result.errors).toHaveLength(0) + expect(result.code).toContain('ɵɵsyntheticHostListener("@anim.start"') + expect(result.code).toContain('TestComponent_animation_anim_start_HostBindingHandler') + }) + + // Mirrors Angular's splitAtPeriod which trims both sides of the split. + it('should trim phase whitespace to match Angular parser', async () => { + const source = ` + import { Component, HostListener } from '@angular/core'; + @Component({ selector: 'app-test', template: '' }) + export class TestComponent { + @HostListener('@anim. start ') + onStart() {} + } + ` + const result = await transformAngularFile(source, 'test.component.ts', {}) + expect(result.errors).toHaveLength(0) + expect(result.code).toContain('ɵɵsyntheticHostListener("@anim.start"') + }) + + // Mirrors Angular's `_parseLegacyAnimationEvent`: a phase that is not "start" or "done" + // still produces a ParsedEvent with that phase (an error is also reported, but code is + // still emitted). The listener is therefore `ɵɵsyntheticHostListener("@anim.foo", fn)`, + // not a fallback `ɵɵlistener`. + it('should preserve bogus phase in syntheticHostListener output', async () => { + const source = ` + import { Component, HostListener } from '@angular/core'; + @Component({ selector: 'app-test', template: '' }) + export class TestComponent { + @HostListener('@anim.foo') + onFoo() {} + } + ` + const result = await transformAngularFile(source, 'test.component.ts', {}) + expect(result.code).toContain('ɵɵsyntheticHostListener("@anim.foo"') + expect(result.code).toContain('TestComponent_animation_anim_foo_HostBindingHandler') + expect(result.code).not.toContain('ɵɵlistener("anim"') + }) + + // Mirrors Angular's `sanitizeIdentifier` (parse_util.ts) which uses `/\W/g` — + // an ASCII-only character class. Non-ASCII characters in trigger names must be + // replaced with `_`, matching the JavaScript regex behavior. + it('should ASCII-sanitize non-ASCII trigger characters in handler names', async () => { + const source = ` + import { Component, HostListener } from '@angular/core'; + @Component({ selector: 'app-test', template: '' }) + export class TestComponent { + @HostListener('@μAnim.start') + onStart() {} + } + ` + const result = await transformAngularFile(source, 'test.component.ts', {}) + expect(result.errors).toHaveLength(0) + // The literal in syntheticHostListener preserves the trigger as-is (Angular + // does not sanitize it — the @ + trigger + . + phase string is the runtime key) + expect(result.code).toContain('ɵɵsyntheticHostListener("@μAnim.start"') + // The handler function name MUST sanitize μ → _ (JS \W matches non-ASCII) + expect(result.code).toContain('TestComponent_animation__Anim_start_HostBindingHandler') + expect(result.code).not.toContain('TestComponent_animation_μAnim_start') + }) + + // Mirrors Angular's binding_parser.ts + ingest.ts behavior for `@HostListener('@trigger')` + // without an explicit phase. Angular's parseLegacyAnimationEventName strips the `@`, + // leaves phase=null, and createListenerOp sets isLegacyAnimationListener=false (since + // phase is null). Reify then emits plain `ɵɵlistener(name, handler)` — no + // syntheticHostListener, no trailing useCapture argument. + // + // Angular also reports an error here, but matching the host-metadata convention in + // this codebase (binding_parser parse errors are silently dropped for host bindings + // — see component/transform.rs and directive/compiler.rs which never inspect + // `parse_result.errors`), we don't surface a diagnostic. Only the code output is + // checked for parity. + it('should emit plain ɵɵlistener for @HostListener without phase, matching Angular', async () => { + const source = ` + import { Component, HostListener } from '@angular/core'; + @Component({ selector: 'app-test', template: '' }) + export class TestComponent { + @HostListener('@anim') + onAnim() {} + } + ` + const result = await transformAngularFile(source, 'test.component.ts', {}) + expect(result.errors).toHaveLength(0) + expect(result.code).not.toContain('ɵɵsyntheticHostListener') + expect(result.code).toMatch( + /ɵɵlistener\(\s*"anim"\s*,\s*function TestComponent_anim_HostBindingHandler\(\)\s*\{[\s\S]*?\}\s*\)\s*;/, + ) + expect(result.code).not.toMatch(/ɵɵlistener\(\s*"anim"[\s\S]*?,\s*null\s*,\s*true\s*\)/) + }) +})