Skip to content

Commit 8738f85

Browse files
fix(compiler): quote object keys containing dots or hyphens in inputs/outputs (#155)
* fix(compiler): quote object keys containing dots or hyphens in inputs/outputs Angular's UNSAFE_OBJECT_KEY_NAME_REGEXP requires quoting property keys that contain `.` or `-` characters (e.g. `fxFlexAlign.xs`). Without quoting, these produce invalid JavaScript object literals. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(compiler): simplify output vector initialization and format key quoting --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5d357ca commit 8738f85

11 files changed

+280
-11
lines changed

crates/oxc_angular_compiler/src/directive/compiler.rs

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,13 @@ pub enum InputFlags {
424424
HasDecoratorInputTransform = 2, // 1 << 1
425425
}
426426

427+
/// Check if an object property key needs quoting because it contains unsafe characters.
428+
///
429+
/// Matches Angular's `UNSAFE_OBJECT_KEY_NAME_REGEXP = /[-.]/` from `render3/view/util.ts`.
430+
fn needs_object_key_quoting(key: &str) -> bool {
431+
key.contains('.') || key.contains('-')
432+
}
433+
427434
/// Creates the inputs literal map.
428435
///
429436
/// Ported from Angular's `conditionallyCreateDirectiveBindingLiteral` in `render3/view/util.ts`.
@@ -512,7 +519,8 @@ pub fn create_inputs_literal<'a>(
512519
))
513520
};
514521

515-
entries.push(LiteralMapEntry { key: declared_name.clone(), value, quoted: false });
522+
let quoted = needs_object_key_quoting(declared_name);
523+
entries.push(LiteralMapEntry { key: declared_name.clone(), value, quoted });
516524
}
517525

