Skip to content

Commit 21903be

Browse files
Brooooooklynclaude
andauthored
fix: ng-content with bound attributes like [select] now passes attrs to projection instruction (#22)
Angular's r3_template_transform.ts converts ALL raw HTML attributes on ng-content to TextAttributes, including binding syntax like [select]="...". OXC was only using categorized text attributes, dropping bound attributes entirely. Exclude i18n/i18n-* attributes since Angular's I18nMetaVisitor strips these from element.attrs before r3_template_transform runs. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent aabfc01 commit 21903be

File tree

3 files changed

+61
-11
lines changed

3 files changed

+61
-11
lines changed

crates/oxc_angular_compiler/src/transform/html_to_r3.rs

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -468,18 +468,27 @@ impl<'a> HtmlToR3Transform<'a> {
468468
let selector = self.get_ng_content_selector(element);
469469
self.ng_content_selectors.push(selector.clone());
470470

471-
// For ng-content, include the structural directive attribute (*ngIf, etc.)
472-
// as a text attribute. This is needed because the projection instruction
473-
// includes these attributes in its output (e.g., ["*ngIf", "!subtitle()"]).
474-
// Reference: r3_template_transform.ts line 193 - all attrs are included
475-
let mut content_attributes = attributes;
476-
if let Some(ref tpl_attr) = template_attr {
471+
// For ng-content, Angular converts ALL raw HTML attributes to TextAttributes.
472+
// Reference: r3_template_transform.ts line 193:
473+
// const attrs: t.TextAttribute[] = element.attrs.map((attr) => this.visitAttribute(attr));
474+
// This includes bound attributes like [select]="..." which get serialized with
475+
// their raw names (e.g., "[select]") and values into the projection instruction.
476+
//
477+
// However, i18n/i18n-* attributes are excluded because Angular's I18nMetaVisitor
478+
// strips them from element.attrs before r3_template_transform runs.
479+
let mut content_attributes: Vec<'a, R3TextAttribute<'a>> =
480+
Vec::with_capacity_in(element.attrs.len(), self.allocator);
481+
for attr in &element.attrs {
482+
let name = attr.name.as_str();
483+
if name == "i18n" || name.starts_with("i18n-") {
484+
continue;
485+
}
477486
content_attributes.push(R3TextAttribute {
478-
name: tpl_attr.name.clone(),
479-
value: tpl_attr.value.clone(),
480-
source_span: tpl_attr.span,
481-
key_span: Some(tpl_attr.name_span),
482-
value_span: tpl_attr.value_span,
487+
name: attr.name.clone(),
488+
value: attr.value.clone(),
489+
source_span: attr.span,
490+
key_span: Some(attr.name_span),
491+
value_span: attr.value_span,
483492
i18n: None,
484493
});
485494
}

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,36 @@ fn test_ng_content_select() {
756756
insta::assert_snapshot!("ng_content_select", js);
757757
}
758758

759+
#[test]
760+
fn test_ng_content_i18n_attr_not_in_projection() {
761+
// Verify i18n/i18n-* attrs are NOT included in ng-content projection attributes.
762+
// Angular's I18nMetaVisitor strips these before r3_template_transform runs.
763+
let js = compile_template_to_js(
764+
r#"<ng-content i18n select=".header"></ng-content>"#,
765+
"TestComponent",
766+
);
767+
assert!(
768+
!js.contains(r#""i18n""#),
769+
"i18n attribute should not appear in projection output. Got:\n{js}"
770+
);
771+
}
772+
773+
#[test]
774+
fn test_ng_content_with_bound_select() {
775+
// Tests that [select] binding on ng-content passes the binding name and value
776+
// as attributes to the projection instruction.
777+
// Angular treats ALL raw attrs on ng-content as TextAttributes, including bindings.
778+
// [select] with brackets is NOT the same as the static `select` attribute for the
779+
// CSS selector — the selector stays as "*" (wildcard).
780+
// Expected: ɵɵprojectionDef() with no args (single wildcard),
781+
// ɵɵprojection(0, 0, ["[select]", "'[slot=expanded-content]'"])
782+
let js = compile_template_to_js(
783+
r#"<ng-content [select]="'[slot=expanded-content]'" />"#,
784+
"TestComponent",
785+
);
786+
insta::assert_snapshot!("ng_content_with_bound_select", js);
787+
}
788+
759789
#[test]
760790
fn test_ng_content_with_ng_project_as() {
761791
// Tests that ngProjectAs attribute generates the correct ProjectAs marker (5)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/integration_test.rs
3+
expression: js
4+
---
5+
const _c0 = ["*"];
6+
function TestComponent_Template(rf,ctx) {
7+
if ((rf & 1)) {
8+
i0.ɵɵprojectionDef();
9+
i0.ɵɵprojection(0,0,["[select]","'[slot=expanded-content]'"]);
10+
}
11+
}

0 commit comments

Comments
 (0)