From cc1dcdb887ce6a2afd9a59cd40815ae945b278aa Mon Sep 17 00:00:00 2001 From: Damian Kaczmarek Date: Wed, 21 Oct 2026 12:58:31 -0500 Subject: [PATCH] fix(host): compile every stacked @HostListener and @HostBinding of a member Only the first `@HostListener` or `@HostBinding` of a member was compiled: `@HostListener('click') @HostListener('keydown.enter') activate() {}` listened to clicks only. Like ngtsc, each decorator is now its own host listener or binding. --- .../src/directive/property_decorators.rs | 39 +++++++++------- .../tests/stacked_host_decorators_test.rs | 44 +++++++++++++++++++ 2 files changed, 66 insertions(+), 17 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/stacked_host_decorators_test.rs diff --git a/crates/oxc_angular_compiler/src/directive/property_decorators.rs b/crates/oxc_angular_compiler/src/directive/property_decorators.rs index fd453d00b..a08bc9c8b 100644 --- a/crates/oxc_angular_compiler/src/directive/property_decorators.rs +++ b/crates/oxc_angular_compiler/src/directive/property_decorators.rs @@ -1812,19 +1812,21 @@ pub(crate) fn extract_host_bindings_in<'a>( continue; } - let Some(decorator) = find_decorator_by_name(decorators, "HostBinding", consts) else { - continue; - }; - let Some(class_property_name) = property_name else { continue; }; - // Extract the host property name from the decorator argument - let host_property_name = - extract_host_binding_name(decorator).unwrap_or_else(|| class_property_name.clone()); + // Every @HostBinding of the member is a binding: ngtsc compiles + // `@HostBinding('class.a') @HostBinding('b') get x()` to two. + for decorator in + decorators.iter().filter(|d| angular_member_decorator(d, consts) == Some("HostBinding")) + { + // Extract the host property name from the decorator argument + let host_property_name = + extract_host_binding_name(decorator).unwrap_or_else(|| class_property_name.clone()); - bindings.push((host_property_name, class_property_name)); + bindings.push((host_property_name, class_property_name.clone())); + } } bindings @@ -1904,21 +1906,24 @@ pub(crate) fn extract_host_listeners_in<'a>( continue; } - let Some(decorator) = find_decorator_by_name(decorators, "HostListener", consts) else { - continue; - }; - let Some(method_name) = property_name else { continue; }; - let (event_name, args) = parse_host_listener_config(allocator, decorator); + // Every @HostListener of the member is a listener: ngtsc compiles + // `@HostListener('click') @HostListener('pointerdown') m() {}` to two. + for decorator in decorators + .iter() + .filter(|d| angular_member_decorator(d, consts) == Some("HostListener")) + { + let (event_name, args) = parse_host_listener_config(allocator, decorator); - let Some(event_name) = event_name else { - continue; - }; + let Some(event_name) = event_name else { + continue; + }; - listeners.push((event_name, method_name, args)); + listeners.push((event_name, method_name.clone(), args)); + } } listeners diff --git a/crates/oxc_angular_compiler/tests/stacked_host_decorators_test.rs b/crates/oxc_angular_compiler/tests/stacked_host_decorators_test.rs new file mode 100644 index 000000000..b8ee636df --- /dev/null +++ b/crates/oxc_angular_compiler/tests/stacked_host_decorators_test.rs @@ -0,0 +1,44 @@ +//! A member can carry several `@HostListener`s or `@HostBinding`s +//! (`@HostListener('click') @HostListener('keydown.enter') activate() {}`); each +//! one is a host listener or binding, as in ngtsc. The expected code is +//! `@angular/compiler-cli` 21.0.1's `hostBindings` for the same class. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{TransformOptions, transform_angular_file}; + +#[test] +fn every_stacked_host_listener_and_binding_is_compiled() { + let source = r" +import { Component, HostBinding, HostListener } from '@angular/core'; +@Component({ selector: 'c-cmp', template: '' }) +export class CCmp { + @HostListener('click') @HostListener('keydown.enter') activate() {} + @HostListener('focus', ['$event']) @HostListener('blur', ['$event']) onFocus(e: Event) {} + @HostBinding('class.on') @HostBinding('attr.aria-pressed') on = true; +} +"; + let allocator = Allocator::default(); + let result = transform_angular_file( + &allocator, + "test.ts", + source, + Some(&TransformOptions::default()), + None, + ); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + let code: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + + let listeners = concat!( + r#"i0.ɵɵlistener("click",functionCCmp_click_HostBindingHandler(){returnctx.activate();})"#, + r#"("keydown.enter",functionCCmp_keydown_enter_HostBindingHandler(){returnctx.activate();})"#, + r#"("focus",functionCCmp_focus_HostBindingHandler($event){returnctx.onFocus($event);})"#, + r#"("blur",functionCCmp_blur_HostBindingHandler($event){returnctx.onFocus($event);});"#, + ); + assert!(code.contains(listeners), "Expected the four listeners in:\n{}", result.code); + assert!( + code.contains(r#"i0.ɵɵattribute("aria-pressed",ctx.on);i0.ɵɵclassProp("on",ctx.on);"#), + "Expected both bindings in:\n{}", + result.code + ); + assert!(code.contains("hostVars:3"), "Got:\n{}", result.code); +}