Skip to content

Commit d95c236

Browse files
Brooooooklynclaude
andauthored
fix: directive compiler uses flat array format for hostDirectives input/output mappings (#77)
* fix: directive compiler uses flat array format for hostDirectives input/output mappings The directive compiler was using `create_string_map()` which generated object format `{publicName: "internalName"}` for host directive mappings. Angular's runtime expects flat array format `["publicName", "internalName"]`. Replaced with `create_host_directive_mappings_array()` matching the component compiler's implementation. - Fix #67 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: deduplicate create_host_directive_mappings_array across directive/component Mirrors Angular's structure where `createHostDirectivesMappingArray` is defined once in `view/compiler.ts` and imported by `partial/directive.ts`. The shared function now lives in `directive::compiler` (pub(crate)) and is imported by `component::definition`, with the `with_capacity_in` optimization. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 61a540b commit d95c236

File tree

4 files changed

+238
-47
lines changed

4 files changed

+238
-47
lines changed

crates/oxc_angular_compiler/src/component/definition.rs

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ use super::metadata::{
2323
ViewEncapsulation,
2424
};
2525
use super::namespace_registry::NamespaceRegistry;
26-
use crate::directive::{create_inputs_literal, create_outputs_literal};
26+
use crate::directive::{
27+
create_host_directive_mappings_array, create_inputs_literal, create_outputs_literal,
28+
};
2729
use crate::output::ast::{
2830
FnParam, FunctionExpr, InstantiateExpr, InvokeFunctionExpr, LiteralArrayExpr, LiteralExpr,
2931
LiteralMapEntry, LiteralMapExpr, LiteralValue, OutputExpression, OutputStatement, ReadPropExpr,
@@ -1256,33 +1258,6 @@ fn create_host_directives_arg<'a>(
12561258
}
12571259
}
12581260

1259-
/// Create a host directive mappings array.
1260-
///
1261-
/// Format: ['publicName', 'internalName', 'publicName2', 'internalName2']
1262-
fn create_host_directive_mappings_array<'a>(
1263-
allocator: &'a Allocator,
1264-
mappings: &[(Atom<'a>, Atom<'a>)],
1265-
) -> OutputExpression<'a> {
1266-
let mut entries: OxcVec<'a, OutputExpression<'a>> =
1267-
OxcVec::with_capacity_in(mappings.len() * 2, allocator);
1268-
1269-
for (public_name, internal_name) in mappings {
1270-
entries.push(OutputExpression::Literal(Box::new_in(
1271-
LiteralExpr { value: LiteralValue::String(public_name.clone()), source_span: None },
1272-
allocator,
1273-
)));
1274-
entries.push(OutputExpression::Literal(Box::new_in(
1275-
LiteralExpr { value: LiteralValue::String(internal_name.clone()), source_span: None },
1276-
allocator,
1277-
)));
1278-
}
1279-
1280-
OutputExpression::LiteralArray(Box::new_in(
1281-
LiteralArrayExpr { entries, source_span: None },
1282-
allocator,
1283-
))
1284-
}
1285-
12861261
/// Generate `ɵɵExternalStylesFeature(['style.css', ...])` expression.
12871262
///
12881263
/// See: packages/compiler/src/render3/view/compiler.ts:150-155

crates/oxc_angular_compiler/src/directive/compiler.rs

Lines changed: 147 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -883,20 +883,20 @@ fn create_host_directives_feature_arg<'a>(
883883

884884
// inputs (if any)
885885
if !hd.inputs.is_empty() {
886-
let inputs_map = create_string_map(allocator, &hd.inputs);
886+
let inputs_array = create_host_directive_mappings_array(allocator, &hd.inputs);
887887
entries.push(LiteralMapEntry {
888888
key: Atom::from("inputs"),
889-
value: inputs_map,
889+
value: inputs_array,
890890
quoted: false,
891891
});
892892
}
893893

