Skip to content

Commit 2f6b801

Browse files
Brooooooklynclaude
andcommitted
Angular compiler alignment: 79.3% match rate (516/651 components)
Major fixes in this session: - Fix nextContext merging with cached steps bug (74.3% match) - Add sanitizer argument to property/attribute bindings - Fix *ngFor "index as i" parser handling - Fix missing nextContext before reference() calls - Add expression type handling in resolve_names (Ternary, SafePropertyRead, etc.) - Fix this. prefix handling in listener expressions - Fix TwoWayListener variable usage counting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 53c8859 commit 2f6b801

35 files changed

+2956
-856
lines changed

crates/oxc_angular_compiler/src/ast/expression.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1024,7 +1024,7 @@ pub struct ExpressionBinding<'a> {
10241024
}
10251025

10261026
/// An identifier in a template binding.
1027-
#[derive(Debug)]
1027+
#[derive(Debug, Clone)]
10281028
pub struct TemplateBindingIdentifier<'a> {
10291029
/// The source text.
10301030
pub source: Atom<'a>,

crates/oxc_angular_compiler/src/parser/expression/parser.rs

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -681,16 +681,34 @@ impl<'a> Parser<'a> {
681681
let expr_start = self.peek().map(|t| t.index).unwrap_or(0);
682682

683683
// Check if there's actually an expression to parse
684+
// Note: `as` keyword means this is a variable binding like `index as i`,
685+
// where `index` is a context variable and `i` is the alias
684686
if self.at_end()
685-
|| self.peek().map(|t| t.is_character(';') || t.is_character(',')).unwrap_or(false)
687+
|| self
688+
.peek()
689+
.map(|t| t.is_character(';') || t.is_character(',') || t.is_keyword_value("as"))
690+
.unwrap_or(false)
686691
{
687-
// No value, like `*ngIf="cond; else elseBlock"` where `else` has no value
692+
// No value - either:
693+
// - `*ngIf="cond; else elseBlock"` where `else` has no value
694+
// - `*ngFor="...; index as i"` where `index` is followed by `as`
688695
let source_span = AbsoluteSourceSpan::new(keyword_start, keyword_end);
689696
result.push(TemplateBinding::Expression(ExpressionBinding {
690697
source_span,
691-
key,
698+
key: key.clone(),
692699
value: None,
693700
}));
701+
702+
// Check for `as` binding after the keyword (e.g., `index as i`)
703+
// The value_key_span points to the keyword (e.g., "index")
704+
if self.optional_keyword("as") {
705+
let value_key_span = AbsoluteSourceSpan::new(keyword_start, keyword_end);
706+
let as_binding = self.parse_as_binding(&full_key, keyword_start, value_key_span);
707+
if let Some(binding) = as_binding {
708+
result.push(binding);
709+
}
710+
}
711+
694712
return result;
695713
}
696714

@@ -3199,6 +3217,68 @@ mod tests {
31993217
}
32003218
}
32013219

