Skip to content
Merged
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
31 changes: 20 additions & 11 deletions crates/oxc_angular_compiler/src/transform/html_to_r3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -468,18 +468,27 @@ impl<'a> HtmlToR3Transform<'a> {
let selector = self.get_ng_content_selector(element);
self.ng_content_selectors.push(selector.clone());

// For ng-content, include the structural directive attribute (*ngIf, etc.)
// as a text attribute. This is needed because the projection instruction
// includes these attributes in its output (e.g., ["*ngIf", "!subtitle()"]).
// Reference: r3_template_transform.ts line 193 - all attrs are included
let mut content_attributes = attributes;
if let Some(ref tpl_attr) = template_attr {
// For ng-content, Angular converts ALL raw HTML attributes to TextAttributes.
// Reference: r3_template_transform.ts line 193:
// const attrs: t.TextAttribute[] = element.attrs.map((attr) => this.visitAttribute(attr));
// This includes bound attributes like [select]="..." which get serialized with
// their raw names (e.g., "[select]") and values into the projection instruction.
//
// However, i18n/i18n-* attributes are excluded because Angular's I18nMetaVisitor
// strips them from element.attrs before r3_template_transform runs.
let mut content_attributes: Vec<'a, R3TextAttribute<'a>> =
Vec::with_capacity_in(element.attrs.len(), self.allocator);
for attr in &element.attrs {
let name = attr.name.as_str();
if name == "i18n" || name.starts_with("i18n-") {
continue;
}
content_attributes.push(R3TextAttribute {
name: tpl_attr.name.clone(),
value: tpl_attr.value.clone(),
source_span: tpl_attr.span,
key_span: Some(tpl_attr.name_span),
value_span: tpl_attr.value_span,
name: attr.name.clone(),
value: attr.value.clone(),
source_span: attr.span,
key_span: Some(attr.name_span),
value_span: attr.value_span,
Comment thread
cursor[bot] marked this conversation as resolved.
i18n: None,
});
}
Expand Down
30 changes: 30 additions & 0 deletions crates/oxc_angular_compiler/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,36 @@ fn test_ng_content_select() {
insta::assert_snapshot!("ng_content_select", js);
}

#[test]
fn test_ng_content_i18n_attr_not_in_projection() {
// Verify i18n/i18n-* attrs are NOT included in ng-content projection attributes.
// Angular's I18nMetaVisitor strips these before r3_template_transform runs.
let js = compile_template_to_js(
r#"<ng-content i18n select=".header"></ng-content>"#,
"TestComponent",
);
assert!(
!js.contains(r#""i18n""#),
"i18n attribute should not appear in projection output. Got:\n{js}"
);
}

#[test]
fn test_ng_content_with_bound_select() {
// Tests that [select] binding on ng-content passes the binding name and value
// as attributes to the projection instruction.
// Angular treats ALL raw attrs on ng-content as TextAttributes, including bindings.
// [select] with brackets is NOT the same as the static `select` attribute for the
// CSS selector — the selector stays as "*" (wildcard).
// Expected: ɵɵprojectionDef() with no args (single wildcard),
// ɵɵprojection(0, 0, ["[select]", "'[slot=expanded-content]'"])
let js = compile_template_to_js(
r#"<ng-content [select]="'[slot=expanded-content]'" />"#,
"TestComponent",
);
insta::assert_snapshot!("ng_content_with_bound_select", js);
}

#[test]
fn test_ng_content_with_ng_project_as() {
// Tests that ngProjectAs attribute generates the correct ProjectAs marker (5)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
source: crates/oxc_angular_compiler/tests/integration_test.rs
expression: js
---
const _c0 = ["*"];
function TestComponent_Template(rf,ctx) {
if ((rf & 1)) {
i0.ɵɵprojectionDef();
i0.ɵɵprojection(0,0,["[select]","'[slot=expanded-content]'"]);
}
}
Loading