diff --git a/crates/oxc_angular_compiler/src/directive/metadata.rs b/crates/oxc_angular_compiler/src/directive/metadata.rs index 0e88c0a30..5529b3709 100644 --- a/crates/oxc_angular_compiler/src/directive/metadata.rs +++ b/crates/oxc_angular_compiler/src/directive/metadata.rs @@ -264,7 +264,7 @@ pub fn merge_host_bindings<'a, V>( /// `key` without the brackets of a `[property]` / `(event)` host key: the key /// ngtsc's `parseHostBindings` stores the binding under. -fn strip_host_key(key: &str, open: char, close: char) -> &str { +pub fn strip_host_key(key: &str, open: char, close: char) -> &str { key.strip_prefix(open).and_then(|k| k.strip_suffix(close)).unwrap_or(key) } diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index f929888be..11d175e55 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -46,7 +46,7 @@ pub(crate) use dts_type::{quote as ts_string_literal, quote_literal as quote_str pub use evaluator::input_transform_types; #[cfg(feature = "cross_file_elision")] pub(crate) use evaluator::{Evaluator, ImportValueResolver, StaticValue}; -pub(crate) use metadata::{MemberHostBindings, merge_host_bindings}; +pub(crate) use metadata::{MemberHostBindings, merge_host_bindings, strip_host_key}; pub use metadata::{ QueryPredicate, R3DirectiveMetadata, R3DirectiveMetadataBuilder, R3HostDirectiveMetadata, R3HostMetadata, R3InputMetadata, R3QueryMetadata, diff --git a/crates/oxc_angular_compiler/src/partial/component.rs b/crates/oxc_angular_compiler/src/partial/component.rs index 9de7f1232..32a703138 100644 --- a/crates/oxc_angular_compiler/src/partial/component.rs +++ b/crates/oxc_angular_compiler/src/partial/component.rs @@ -42,7 +42,7 @@ use crate::component::{ HostMetadata, ImportRef, NamespaceRegistry, TemplateDependency, TemplateDependencyKind, ViewEncapsulation, }; -use crate::directive::{R3InputMetadata, R3QueryMetadata}; +use crate::directive::{R3InputMetadata, R3QueryMetadata, strip_host_key}; use crate::factory::{ FactoryTarget, R3ConstructorFactoryMetadata, R3DependencyMetadata, R3FactoryDeps, R3FactoryMetadata, @@ -574,7 +574,9 @@ fn create_outputs_map<'a>( /// Component carries host bindings as raw `(key, value)` string pairs /// where the key still includes any binding syntax (`[class.x]`, -/// `(click)`, …). The linker parses these into Ivy host instructions. +/// `(click)`, …). The declaration keys `listeners` / `properties` by the +/// bare name (see `host_binding_map`), which the linker parses into Ivy host +/// instructions. fn compile_host_metadata<'a>( allocator: &'a Allocator, host: &HostMetadata<'a>, @@ -608,14 +610,14 @@ fn compile_host_metadata<'a>( if !host.listeners.is_empty() { entries.push(LiteralMapEntry::new( Ident::from("listeners"), - ident_pairs_to_string_map(allocator, &host.listeners), + host_binding_map(allocator, &host.listeners, '(', ')'), false, )); } if !host.properties.is_empty() { entries.push(LiteralMapEntry::new( Ident::from("properties"), - ident_pairs_to_string_map(allocator, &host.properties), + host_binding_map(allocator, &host.properties, '[', ']'), false, )); } @@ -640,15 +642,23 @@ fn compile_host_metadata<'a>( ))) } -fn ident_pairs_to_string_map<'a>( +/// The `listeners` / `properties` map of the partial `host`, keyed by the bare +/// event / property name (`click`, `keydown.enter`, `window:resize`, +/// `attr.aria-label`, `style.height.px`, …) without the `(…)` / `[…]` of the +/// `host:` entry, like ngtsc's `parseHostBindings`. The linker parses that +/// name the way a full compilation parses the key after removing its brackets. +fn host_binding_map<'a>( allocator: &'a Allocator, pairs: &Vec<'a, (Ident<'a>, Ident<'a>)>, + open: char, + close: char, ) -> OutputExpression<'a> { let mut entries: Vec<'a, LiteralMapEntry<'a>> = Vec::with_capacity_in(pairs.len(), &allocator); for (key, value) in pairs { - let quoted = is_unsafe_object_key(key.as_str()); + let name = strip_host_key(key.as_str(), open, close); + let quoted = is_unsafe_object_key(name); entries.push(LiteralMapEntry::new( - key.clone(), + Ident::from(name), string_literal_owned(allocator, value.clone()), quoted, )); diff --git a/crates/oxc_angular_compiler/src/partial/directive.rs b/crates/oxc_angular_compiler/src/partial/directive.rs index 14c701f69..d4a34218d 100644 --- a/crates/oxc_angular_compiler/src/partial/directive.rs +++ b/crates/oxc_angular_compiler/src/partial/directive.rs @@ -43,7 +43,7 @@ use super::factory::compile_declare_factory_function; use super::wrap_forward_ref; use crate::directive::{ QueryPredicate, R3DirectiveMetadata, R3HostDirectiveMetadata, R3HostMetadata, R3InputMetadata, - R3QueryMetadata, + R3QueryMetadata, strip_host_key, }; use crate::factory::{ FactoryTarget, R3ConstructorFactoryMetadata, R3DependencyMetadata, R3FactoryDeps, @@ -394,14 +394,14 @@ fn compile_host_metadata<'a>( if !host.listeners.is_empty() { entries.push(LiteralMapEntry::new( Ident::from("listeners"), - ident_pairs_to_string_map(allocator, &host.listeners), + host_binding_map(allocator, &host.listeners, '(', ')'), false, )); } if !host.properties.is_empty() { entries.push(LiteralMapEntry::new( Ident::from("properties"), - ident_pairs_to_string_map(allocator, &host.properties), + host_binding_map(allocator, &host.properties, '[', ']'), false, )); } @@ -426,15 +426,23 @@ fn compile_host_metadata<'a>( ))) } -fn ident_pairs_to_string_map<'a>( +/// The `listeners` / `properties` map of the partial `host`, keyed by the bare +/// event / property name (`click`, `keydown.enter`, `window:resize`, +/// `attr.aria-label`, `style.height.px`, …) without the `(…)` / `[…]` of the +/// `host:` entry, like ngtsc's `parseHostBindings`. The linker parses that +/// name the way a full compilation parses the key after removing its brackets. +fn host_binding_map<'a>( allocator: &'a Allocator, pairs: &Vec<'a, (Ident<'a>, Ident<'a>)>, + open: char, + close: char, ) -> OutputExpression<'a> { let mut entries: Vec<'a, LiteralMapEntry<'a>> = Vec::with_capacity_in(pairs.len(), &allocator); for (key, value) in pairs { - let quoted = is_unsafe_object_key(key.as_str()); + let name = strip_host_key(key.as_str(), open, close); + let quoted = is_unsafe_object_key(name); entries.push(LiteralMapEntry::new( - key.clone(), + Ident::from(name), string_literal_owned(allocator, value.clone()), quoted, )); diff --git a/crates/oxc_angular_compiler/tests/partial_directive_test.rs b/crates/oxc_angular_compiler/tests/partial_directive_test.rs index 4d3e9ae84..56b5d4022 100644 --- a/crates/oxc_angular_compiler/tests/partial_directive_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_directive_test.rs @@ -141,11 +141,14 @@ fn signal_query_bumps_minversion_17_2() { } #[test] -fn host_listeners_and_properties_emitted_raw() { +fn host_listeners_and_properties_keyed_by_bare_name() { let allocator = Allocator::default(); + // Keyed like the `host:` entries they come from. let mut host = R3HostMetadata::new(&allocator); - host.listeners.push((Ident::from("click"), Ident::from("onClick($event)"))); - host.properties.push((Ident::from("disabled"), Ident::from("isDisabled"))); + host.listeners.push((Ident::from("(click)"), Ident::from("onClick($event)"))); + host.listeners.push((Ident::from("(window:resize)"), Ident::from("onResize()"))); + host.properties.push((Ident::from("[disabled]"), Ident::from("isDisabled"))); + host.properties.push((Ident::from("[style.height.px]"), Ident::from("height"))); let meta = R3DirectiveMetadataBuilder::new(&allocator) .name(Ident::from("MyDir")) @@ -157,16 +160,18 @@ fn host_listeners_and_properties_emitted_raw() { let expr = compile_declare_directive_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); - let js = emit(&expr); - // Listeners and properties carried as raw unparsed strings — the linker - // re-parses them. + // The emitter wraps long lines. + let js: String = emit(&expr).split_whitespace().collect(); + // Like ngtsc's `parseHostBindings`, the declaration keys each binding by + // its name without the `(…)` / `[…]`, and carries the expression as a raw + // unparsed string — the linker parses both. assert!( - js.contains(r#"listeners:{click:"onClick($event)"}"#), - "expected raw listener, got: {js}" + js.contains(r#"listeners:{click:"onClick($event)","window:resize":"onResize()"}"#), + "expected listeners keyed by event name, got: {js}" ); assert!( - js.contains(r#"properties:{disabled:"isDisabled"}"#), - "expected raw property, got: {js}" + js.contains(r#"properties:{disabled:"isDisabled","style.height.px":"height"}"#), + "expected properties keyed by property name, got: {js}" ); } diff --git a/napi/angular-compiler/test/partial-host-bindings.test.ts b/napi/angular-compiler/test/partial-host-bindings.test.ts new file mode 100644 index 000000000..0fe6e4720 --- /dev/null +++ b/napi/angular-compiler/test/partial-host-bindings.test.ts @@ -0,0 +1,136 @@ +/** + * Host bindings of partially compiled libraries (#680), once linked. + * + * A partial declaration keys `host.listeners` / `host.properties` by the + * event / property name without the `(…)` / `[…]` of its `host:` key, as + * ngtsc's `parseHostBindings` does, and the linker parses that name like a + * full compilation: key modifiers, `window:` targets, `attr.` / `class.` / + * `style.` bindings with units, and animations. Each case is linked with + * Angular's linker and with Oxc's. + */ +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, relative, resolve } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { linkAngularPackageSync, transformAngularFileSync } from '../index.js' + +/** Angular's linker, as the Angular CLI runs it on libraries. */ +async function angularLink(code: string, filename: string): Promise { + const require = createRequire(import.meta.url) + const babel = createRequire(require.resolve('@angular/compiler-cli/package.json'))('@babel/core') + const { createEs2015LinkerPlugin } = await import('@angular/compiler-cli/linker/babel') + const plugin = createEs2015LinkerPlugin({ + linkerJitMode: false, + fileSystem: { + resolve, + exists: existsSync, + dirname, + relative, + readFile: (file: string) => readFileSync(file, 'utf8'), + } as any, + logger: { level: 1, debug() {}, info() {}, warn() {}, error() {} }, + }) + return babel.transformSync(code, { + filename, + babelrc: false, + configFile: false, + plugins: [plugin], + }).code +} + +const LINKERS = { + angular: angularLink, + oxc: async (code: string, filename: string) => linkAngularPackageSync(code, filename).code, +} + +/** Partially compiles `source` and links it with `linker`. */ +async function link(source: string, linker: keyof typeof LINKERS): Promise { + const result = transformAngularFileSync(source, '/lib/dir.mts', { compilationMode: 'partial' }) + expect(result.errors).toEqual([]) + return LINKERS[linker](result.code, '/lib/dir.mjs') +} + +describe.each(Object.keys(LINKERS) as (keyof typeof LINKERS)[])('linked by %s', (linker) => { + it('listens to host events by name, with key modifiers and global targets', async () => { + const code = await link( + ` + import { Directive } from '@angular/core'; + @Directive({ + selector: '[d]', + host: { + '(click)': 'onClick($event)', + '(keydown.shift.tab)': 'onShiftTab()', + '(window:resize)': 'onResize()', + '(document:keydown.escape)': 'onEscape()', + }, + }) + export class D {} + `, + linker, + ) + expect(code).toMatch(/ɵɵlistener\("click",\s*function D_click_HostBindingHandler/) + expect(code).toMatch(/\("keydown\.shift\.tab",\s*function D_keydown_shift_tab_/) + expect(code).toMatch(/\("resize",\s*function D_resize_[^]*?\},\s*i0\.ɵɵresolveWindow\)/) + expect(code).toMatch( + /\("keydown\.escape",\s*function D_keydown_escape_[^]*?\},\s*i0\.ɵɵresolveDocument\)/, + ) + }) + + it('binds host properties, attributes, classes and styles with units', async () => { + const code = await link( + ` + import { Directive } from '@angular/core'; + @Directive({ + selector: '[d]', + host: { + '[id]': 'id', + '[attr.aria-label]': 'label', + '[class.active]': 'active', + '[style.height.px]': 'height', + }, + }) + export class D {} + `, + linker, + ) + expect(code).toMatch(/ɵɵdomProperty\("id",\s*ctx\.id\)/) + expect(code).toMatch(/ɵɵattribute\("aria-label",\s*ctx\.label\)/) + expect(code).toMatch(/ɵɵclassProp\("active",\s*ctx\.active\)/) + expect(code).toMatch(/ɵɵstyleProp\("height",\s*ctx\.height,\s*"px"\)/) + }) + + it('binds legacy animation triggers and their events', async () => { + const code = await link( + ` + import { Directive } from '@angular/core'; + @Directive({ + selector: '[d]', + host: { '[@fade]': 'state', '(@fade.done)': 'onDone($event)' }, + }) + export class D {} + `, + linker, + ) + expect(code).toMatch(/ɵɵsyntheticHostProperty\("@fade",\s*ctx\.state\)/) + expect(code).toMatch(/ɵɵsyntheticHostListener\("@fade\.done",/) + }) + + it('binds @HostBinding and @HostListener members of a component', async () => { + const code = await link( + ` + import { Component, HostBinding, HostListener } from '@angular/core'; + @Component({ selector: 'c', template: '', host: { '(keydown.enter)': 'onEnter()' } }) + export class C { + @HostBinding('style.width.%') width = 50; + @HostListener('keyup.control.a', ['$event']) onSelectAll(e) {} + } + `, + linker, + ) + expect(code).toMatch(/ɵɵlistener\("keydown\.enter",\s*function C_keydown_enter_/) + expect(code).toMatch(/\("keyup\.control\.a",\s*function C_keyup_control_a_/) + expect(code).toMatch(/ɵɵstyleProp\("width",\s*ctx\.width,\s*"%"\)/) + }) +})