3220+
#[test]
3221+
fn test_parse_template_bindings_ngfor_index_as_i() {
3222+
// *ngFor="let item of items; index as i"
3223+
// This tests the case where a context variable (index) uses "as" to create an alias
3224+
let allocator = Allocator::default();
3225+
let parser = Parser::new(&allocator, "let item of items; index as i");
3226+
let key = TemplateBindingIdentifier {
3227+
source: Atom::from("ngFor"),
3228+
span: AbsoluteSourceSpan::new(0, 5),
3229+
};
3230+
let result = parser.parse_template_bindings(key);
3231+
3232+
assert!(result.errors.is_empty(), "Errors: {:?}", result.errors);
3233+
assert_eq!(result.bindings.len(), 5, "Bindings: {:?}", result.bindings);
3234+
3235+
// First: ngFor (no value)
3236+
match &result.bindings[0] {
3237+
TemplateBinding::Expression(expr) => {
3238+
assert_eq!(expr.key.source.as_str(), "ngFor");
3239+
assert!(expr.value.is_none());
3240+
}
3241+
_ => panic!("Expected expression binding for ngFor"),
3242+
}
3243+
3244+
// Second: let item (no value)
3245+
match &result.bindings[1] {
3246+
TemplateBinding::Variable(var) => {
3247+
assert_eq!(var.key.source.as_str(), "item");
3248+
assert!(var.value.is_none());
3249+
}
3250+
_ => panic!("Expected variable binding for item"),
3251+
}
3252+
3253+
// Third: ngForOf = items
3254+
match &result.bindings[2] {
3255+
TemplateBinding::Expression(expr) => {
3256+
assert_eq!(expr.key.source.as_str(), "ngForOf");
3257+
assert!(expr.value.is_some());
3258+
}
3259+
_ => panic!("Expected expression binding for ngForOf"),
3260+
}
3261+
3262+
// Fourth: ngForIndex (no value - context variable reference)
3263+
match &result.bindings[3] {
3264+
TemplateBinding::Expression(expr) => {
3265+
assert_eq!(expr.key.source.as_str(), "ngForIndex");
3266+
assert!(expr.value.is_none());
3267+
}
3268+
_ => panic!("Expected expression binding for ngForIndex"),
3269+
}
3270+
3271+
// Fifth: i = ngForIndex (variable binding from 'as')
3272+
match &result.bindings[4] {
3273+
TemplateBinding::Variable(var) => {
3274+
assert_eq!(var.key.source.as_str(), "i");
3275+
assert!(var.value.is_some());
3276+
assert_eq!(var.value.as_ref().unwrap().source.as_str(), "ngForIndex");
3277+
}
3278+
_ => panic!("Expected variable binding for i"),
3279+
}
3280+
}
3281+
32023282
// ========================================================================
32033283
// Regex literal tests
32043284
// ========================================================================

crates/oxc_angular_compiler/src/pipeline/conversion.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,9 @@ pub fn convert_ast<'a>(
309309
matches!(&pr.receiver, AngularExpression::ImplicitReceiver(_))
310310
&& !matches!(&pr.receiver, AngularExpression::ThisReceiver(_));
311311

312+
// Check if the receiver is explicit `this`
313+
let is_this_receiver = matches!(&pr.receiver, AngularExpression::ThisReceiver(_));
314+
312315
if is_implicit_receiver {
313316
// Implicit receiver property read becomes a lexical read
314317
ConvertedExpression::ir(IrExpression::LexicalRead(Box::new_in(
@@ -318,6 +321,32 @@ pub fn convert_ast<'a>(
318321
},
319322
allocator,
320323
)))
324+
} else if is_this_receiver {
325+
// Explicit `this` property read (e.g., `this.formGroup`) becomes a
326+
// ResolvedPropertyRead with Context receiver. This is critical for embedded
327+
// views because the resolve phases need to see the ContextExpr(root_xref)
328+
// to properly generate nextContext() calls.
329+
//
330+
// Without this, `this.formGroup` would go directly to OutputExpression::ReadProp,
331+
// bypassing the IR phases, and embedded views would incorrectly use `ctx.formGroup`
332+
// instead of `ctx_r.formGroup` (after nextContext()).
333+
ConvertedExpression::ir(IrExpression::ResolvedPropertyRead(Box::new_in(
334+
crate::ir::expression::ResolvedPropertyReadExpr {
335+
receiver: Box::new_in(
336+
IrExpression::Context(Box::new_in(
337+
ContextExpr {
338+
view: root_xref,
339+
source_span: convert_source_span(pr.source_span),
340+
},
341+
allocator,
342+
)),
343+
allocator,
344+
),
345+
name: pr.name.clone(),
346+
source_span: convert_source_span(pr.source_span),
347+
},
348+
allocator,
349+
)))
321350
} else {
322351
// Explicit receiver property read becomes ReadPropExpr
323352
let receiver = convert_ast(allocator, &pr.receiver, root_xref, allocate_xref_id);

crates/oxc_angular_compiler/src/pipeline/emit.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ pub fn compile_template<'a>(
299299
DeclareVarStmt {
300300
name: constant.name.clone(),
301301
value: Some(value),
302-
modifiers: StmtModifier::NONE,
302+
modifiers: StmtModifier::FINAL, // TypeScript uses const for constant pool entries
303303
leading_comment: None,
304304
source_span: None,
305305
},

0 commit comments

Comments
 (0)