From c82d09a7fa93b170e351240955b4b2b33ff7867a Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Thu, 19 Mar 2026 10:14:41 +0000 Subject: [PATCH] fix(compiler): correct host directive mapping array order to [internalName, publicName] The mapping array in `create_host_directive_mappings_array` was emitting [publicName, internalName] but Angular's `createHostDirectivesMappingArray` expects [internalName, publicName]. This fixes the ordering and adds integration tests for host directives with aliased inputs/outputs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/component/definition.rs | 4 +- .../src/directive/compiler.rs | 20 +-- .../tests/integration_test.rs | 128 ++++++++++++++++++ ...st__host_directives_with_host_aliases.snap | 37 +++++ ...__host_directives_with_inputs_outputs.snap | 36 +++++ 5 files changed, 214 insertions(+), 11 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_host_aliases.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_inputs_outputs.snap diff --git a/crates/oxc_angular_compiler/src/component/definition.rs b/crates/oxc_angular_compiler/src/component/definition.rs index b84db57da..ff061e2b8 100644 --- a/crates/oxc_angular_compiler/src/component/definition.rs +++ b/crates/oxc_angular_compiler/src/component/definition.rs @@ -1201,7 +1201,7 @@ fn create_host_directives_arg<'a>( quoted: false, }); - // inputs: ['publicName', 'internalName', ...] + // inputs: ['internalName', 'publicName', ...] if !directive.inputs.is_empty() { let inputs_array = create_host_directive_mappings_array(allocator, &directive.inputs); @@ -1212,7 +1212,7 @@ fn create_host_directives_arg<'a>( }); } - // outputs: ['publicName', 'internalName', ...] + // outputs: ['internalName', 'publicName', ...] if !directive.outputs.is_empty() { let outputs_array = create_host_directive_mappings_array(allocator, &directive.outputs); diff --git a/crates/oxc_angular_compiler/src/directive/compiler.rs b/crates/oxc_angular_compiler/src/directive/compiler.rs index 960b31fce..c09870af2 100644 --- a/crates/oxc_angular_compiler/src/directive/compiler.rs +++ b/crates/oxc_angular_compiler/src/directive/compiler.rs @@ -915,7 +915,7 @@ fn create_host_directives_feature_arg<'a>( /// Creates a host directive mappings array. /// -/// Format: `['publicName', 'internalName', 'publicName2', 'internalName2']` +/// Format: `['internalName', 'publicName', 'internalName2', 'publicName2']` /// /// Shared between directive and component compilers, mirroring Angular's /// `createHostDirectivesMappingArray` in `view/compiler.ts`. @@ -927,11 +927,11 @@ pub(crate) fn create_host_directive_mappings_array<'a>( for (public_name, internal_name) in mappings { entries.push(OutputExpression::Literal(Box::new_in( - LiteralExpr { value: LiteralValue::String(public_name.clone()), source_span: None }, + LiteralExpr { value: LiteralValue::String(internal_name.clone()), source_span: None }, allocator, ))); entries.push(OutputExpression::Literal(Box::new_in( - LiteralExpr { value: LiteralValue::String(internal_name.clone()), source_span: None }, + LiteralExpr { value: LiteralValue::String(public_name.clone()), source_span: None }, allocator, ))); } @@ -1476,10 +1476,11 @@ mod tests { let output = emitter.emit_expression(&result.expression); let normalized = output.replace([' ', '\n', '\t'], ""); - // Must contain flat array format: inputs:["uTooltip","brnTooltipTrigger"] + // Must contain flat array format: inputs:["brnTooltipTrigger","uTooltip"] + // (internalName first, then publicName — matching Angular's createHostDirectivesMappingArray) assert!( - normalized.contains(r#"inputs:["uTooltip","brnTooltipTrigger"]"#), - "Host directive inputs should be flat array [\"publicName\",\"internalName\"], not object. Got:\n{}", + normalized.contains(r#"inputs:["brnTooltipTrigger","uTooltip"]"#), + "Host directive inputs should be flat array [\"internalName\",\"publicName\"]. Got:\n{}", output ); // Must NOT contain object format: inputs:{uTooltip:"brnTooltipTrigger"} @@ -1540,10 +1541,11 @@ mod tests { let output = emitter.emit_expression(&result.expression); let normalized = output.replace([' ', '\n', '\t'], ""); - // Must contain flat array format: outputs:["clicked","trackClick"] + // Must contain flat array format: outputs:["trackClick","clicked"] + // (internalName first, then publicName — matching Angular's createHostDirectivesMappingArray) assert!( - normalized.contains(r#"outputs:["clicked","trackClick"]"#), - "Host directive outputs should be flat array. Got:\n{}", + normalized.contains(r#"outputs:["trackClick","clicked"]"#), + "Host directive outputs should be flat array [\"internalName\",\"publicName\"]. Got:\n{}", output ); } diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 9fcaebe71..690930336 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -7833,3 +7833,131 @@ fn test_property_singleton_interpolation_with_sanitizer_angular_v19() { assert!(js.contains("ɵɵsanitizeUrl"), "Should include ɵɵsanitizeUrl sanitizer. Got:\n{js}"); insta::assert_snapshot!("property_singleton_interpolation_with_sanitizer_v19", js); } + +// ============================================================================ +// Host Directive Alias Tests +// ============================================================================ + +/// Test host directives with simple aliased inputs/outputs. +/// +/// Mirrors the compliance test `host_directives_with_inputs_outputs.ts`. +/// The mapping array must use `[internalName, publicName]` ordering. +#[test] +fn test_host_directives_with_inputs_outputs() { + let allocator = Allocator::default(); + let source = r#" +import { Component, Directive, EventEmitter, Input, Output } from '@angular/core'; + +@Directive({}) +export class HostDir { + @Input() value = 0; + @Input() color = ''; + @Output() opened = new EventEmitter(); + @Output() closed = new EventEmitter(); +} + +@Component({ + selector: 'my-component', + template: '', + hostDirectives: [{ + directive: HostDir, + inputs: ['value', 'color: colorAlias'], + outputs: ['opened', 'closed: closedAlias'], + }], + standalone: false, +}) +export class MyComponent { +} +"#; + + let result = transform_angular_file( + &allocator, + "test.component.ts", + source, + &ComponentTransformOptions::default(), + None, + ); + + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + let normalized = result.code.replace([' ', '\n', '\t'], ""); + + // Input mappings: 'value' (no alias) → ["value", "value"], 'color: colorAlias' → ["color", "colorAlias"] + // The array must be [internalName, publicName, ...] i.e. ["value", "value", "color", "colorAlias"] + assert!( + normalized.contains(r#"inputs:["value","value","color","colorAlias"]"#), + "Input mappings should be [internalName, publicName]. Got:\n{}", + result.code + ); + + // Output mappings: 'opened' → ["opened", "opened"], 'closed: closedAlias' → ["closed", "closedAlias"] + assert!( + normalized.contains(r#"outputs:["opened","opened","closed","closedAlias"]"#), + "Output mappings should be [internalName, publicName]. Got:\n{}", + result.code + ); + + insta::assert_snapshot!("host_directives_with_inputs_outputs", result.code); +} + +/// Test host directives where the directive has `@Input('alias')` and the host re-aliases. +/// +/// Mirrors the compliance test `host_directives_with_host_aliases.ts`. +#[test] +fn test_host_directives_with_host_aliases() { + let allocator = Allocator::default(); + let source = r#" +import { Component, Directive, EventEmitter, Input, Output } from '@angular/core'; + +@Directive({}) +export class HostDir { + @Input('valueAlias') value = 1; + @Input('colorAlias') color = ''; + @Output('openedAlias') opened = new EventEmitter(); + @Output('closedAlias') closed = new EventEmitter(); +} + +@Component({ + selector: 'my-component', + template: '', + hostDirectives: [{ + directive: HostDir, + inputs: ['valueAlias', 'colorAlias: customColorAlias'], + outputs: ['openedAlias', 'closedAlias: customClosedAlias'], + }], + standalone: false, +}) +export class MyComponent { +} +"#; + + let result = transform_angular_file( + &allocator, + "test.component.ts", + source, + &ComponentTransformOptions::default(), + None, + ); + + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + let normalized = result.code.replace([' ', '\n', '\t'], ""); + + // Input mappings: 'valueAlias' → ["valueAlias", "valueAlias"], 'colorAlias: customColorAlias' → ["colorAlias", "customColorAlias"] + assert!( + normalized + .contains(r#"inputs:["valueAlias","valueAlias","colorAlias","customColorAlias"]"#), + "Input mappings should be [internalName, publicName]. Got:\n{}", + result.code + ); + + // Output mappings: 'openedAlias' → ["openedAlias", "openedAlias"], 'closedAlias: customClosedAlias' → ["closedAlias", "customClosedAlias"] + assert!( + normalized + .contains(r#"outputs:["openedAlias","openedAlias","closedAlias","customClosedAlias"]"#), + "Output mappings should be [internalName, publicName]. Got:\n{}", + result.code + ); + + insta::assert_snapshot!("host_directives_with_host_aliases", result.code); +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_host_aliases.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_host_aliases.snap new file mode 100644 index 000000000..a48a2520a --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_host_aliases.snap @@ -0,0 +1,37 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: result.code +--- + +import { Component, Directive, EventEmitter, Input, Output } from '@angular/core'; +import * as i0 from '@angular/core'; + +export class HostDir { + value = 1; + color = ''; + opened = new EventEmitter(); + closed = new EventEmitter(); + +static ɵfac = function HostDir_Factory(__ngFactoryType__) { + return new (__ngFactoryType__ || HostDir)(); +}; +static ɵdir = /*@__PURE__*/ i0.ɵɵdefineDirective({type:HostDir,inputs:{value:[0,"valueAlias","value"], + color:[0,"colorAlias","color"]},outputs:{opened:"openedAlias",closed:"closedAlias"}}); +} + +export class MyComponent { + +static ɵfac = function MyComponent_Factory(__ngFactoryType__) { + return new (__ngFactoryType__ || MyComponent)(); +}; +static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:MyComponent,selectors:[["my-component"]], + standalone:false,features:[i0.ɵɵHostDirectivesFeature([{directive:HostDir,inputs:["valueAlias", + "valueAlias","colorAlias","customColorAlias"],outputs:["openedAlias","openedAlias", + "closedAlias","customClosedAlias"]}])],decls:0,vars:0,template:function MyComponent_Template(rf, + ctx) { + },dependencies:i0.ɵɵgetComponentDepsFactory(MyComponent),encapsulation:2}); +} +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(MyComponent, + {className:"MyComponent",filePath:"test.component.ts",lineNumber:1})); +})(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_inputs_outputs.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_inputs_outputs.snap new file mode 100644 index 000000000..90d2a80d7 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_inputs_outputs.snap @@ -0,0 +1,36 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: result.code +--- + +import { Component, Directive, EventEmitter, Input, Output } from '@angular/core'; +import * as i0 from '@angular/core'; + +export class HostDir { + value = 0; + color = ''; + opened = new EventEmitter(); + closed = new EventEmitter(); + +static ɵfac = function HostDir_Factory(__ngFactoryType__) { + return new (__ngFactoryType__ || HostDir)(); +}; +static ɵdir = /*@__PURE__*/ i0.ɵɵdefineDirective({type:HostDir,inputs:{value:"value",color:"color"}, + outputs:{opened:"opened",closed:"closed"}}); +} + +export class MyComponent { + +static ɵfac = function MyComponent_Factory(__ngFactoryType__) { + return new (__ngFactoryType__ || MyComponent)(); +}; +static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:MyComponent,selectors:[["my-component"]], + standalone:false,features:[i0.ɵɵHostDirectivesFeature([{directive:HostDir,inputs:["value", + "value","color","colorAlias"],outputs:["opened","opened","closed","closedAlias"]}])], + decls:0,vars:0,template:function MyComponent_Template(rf,ctx) { + },dependencies:i0.ɵɵgetComponentDepsFactory(MyComponent),encapsulation:2}); +} +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(MyComponent, + {className:"MyComponent",filePath:"test.component.ts",lineNumber:1})); +})();