894894
// outputs (if any)
895895
if !hd.outputs.is_empty() {
896-
let outputs_map = create_string_map(allocator, &hd.outputs);
896+
let outputs_array = create_host_directive_mappings_array(allocator, &hd.outputs);
897897
entries.push(LiteralMapEntry {
898898
key: Atom::from("outputs"),
899-
value: outputs_map,
899+
value: outputs_array,
900900
quoted: false,
901901
});
902902
}
@@ -913,26 +913,31 @@ fn create_host_directives_feature_arg<'a>(
913913
))
914914
}
915915

916-
/// Creates a string-to-string map expression.
917-
fn create_string_map<'a>(
916+
/// Creates a host directive mappings array.
917+
///
918+
/// Format: `['publicName', 'internalName', 'publicName2', 'internalName2']`
919+
///
920+
/// Shared between directive and component compilers, mirroring Angular's
921+
/// `createHostDirectivesMappingArray` in `view/compiler.ts`.
922+
pub(crate) fn create_host_directive_mappings_array<'a>(
918923
allocator: &'a Allocator,
919-
pairs: &[(Atom<'a>, Atom<'a>)],
924+
mappings: &[(Atom<'a>, Atom<'a>)],
920925
) -> OutputExpression<'a> {
921-
let mut entries = Vec::new_in(allocator);
926+
let mut entries = Vec::with_capacity_in(mappings.len() * 2, allocator);
922927

