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
7 changes: 7 additions & 0 deletions crates/oxc_angular_compiler/src/component/transform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6688,6 +6688,13 @@ fn convert_host_metadata_to_input<'a>(

let (effective_name, event_type, phase) =
parse_legacy_animation_event(final_event_name, &allocator);
// Angular's `_parseRegularEvent` checks the name before splitting off the target.
let event_type =
if event_type == ParsedEventType::Regular && event_name.starts_with("animate.") {
ParsedEventType::Animation
} else {
event_type
};

// Parse the handler expression
let value_str = allocator.alloc_str(value.as_str());
Expand Down
7 changes: 7 additions & 0 deletions crates/oxc_angular_compiler/src/directive/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,13 @@ fn convert_r3_host_metadata_to_input<'a>(

let (effective_name, event_type, phase) =
parse_legacy_animation_event(final_event_name, &allocator);
// Angular's `_parseRegularEvent` checks the name before splitting off the target.
let event_type =
if event_type == ParsedEventType::Regular && event_name.starts_with("animate.") {
ParsedEventType::Animation
} else {
event_type
};

// Parse the handler expression
let value_str = allocator.alloc_str(value.as_str());
Expand Down
116 changes: 100 additions & 16 deletions crates/oxc_angular_compiler/src/pipeline/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ use crate::ast::r3::{
R3TextAttribute, SecurityContext,
};
use crate::ir::enums::{
BindingKind, DeferOpModifierKind, DeferTriggerKind, Namespace, TDeferDetailsFlags, TemplateKind,
AnimationKind, BindingKind, DeferOpModifierKind, DeferTriggerKind, Namespace,
TDeferDetailsFlags, TemplateKind,
};
use crate::ir::expression::{
BinaryExpr, ConditionalCaseExpr, EmptyExpr, IrBinaryOperator, IrExpression, LexicalReadExpr,
Expand All @@ -40,12 +41,12 @@ use crate::ir::expression::{
TwoWayBindingSetExpr, VisitorContextFlag, transform_expressions_in_expression,
};
use crate::ir::ops::{
BindingOp, ConditionalBranchCreateOp, ConditionalOp, ConditionalUpdateOp, CreateOp,
CreateOpBase, DeclareLetOp, DeferOnOp, DeferOp, DeferWhenOp, ElementEndOp, ElementStartOp,
ExtractedAttributeOp, I18nAttributesOp, I18nEndOp, I18nPlaceholder, I18nSlotHandle,
I18nStartOp, IcuEndOp, IcuStartOp, InterpolateTextOp, ListenerOp, LocalRef, ProjectionOp,
RepeaterCreateOp, RepeaterOp, RepeaterVarNames, SlotId, StatementOp, StoreLetOp, TemplateOp,
TextOp, TwoWayListenerOp, UpdateOp, UpdateOpBase, XrefId,
AnimationListenerOp, BindingOp, ConditionalBranchCreateOp, ConditionalOp, ConditionalUpdateOp,
CreateOp, CreateOpBase, DeclareLetOp, DeferOnOp, DeferOp, DeferWhenOp, ElementEndOp,
ElementStartOp, ExtractedAttributeOp, I18nAttributesOp, I18nEndOp, I18nPlaceholder,
I18nSlotHandle, I18nStartOp, IcuEndOp, IcuStartOp, InterpolateTextOp, ListenerOp, LocalRef,
ProjectionOp, RepeaterCreateOp, RepeaterOp, RepeaterVarNames, SlotId, StatementOp, StoreLetOp,
TemplateOp, TextOp, TwoWayListenerOp, UpdateOp, UpdateOpBase, XrefId,
};
use crate::output::ast::{OutputExpression, ReadVarExpr};
use crate::pipeline::compilation::{AliasVariable, ContextVariable};
Expand Down Expand Up @@ -1893,16 +1894,29 @@ fn ingest_listener_owned<'a>(
}
}

if output.event_type == ParsedEventType::Animation {
let op = create_animation_listener_op(
allocator,
element_xref,
tag,
false,
output.name,
handler_ops,
handler_expr,
output.source_span,
);
if let Some(view) = job.view_mut(view_xref) {
view.create.push(op);
}
return;
}

// 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.
// matching Angular's reference output.
let (is_animation_listener, legacy_animation_phase) = match output.event_type {
ParsedEventType::Animation => (true, None),
ParsedEventType::LegacyAnimation => {
let phase = output.phase.clone();
(phase.is_some(), phase)
Expand Down Expand Up @@ -1933,6 +1947,55 @@ fn ingest_listener_owned<'a>(
}
}

