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
52 changes: 49 additions & 3 deletions crates/oxc_angular_compiler/src/component/transform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Ident<'a>>) {
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:
Expand Down
52 changes: 49 additions & 3 deletions crates/oxc_angular_compiler/src/directive/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Ident<'a>>) {
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:") {
Expand Down
25 changes: 25 additions & 0 deletions crates/oxc_angular_compiler/src/ir/enums.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
}
}
78 changes: 76 additions & 2 deletions crates/oxc_angular_compiler/src/ir/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -964,14 +964,28 @@ pub struct ListenerOp<'a> {
pub consume_fn_name: Option<Ident<'a>>,
/// Whether this is an animation listener.
pub is_animation_listener: bool,
/// Animation phase.
pub animation_phase: Option<AnimationKind>,
/// 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<Ident<'a>>,
/// Event target (window, document, body).
pub event_target: Option<Ident<'a>>,
/// 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> {
Expand Down Expand Up @@ -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<Ident<'a>>,
) -> 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());
}
}
58 changes: 30 additions & 28 deletions crates/oxc_angular_compiler/src/pipeline/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
};
Expand All @@ -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
})
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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() },
Expand All @@ -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
});
Expand Down
Loading
Loading