Skip to content
Open
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
2 changes: 1 addition & 1 deletion crates/oxc_angular_compiler/src/directive/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
2 changes: 1 addition & 1 deletion crates/oxc_angular_compiler/src/directive/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 17 additions & 7 deletions crates/oxc_angular_compiler/src/partial/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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>,
Expand Down Expand Up @@ -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,
));
}
Expand All @@ -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,
));
Expand Down
20 changes: 14 additions & 6 deletions crates/oxc_angular_compiler/src/partial/directive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
));
}
Expand All @@ -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,
));
Expand Down
25 changes: 15 additions & 10 deletions crates/oxc_angular_compiler/tests/partial_directive_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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}"
);
}

Expand Down
136 changes: 136 additions & 0 deletions napi/angular-compiler/test/partial-host-bindings.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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*"%"\)/)
})
})