/// Creates the `AnimationListenerOp` for an `(animate.enter)` / `(animate.leave)` event.
///
/// The last handler expression is returned, as in Angular's `makeListenerHandlerOps`.
#[allow(clippy::too_many_arguments)]
fn create_animation_listener_op<'a>(
allocator: &'a oxc_allocator::Allocator,
target: XrefId,
tag: Option<Ident<'a>>,
host_listener: bool,
name: Ident<'a>,
mut handler_ops: Vec<'a, UpdateOp<'a>>,
handler_expr: Option<Box<'a, IrExpression<'a>>>,
source_span: oxc_span::Span,
) -> CreateOp<'a> {
if let Some(expr) = handler_expr {
let return_stmt = crate::output::ast::OutputStatement::Return(Box::new_in(
crate::output::ast::ReturnStatement {
value: OutputExpression::WrappedIrNode(Box::new_in(
crate::output::ast::WrappedIrExpr {
node: expr,
source_span: Some(source_span),
},
&allocator,
)),
source_span: Some(source_span),
},
&allocator,
));
handler_ops.push(UpdateOp::Statement(StatementOp {
base: UpdateOpBase::default(),
statement: return_stmt,
}));
}
let phase =
if name.as_str().ends_with("enter") { AnimationKind::Enter } else { AnimationKind::Leave };
CreateOp::AnimationListener(AnimationListenerOp {
base: CreateOpBase { source_span: Some(source_span), ..Default::default() },
target,
target_slot: SlotId(0), // Will be set during slot allocation
tag,
host_listener,
name,
handler_ops,
handler_fn_name: None,
phase,
consumes_dollar_event: false, // Set during resolve_dollar_event phase
})
}