923-
for (key, value) in pairs {
924-
entries.push(LiteralMapEntry {
925-
key: key.clone(),
926-
value: OutputExpression::Literal(Box::new_in(
927-
LiteralExpr { value: LiteralValue::String(value.clone()), source_span: None },
928-
allocator,
929-
)),
930-
quoted: false,
931-
});
928+
for (public_name, internal_name) in mappings {
929+
entries.push(OutputExpression::Literal(Box::new_in(
930+
LiteralExpr { value: LiteralValue::String(public_name.clone()), source_span: None },
931+
allocator,
932+
)));
933+
entries.push(OutputExpression::Literal(Box::new_in(
934+
LiteralExpr { value: LiteralValue::String(internal_name.clone()), source_span: None },
935+
allocator,
936+
)));
932937
}
933938

934-
OutputExpression::LiteralMap(Box::new_in(
935-
LiteralMapExpr { entries, source_span: None },
939+
OutputExpression::LiteralArray(Box::new_in(
940+
LiteralArrayExpr { entries, source_span: None },
936941
allocator,
937942
))
938943
}
@@ -1419,4 +1424,127 @@ mod tests {
14191424
output
14201425
);
14211426
}
1427+
1428+
#[test]
1429+
fn test_host_directives_input_output_mappings_use_flat_array() {
1430+
// Issue #67: hostDirectives input/output mappings must be flat arrays
1431+
// ["publicName", "internalName"], NOT objects {publicName: "internalName"}
1432+
let allocator = Allocator::default();
1433+
let type_expr = OutputExpression::ReadVar(Box::new_in(
1434+
ReadVarExpr { name: Atom::from("TooltipTrigger"), source_span: None },
1435+
&allocator,
1436+
));
1437+
1438+
let directive_expr = OutputExpression::ReadVar(Box::new_in(
1439+
ReadVarExpr { name: Atom::from("BrnTooltipTrigger"), source_span: None },
1440+
&allocator,
1441+
));
1442+
1443+
let mut host_directive_inputs = Vec::new_in(&allocator);
1444+
host_directive_inputs.push((Atom::from("uTooltip"), Atom::from("brnTooltipTrigger")));
1445+
1446+
let mut host_directives = Vec::new_in(&allocator);
1447+
host_directives.push(R3HostDirectiveMetadata {
1448+
directive: directive_expr,
1449+
is_forward_reference: false,
1450+
inputs: host_directive_inputs,
1451+
outputs: Vec::new_in(&allocator),
1452+
});
1453+
1454+
let metadata = R3DirectiveMetadata {
1455+
name: Atom::from("TooltipTrigger"),
1456+
r#type: type_expr,
1457+
type_argument_count: 0,
1458+
deps: None,
1459+
selector: Some(Atom::from("[uTooltip]")),
1460+
queries: Vec::new_in(&allocator),
1461+
view_queries: Vec::new_in(&allocator),
1462+
host: R3HostMetadata::new(&allocator),
1463+
uses_on_changes: false,
1464+
inputs: Vec::new_in(&allocator),
1465+
outputs: Vec::new_in(&allocator),
1466+
uses_inheritance: false,
1467+
export_as: Vec::new_in(&allocator),
1468+
providers: None,
1469+
is_standalone: true,
1470+
is_signal: false,
1471+
host_directives,
1472+
};
1473+
1474+
let result = compile_directive(&allocator, &metadata, 0);
1475+
let emitter = JsEmitter::new();
1476+
let output = emitter.emit_expression(&result.expression);
1477+
let normalized = output.replace([' ', '\n', '\t'], "");
1478+
1479+
// Must contain flat array format: inputs:["uTooltip","brnTooltipTrigger"]
1480+
assert!(
1481+
normalized.contains(r#"inputs:["uTooltip","brnTooltipTrigger"]"#),
1482+
"Host directive inputs should be flat array [\"publicName\",\"internalName\"], not object. Got:\n{}",
1483+
output
1484+
);
1485+
// Must NOT contain object format: inputs:{uTooltip:"brnTooltipTrigger"}
1486+
assert!(
1487+
!normalized.contains(r#"inputs:{uTooltip:"brnTooltipTrigger"}"#),
1488+
"Host directive inputs should NOT be object format. Got:\n{}",
1489+
output
1490+
);
1491+
}
1492+
1493+
#[test]
1494+
fn test_host_directives_output_mappings_use_flat_array() {
1495+
// Issue #67: output mappings must also be flat arrays
1496+
let allocator = Allocator::default();
1497+
let type_expr = OutputExpression::ReadVar(Box::new_in(
1498+
ReadVarExpr { name: Atom::from("MyDirective"), source_span: None },
1499+
&allocator,
1500+
));
1501+
1502+
let directive_expr = OutputExpression::ReadVar(Box::new_in(
1503+
ReadVarExpr { name: Atom::from("ClickTracker"), source_span: None },
1504+
&allocator,
1505+
));
1506+
1507+
let mut host_directive_outputs = Vec::new_in(&allocator);
1508+
host_directive_outputs.push((Atom::from("clicked"), Atom::from("trackClick")));
1509+
1510+
let mut host_directives = Vec::new_in(&allocator);
1511+
host_directives.push(R3HostDirectiveMetadata {
1512+
directive: directive_expr,
1513+
is_forward_reference: false,
1514+
inputs: Vec::new_in(&allocator),
1515+
outputs: host_directive_outputs,
1516+
});
1517+
1518+
let metadata = R3DirectiveMetadata {
1519+
name: Atom::from("MyDirective"),
1520+
r#type: type_expr,
1521+
type_argument_count: 0,
1522+
deps: None,
1523+
selector: Some(Atom::from("[myDir]")),
1524+
queries: Vec::new_in(&allocator),
1525+
view_queries: Vec::new_in(&allocator),
1526+
host: R3HostMetadata::new(&allocator),
1527+
uses_on_changes: false,
1528+
inputs: Vec::new_in(&allocator),
1529+
outputs: Vec::new_in(&allocator),
1530+
uses_inheritance: false,
1531+
export_as: Vec::new_in(&allocator),
1532+
providers: None,
1533+
is_standalone: true,
1534+
is_signal: false,
1535+
host_directives,
1536+
};
1537+
1538+
let result = compile_directive(&allocator, &metadata, 0);
1539+
let emitter = JsEmitter::new();
1540+
let output = emitter.emit_expression(&result.expression);
1541+
let normalized = output.replace([' ', '\n', '\t'], "");
1542+
1543+
// Must contain flat array format: outputs:["clicked","trackClick"]
1544+
assert!(
1545+
normalized.contains(r#"outputs:["clicked","trackClick"]"#),
1546+
"Host directive outputs should be flat array. Got:\n{}",
1547+
output
1548+
);
1549+
}
14221550
}

crates/oxc_angular_compiler/src/directive/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ mod metadata;
1919
mod property_decorators;
2020
mod query;
2121

22+
pub(crate) use compiler::create_host_directive_mappings_array;
2223
pub use compiler::{
2324
DirectiveCompileResult, compile_directive, compile_directive_from_metadata,
2425
create_inputs_literal, create_outputs_literal,

napi/angular-compiler/e2e/compare/fixtures/host-directives/basic.fixture.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,4 +801,91 @@ export class ForwardAllComponent {}
801801
`.trim(),
802802
expectedFeatures: ['ɵɵdefineComponent', 'ɵɵHostDirectivesFeature'],
803803
},
804+
805+
// ==========================================================================
806+
// Host Directives on Directives (not just Components)
807+
// ==========================================================================
808+
809+
{
810+
type: 'full-transform',
811+
name: 'directive-host-directive-input-mapping',
812+
category: 'host-directives',
813+
description: 'Directive with host directive input mappings (issue #67)',
814+
className: 'UnityTooltipTrigger',
815+
sourceCode: `
816+
import { Directive, Input } from '@angular/core';
817+
818+
@Directive({ selector: '[brnTooltipTrigger]', standalone: true })
819+
export class BrnTooltipTrigger {
820+
@Input() brnTooltipTrigger: string = '';
821+
}
822+
823+
@Directive({
824+
selector: '[uTooltip]',
825+
standalone: true,
826+
hostDirectives: [{
827+
directive: BrnTooltipTrigger,
828+
inputs: ['brnTooltipTrigger: uTooltip']
829+
}]
830+
})
831+
export class UnityTooltipTrigger {}
832+
`.trim(),
833+
expectedFeatures: ['ɵɵdefineDirective', 'ɵɵHostDirectivesFeature'],
834+
},
835+
836+
{
837+
type: 'full-transform',
838+
name: 'directive-host-directive-output-mapping',
839+
category: 'host-directives',
840+
description: 'Directive with host directive output mappings',
841+
className: 'MyWrapperDirective',
842+
sourceCode: `
843+
import { Directive, Output, EventEmitter } from '@angular/core';
844+
845+
@Directive({ selector: '[trackable]', standalone: true })
846+
export class TrackableDirective {
847+
@Output() trackClick = new EventEmitter<void>();
848+
}
849+
850+
@Directive({
851+
selector: '[myWrapper]',
852+
standalone: true,
853+
hostDirectives: [{
854+
directive: TrackableDirective,
855+
outputs: ['trackClick: clicked']
856+
}]
857+
})
858+
export class MyWrapperDirective {}
859+
`.trim(),
860+
expectedFeatures: ['ɵɵdefineDirective', 'ɵɵHostDirectivesFeature'],
861+
},
862+
863+
{
864+
type: 'full-transform',
865+
name: 'directive-host-directive-input-output-mapping',
866+
category: 'host-directives',
867+
description: 'Directive with both input and output host directive mappings',
868+
className: 'EnhancedDirective',
869+
sourceCode: `
870+
import { Directive, Input, Output, EventEmitter } from '@angular/core';
871+
872+
@Directive({ selector: '[base]', standalone: true })
873+
export class BaseDirective {
874+
@Input() baseValue: string = '';
875+
@Output() baseChange = new EventEmitter<string>();
876+
}
877+
878+
@Directive({
879+
selector: '[enhanced]',
880+
standalone: true,
881+
hostDirectives: [{
882+
directive: BaseDirective,
883+
inputs: ['baseValue: value'],
884+
outputs: ['baseChange: valueChange']
885+
}]
886+
})
887+
export class EnhancedDirective {}
888+
`.trim(),
889+
expectedFeatures: ['ɵɵdefineDirective', 'ɵɵHostDirectivesFeature'],
890+
},
804891
]

0 commit comments

Comments
 (0)