Skip to content

Commit da9f6d6

Browse files
Brooooooklynclaude
andcommitted
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>
1 parent 61a540b commit da9f6d6

File tree

2 files changed

+230
-18
lines changed

2 files changed

+230
-18
lines changed

crates/oxc_angular_compiler/src/directive/compiler.rs

Lines changed: 143 additions & 18 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,28 @@ 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+
fn create_host_directive_mappings_array<'a>(
918920
allocator: &'a Allocator,
919-
pairs: &[(Atom<'a>, Atom<'a>)],
921+
mappings: &[(Atom<'a>, Atom<'a>)],
920922
) -> OutputExpression<'a> {
921923
let mut entries = Vec::new_in(allocator);
922924

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-
});
925+
for (public_name, internal_name) in mappings {
926+
entries.push(OutputExpression::Literal(Box::new_in(
927+
LiteralExpr { value: LiteralValue::String(public_name.clone()), source_span: None },
928+
allocator,
929+
)));
930+
entries.push(OutputExpression::Literal(Box::new_in(
931+
LiteralExpr { value: LiteralValue::String(internal_name.clone()), source_span: None },
932+
allocator,
933+
)));
932934
}
933935

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

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)