/// Checks if a template is an explicit `<ng-template>` (as opposed to a structural directive).
/// Ported from Angular's `isPlainTemplate` in `ingest.ts`.
fn is_plain_template(tag_name: &Option<Ident>) -> bool {
Expand Down Expand Up @@ -2212,6 +2275,12 @@ fn ingest_template<'a>(
for output in outputs {
if template_kind == TemplateKind::NgTemplate {
// For explicit <ng-template>, create listeners at the template level.
// Angular's `ingestTemplateBindings` makes a plain listener for every output here,
// `(animate.enter)` / `(animate.leave)` included.
let mut output = output;
if output.event_type == ParsedEventType::Animation {
output.event_type = ParsedEventType::Regular;
}
ingest_listener_owned(job, view_xref, xref, tag_name.clone(), output);
} else {
// For structural directives, create ExtractedAttributeOp for directive matching.
Expand Down Expand Up @@ -4500,10 +4569,25 @@ fn ingest_host_event<'a>(job: &mut HostBindingCompilationJob<'a>, event: R3Bound
// 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.
// 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.
if event.event_type == ParsedEventType::Animation {
let root_xref = job.root.xref;
let op = create_animation_listener_op(
allocator,
root_xref,
None,
true,
event.name,
handler_ops,
handler_expr,
event.source_span,
);
job.root.create.push(op);
return;
}

let (legacy_animation_phase, target) = match event.event_type {
ParsedEventType::LegacyAnimation => (event.phase.clone(), None),
_ => (None, event.target.clone()),
Expand Down
4 changes: 2 additions & 2 deletions crates/oxc_angular_compiler/src/pipeline/phases/naming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -864,7 +864,7 @@ fn process_single_create_op_ref<'a>(
CreateOp::AnimationListener(listener) => {
if listener.handler_fn_name.is_none() {
let slot = listener.target_slot.0;
let animation_kind = listener.name.as_str().replace('.', "");
let animation_kind = listener.name.as_str().replacen('.', "", 1);
let fn_name_str = fn_name.map(|n| n.as_str()).unwrap_or(base_name);

let handler_name = if listener.host_listener {
Expand Down Expand Up @@ -1220,7 +1220,7 @@ pub fn name_functions_and_variables_for_host(job: &mut HostBindingCompilationJob
}
CreateOp::AnimationListener(listener) => {
if listener.handler_fn_name.is_none() && listener.host_listener {
let animation_kind = listener.name.as_str().replace('.', "");
let animation_kind = listener.name.as_str().replacen('.', "", 1);
let handler_name =
format!("{sanitized_base}_{animation_kind}_HostBindingHandler");
let sanitized = sanitize_identifier(&handler_name);
Expand Down
17 changes: 13 additions & 4 deletions crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -909,7 +909,7 @@ fn reify_create_op<'a>(
))
}
CreateOp::AnimationListener(listener) => {
// Emit syntheticHostListener instruction for animation listeners
// Emit ɵɵanimateEnterListener / ɵɵanimateLeaveListener
let mut handler_stmts = OxcVec::new_in(&allocator);
for handler_op in listener.handler_ops.iter() {
if let Some(stmt) = reify_update_op(
Expand All @@ -929,7 +929,6 @@ fn reify_create_op<'a>(
Some(create_animation_listener_stmt(
&allocator,
core_namespace,
&listener.name,
listener.phase,
handler_stmts,
listener.handler_fn_name.as_ref(),
Expand Down Expand Up @@ -1803,7 +1802,7 @@ fn reify_host_create_op<'a>(
}
}
CreateOp::AnimationListener(listener) => {
// Emit syntheticHostListener for animation listeners
// Emit ɵɵanimateEnterListener / ɵɵanimateLeaveListener
let mut handler_stmts = OxcVec::new_in(&allocator);
for handler_op in listener.handler_ops.iter() {
// Host bindings use Full mode (not DomOnly)
Expand All @@ -1824,13 +1823,23 @@ fn reify_host_create_op<'a>(
Some(create_animation_listener_stmt(
&allocator,
core_namespace,
&listener.name,
listener.phase,
handler_stmts,
listener.handler_fn_name.as_ref(),
listener.consumes_dollar_event,
))
}
CreateOp::AnimationString(anim) => {
// Emit ɵɵanimateEnter or ɵɵanimateLeave for a static `animate.enter` / `animate.leave`
let expr = convert_ir_expression(
allocator,
core_namespace,
&anim.expression,
expressions,
root_xref,
);
Some(create_animation_string_stmt(allocator, core_namespace, anim.animation_kind, expr))
}
// ExtractedAttribute ops are handled separately for hostAttrs
CreateOp::ExtractedAttribute(_) => None,
// Other create ops are not relevant for host bindings
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,37 +269,23 @@ pub fn create_synthetic_host_listener_stmt<'a>(
)
}

/// Creates an ɵɵsyntheticHostListener() call statement for animation listeners.
/// Creates an ɵɵanimateEnterListener() or ɵɵanimateLeaveListener() call statement for an
/// `(animate.enter)` / `(animate.leave)` listener.
///
/// Animation listeners have event names like "@trigger.start" or "@trigger.done".
/// The AnimationKind enum currently has Enter/Leave for direction, but animation
/// callbacks use start/done for timing. For now, we map Enter -> start, Leave -> done.
/// Ported from Angular's `animationListener` in `instruction.ts`.
///
/// The `handler_fn_name` parameter sets the name of the handler function expression.
/// The `consumes_dollar_event` parameter controls whether the `$event` parameter is included.
pub fn create_animation_listener_stmt<'a>(
allocator: &'a oxc_allocator::Allocator,
core_namespace: &'a str,
name: &Ident<'a>,
phase: crate::ir::enums::AnimationKind,
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);

// Build the full event name: "@{name}.{phase}"
// AnimationKind::Enter maps to "start", AnimationKind::Leave maps to "done"
let phase_str = match phase {
crate::ir::enums::AnimationKind::Enter => "start",
crate::ir::enums::AnimationKind::Leave => "done",
};
let full_name = allocator.alloc_str(&format!("@{}.{}", name.as_str(), phase_str));
args.push(OutputExpression::Literal(Box::new_in(
LiteralExpr { value: LiteralValue::String(Ident::from(full_name)), source_span: None },
&allocator,
)));

// Handler function: function name($event) { ... } or function name() { ... }
let mut params = OxcVec::new_in(&allocator);
if consumes_dollar_event {
Expand All @@ -317,12 +303,11 @@ pub fn create_animation_listener_stmt<'a>(
));
args.push(handler_fn);

create_instruction_call_stmt(
allocator,
core_namespace,
Identifiers::SYNTHETIC_HOST_LISTENER,
args,
)
let instruction = match phase {
crate::ir::enums::AnimationKind::Enter => Identifiers::ANIMATION_ENTER_LISTENER,
crate::ir::enums::AnimationKind::Leave => Identifiers::ANIMATION_LEAVE_LISTENER,
};
create_instruction_call_stmt(allocator, core_namespace, instruction, args)
}

/// Creates an ɵɵanimateEnter() or ɵɵanimateLeave() call statement for animation string bindings.
Expand Down
8 changes: 7 additions & 1 deletion crates/oxc_angular_compiler/src/transform/html_to_r3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3641,10 +3641,16 @@ impl<'a> HtmlToR3Transform<'a> {
let handler_span = self.calculate_event_handler_span(attr);
let handler = self.parse_event_expression(&attr.value, handler_span);
let name_atom = Ident::from(self.allocator.alloc_str(name));
// `on-animate.enter` is an animation event, like `(animate.enter)`
let event_type = if name.starts_with("animate.") {
ParsedEventType::Animation
} else {
ParsedEventType::Regular
};

R3BoundEvent {
name: name_atom,
event_type: ParsedEventType::Regular,
event_type,
handler,
target: None,
phase: None,
Expand Down
Loading
Loading