518526
Some(OutputExpression::LiteralMap(Box::new_in(
@@ -533,6 +541,7 @@ pub fn create_outputs_literal<'a>(
533541
let mut entries = Vec::new_in(allocator);
534542

535543
for (class_name, binding_name) in outputs {
544+
let quoted = needs_object_key_quoting(class_name);
536545
entries.push(LiteralMapEntry {
537546
key: class_name.clone(),
538547
value: OutputExpression::Literal(Box::new_in(
@@ -542,7 +551,7 @@ pub fn create_outputs_literal<'a>(
542551
},
543552
allocator,
544553
)),
545-
quoted: false,
554+
quoted,
546555
});
547556
}
548557

@@ -1549,4 +1558,97 @@ mod tests {
15491558
output
15501559
);
15511560
}
1561+
1562+
#[test]
1563+
fn test_create_inputs_literal_quotes_dotted_key() {
1564+
let allocator = Allocator::default();
1565+
let inputs = vec![R3InputMetadata {
1566+
class_property_name: Atom::from("fxFlexAlign.xs"),
1567+
binding_property_name: Atom::from("fxFlexAlign.xs"),
1568+
required: false,
1569+
is_signal: false,
1570+
transform_function: None,
1571+
}];
1572+
let expr = create_inputs_literal(&allocator, &inputs).unwrap();
1573+
let emitter = JsEmitter::new();
1574+
let output = emitter.emit_expression(&expr);
1575+
assert!(
1576+
output.contains(r#""fxFlexAlign.xs""#),
1577+
"Dotted key should be quoted. Got:\n{output}"
1578+
);
1579+
}
1580+
1581+
#[test]
1582+
fn test_create_inputs_literal_quotes_hyphenated_key() {
1583+
let allocator = Allocator::default();
1584+
let inputs = vec![R3InputMetadata {
1585+
class_property_name: Atom::from("fxFlexAlign.lt-sm"),
1586+
binding_property_name: Atom::from("fxFlexAlign.lt-sm"),
1587+
required: false,
1588+
is_signal: false,
1589+
transform_function: None,
1590+
}];
1591+
let expr = create_inputs_literal(&allocator, &inputs).unwrap();
1592+
let emitter = JsEmitter::new();
1593+
let output = emitter.emit_expression(&expr);
1594+
assert!(
1595+
output.contains(r#""fxFlexAlign.lt-sm""#),
1596+
"Hyphenated key should be quoted. Got:\n{output}"
1597+
);
1598+
}
1599+
1600+
#[test]
1601+
fn test_create_inputs_literal_no_quotes_for_simple_identifier() {
1602+
let allocator = Allocator::default();
1603+
let inputs = vec![R3InputMetadata {
1604+
class_property_name: Atom::from("fxFlexAlign"),
1605+
binding_property_name: Atom::from("fxFlexAlign"),
1606+
required: false,
1607+
is_signal: false,
1608+
transform_function: None,
1609+
}];
1610+
let expr = create_inputs_literal(&allocator, &inputs).unwrap();
1611+
let emitter = JsEmitter::new();
1612+
let output = emitter.emit_expression(&expr);
1613+
// Key should be bare (unquoted), followed by colon
1614+
assert!(
1615+
output.contains("fxFlexAlign:"),
1616+
"Simple identifier key should be bare. Got:\n{output}"
1617+
);
1618+
// Key should NOT be quoted — check that no quoted form appears before the colon
1619+
assert!(
1620+
!output.contains(r#""fxFlexAlign":"#),
1621+
"Simple identifier key should not be quoted. Got:\n{output}"
1622+
);
1623+
}
1624+
1625+
#[test]
1626+
fn test_create_outputs_literal_quotes_dotted_key() {
1627+
let allocator = Allocator::default();
1628+
let outputs = vec![(Atom::from("activate.xs"), Atom::from("activateXs"))];
1629+
let expr = create_outputs_literal(&allocator, &outputs).unwrap();
1630+
let emitter = JsEmitter::new();
1631+
let output = emitter.emit_expression(&expr);
1632+
assert!(
1633+
output.contains(r#""activate.xs""#),
1634+
"Dotted output key should be quoted. Got:\n{output}"
1635+
);
1636+
}
1637+
1638+
#[test]
1639+
fn test_create_outputs_literal_no_quotes_for_simple_identifier() {
1640+
let allocator = Allocator::default();
1641+
let outputs = vec![(Atom::from("activate"), Atom::from("activate"))];
1642+
let expr = create_outputs_literal(&allocator, &outputs).unwrap();
1643+
let emitter = JsEmitter::new();
1644+
let output = emitter.emit_expression(&expr);
1645+
assert!(
1646+
output.contains("activate:"),
1647+
"Simple identifier output key should be bare. Got:\n{output}"
1648+
);
1649+
assert!(
1650+
!output.contains(r#""activate":"#),
1651+
"Simple identifier output key should not be quoted. Got:\n{output}"
1652+
);
1653+
}
15521654
}

crates/oxc_angular_compiler/src/linker/mod.rs

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ use oxc_span::{GetSpan, SourceType};
4343
use crate::optimizer::Edit;
4444
use crate::pipeline::selector::{R3SelectorElement, parse_selector_to_r3_selector};
4545

46+
/// Check if an object property key needs quoting because it contains unsafe characters.
47+
///
48+
/// Matches Angular's `UNSAFE_OBJECT_KEY_NAME_REGEXP = /[-.]/` from `render3/view/util.ts`.
49+
fn needs_object_key_quoting(key: &str) -> bool {
50+
key.contains('.') || key.contains('-')
51+
}
52+
53+
/// Quote a property key if it contains unsafe characters (dots or hyphens).
54+
fn quote_key(key: &str) -> String {
55+
if needs_object_key_quoting(key) { format!("\"{key}\"") } else { key.to_string() }
56+
}
57+
4658
/// Partial declaration function names to link.
4759
const DECLARE_FACTORY: &str = "\u{0275}\u{0275}ngDeclareFactory";
4860
const DECLARE_INJECTABLE: &str = "\u{0275}\u{0275}ngDeclareInjectable";
@@ -1234,10 +1246,12 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source
12341246
}
12351247
};
12361248

1249+
let quoted_key = quote_key(&key);
1250+
12371251
match &p.value {
12381252
// Simple string: propertyName: "publicName" → keep as is
12391253
Expression::StringLiteral(lit) => {
1240-
entries.push(format!("{key}: \"{}\"", lit.value));
1254+
entries.push(format!("{quoted_key}: \"{}\"", lit.value));
12411255
}
12421256
// Array: check if it's declaration format [publicName, classPropertyName]
12431257
// and convert to definition format [InputFlags, publicName, classPropertyName]
@@ -1253,17 +1267,17 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source
12531267
// Convert to: [0, "publicName", "classPropertyName"]
12541268
let arr_source =
12551269
&source[arr.span.start as usize + 1..arr.span.end as usize - 1];
1256-
entries.push(format!("{key}: [0, {arr_source}]"));
1270+
entries.push(format!("{quoted_key}: [0, {arr_source}]"));
12571271
} else {
12581272
// Already in definition format or unknown, keep as is
12591273
let val =
12601274
&source[p.value.span().start as usize..p.value.span().end as usize];
1261-
entries.push(format!("{key}: {val}"));
1275+
entries.push(format!("{quoted_key}: {val}"));
12621276
}
12631277
} else {
12641278
// 3+ elements likely already in definition format, keep as is
12651279
let val = &source[p.value.span().start as usize..p.value.span().end as usize];
1266-
entries.push(format!("{key}: {val}"));
1280+
entries.push(format!("{quoted_key}: {val}"));
12671281
}
12681282
}
12691283
// Object: Angular 16+ format with classPropertyName, publicName, isRequired, etc.
@@ -1290,20 +1304,21 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source
12901304

12911305
if flags == 0 && transform.is_none() && public_name == declared_name {
12921306
// Simple case: no flags, no transform, names match
1293-
entries.push(format!("{key}: \"{public_name}\""));
1307+
entries.push(format!("{quoted_key}: \"{public_name}\""));
12941308
} else if let Some(transform_fn) = transform {
12951309
entries.push(format!(
1296-
"{key}: [{flags}, \"{public_name}\", \"{declared_name}\", {transform_fn}]"
1310+
"{quoted_key}: [{flags}, \"{public_name}\", \"{declared_name}\", {transform_fn}]"
12971311
));
12981312
} else {
1299-
entries
1300-
.push(format!("{key}: [{flags}, \"{public_name}\", \"{declared_name}\"]"));
1313+
entries.push(format!(
1314+
"{quoted_key}: [{flags}, \"{public_name}\", \"{declared_name}\"]"
1315+
));
13011316
}
13021317
}
13031318
// Unknown format, keep as is
13041319
_ => {
13051320
let val = &source[p.value.span().start as usize..p.value.span().end as usize];
1306-
entries.push(format!("{key}: {val}"));
1321+
entries.push(format!("{quoted_key}: {val}"));
13071322
}
13081323
}
13091324
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
//! Tests for Angular linker input/output key quoting.
2+
3+
use oxc_allocator::Allocator;
4+
use oxc_angular_compiler::linker::link;
5+
6+
/// Helper to build a ɵɵngDeclareDirective source with a given inputs block.
7+
fn make_directive_source(inputs_block: &str) -> String {
8+
format!(
9+
r#"import * as i0 from "@angular/core";
10+
export class MyDir {{}}
11+
MyDir.ɵdir = i0.ɵɵngDeclareDirective({{ minVersion: "14.0.0", version: "17.0.0", type: MyDir, selector: "[myDir]", inputs: {{ {inputs_block} }} }});"#
12+
)
13+
}
14+
15+
/// Helper to build a ɵɵngDeclareDirective source with a given outputs block.
16+
fn make_directive_source_with_outputs(outputs_block: &str) -> String {
17+
format!(
18+
r#"import * as i0 from "@angular/core";
19+
export class MyDir {{}}
20+
MyDir.ɵdir = i0.ɵɵngDeclareDirective({{ minVersion: "14.0.0", version: "17.0.0", type: MyDir, selector: "[myDir]", outputs: {{ {outputs_block} }} }});"#
21+
)
22+
}
23+
24+
#[test]
25+
fn test_link_inputs_dotted_key() {
26+
let allocator = Allocator::default();
27+
let code = make_directive_source(r#""fxFlexAlign.xs": "fxFlexAlignXs""#);
28+
let result = link(&allocator, &code, "test.mjs");
29+
insta::assert_snapshot!(result.code);
30+
}
31+
32+
#[test]
33+
fn test_link_inputs_hyphenated_key() {
34+
let allocator = Allocator::default();
35+
let code = make_directive_source(r#""fxFlexAlign.lt-sm": "fxFlexAlignLtSm""#);
36+
let result = link(&allocator, &code, "test.mjs");
37+
insta::assert_snapshot!(result.code);
38+
}
39+
40+
#[test]
41+
fn test_link_inputs_simple_identifier() {
42+
let allocator = Allocator::default();
43+
let code = make_directive_source(r#"fxFlexAlign: "fxFlexAlign""#);
44+
let result = link(&allocator, &code, "test.mjs");
45+
insta::assert_snapshot!(result.code);
46+
}
47+
48+
#[test]
49+
fn test_link_inputs_object_format_dotted_key() {
50+
let allocator = Allocator::default();
51+
let code = make_directive_source(
52+
r#""fxFlexAlign.xs": { classPropertyName: "fxFlexAlignXs", publicName: "fxFlexAlign.xs", isRequired: false, isSignal: false }"#,
53+
);
54+
let result = link(&allocator, &code, "test.mjs");
55+
insta::assert_snapshot!(result.code);
56+
}
57+
58+
#[test]
59+
fn test_link_inputs_array_format_dotted_key() {
60+
let allocator = Allocator::default();
61+
let code = make_directive_source(r#""fxFlexAlign.xs": ["fxFlexAlign.xs", "fxFlexAlignXs"]"#);
62+
let result = link(&allocator, &code, "test.mjs");
63+
insta::assert_snapshot!(result.code);
64+
}
65+
66+
#[test]
67+
fn test_link_outputs_dotted_key() {
68+
let allocator = Allocator::default();
69+
let code = make_directive_source_with_outputs(r#""activate.xs": "activateXs""#);
70+
let result = link(&allocator, &code, "test.mjs");
71+
insta::assert_snapshot!(result.code);
72+
}
73+
74+
#[test]
75+
fn test_link_outputs_hyphenated_key() {
76+
let allocator = Allocator::default();
77+
let code = make_directive_source_with_outputs(r#""activate.lt-sm": "activateLtSm""#);
78+
let result = link(&allocator, &code, "test.mjs");
79+
insta::assert_snapshot!(result.code);
80+
}
81+
82+
#[test]
83+
fn test_link_outputs_simple_identifier() {
84+
let allocator = Allocator::default();
85+
let code = make_directive_source_with_outputs(r#"activate: "activate""#);
86+
let result = link(&allocator, &code, "test.mjs");
87+
insta::assert_snapshot!(result.code);
88+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/linker_test.rs
3+
assertion_line: 54
4+
expression: result.code
5+
---
6+
import * as i0 from "@angular/core";
7+
export class MyDir {}
8+
MyDirdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], inputs: { "fxFlexAlign.xs": [0, "fxFlexAlign.xs", "fxFlexAlignXs"] }, standalone: false });
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/linker_test.rs
3+
assertion_line: 20
4+
expression: result.code
5+
---
6+
import * as i0 from "@angular/core";
7+
export class MyDir {}
8+
MyDirdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], inputs: { "fxFlexAlign.xs": "fxFlexAlignXs" }, standalone: false });
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/linker_test.rs
3+
assertion_line: 28
4+
expression: result.code
5+
---
6+
import * as i0 from "@angular/core";
7+
export class MyDir {}
8+
MyDirdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], inputs: { "fxFlexAlign.lt-sm": "fxFlexAlignLtSm" }, standalone: false });
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/linker_test.rs
3+
assertion_line: 46
4+
expression: result.code
5+
---
6+
import * as i0 from "@angular/core";
7+
export class MyDir {}
8+
MyDirdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], inputs: { "fxFlexAlign.xs": [0, "fxFlexAlign.xs", "fxFlexAlignXs"] }, standalone: false });
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/linker_test.rs
3+
assertion_line: 36
4+
expression: result.code
5+
---
6+
import * as i0 from "@angular/core";
7+
export class MyDir {}
8+
MyDirdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], inputs: { fxFlexAlign: "fxFlexAlign" }, standalone: false });
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/linker_test.rs
3+
assertion_line: 71
4+
expression: result.code
5+
---
6+
import * as i0 from "@angular/core";
7+
export class MyDir {}
8+
MyDirdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], outputs: { "activate.xs": "activateXs" }, standalone: false });
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/linker_test.rs
3+
assertion_line: 79
4+
expression: result.code
5+
---
6+
import * as i0 from "@angular/core";
7+
export class MyDir {}
8+
MyDirdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], outputs: { "activate.lt-sm": "activateLtSm" }, standalone: false });

0 commit comments

Comments
 (0)