From 2dfef914e3e393c78e57478e95f5aa06480e0d95 Mon Sep 17 00:00:00 2001 From: Shahar Kazaz Date: Wed, 22 Jul 2026 22:26:22 +0300 Subject: [PATCH] fix(lexer): consume newlines between `@let` and the declared name Angular consumes all whitespace, including newlines, between `@let` and the declared variable name. The HTML lexer only skipped spaces and tabs, so a `@let` followed by a newline (as prettier commonly formats it) was misclassified as incomplete. Its value was then never consumed, leaving any object-literal braces in the value to be re-lexed as a stray block close. That corrupted block nesting and surfaced downstream as an "Unexpected closing block" / "Unexpected closing tag" parse error. Skip all whitespace after `@let` (matching Angular's lexer) so `@let\nname = expr;` parses as a complete declaration. Adds a lexer test (newline before the name tokenizes as a complete LET) and a parser regression test (object literal in a `@let` value inside a block no longer breaks block nesting). --- .../src/parser/html/lexer.rs | 8 +++++-- .../tests/html_lexer_test.rs | 21 +++++++++++++++++++ .../tests/html_parser_test.rs | 15 +++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/oxc_angular_compiler/src/parser/html/lexer.rs b/crates/oxc_angular_compiler/src/parser/html/lexer.rs index 7779650f6..1add82126 100644 --- a/crates/oxc_angular_compiler/src/parser/html/lexer.rs +++ b/crates/oxc_angular_compiler/src/parser/html/lexer.rs @@ -1205,8 +1205,12 @@ impl<'a> HtmlLexer<'a> { for _ in 0..4 { self.advance(); } - // Skip whitespace (but not newlines for detecting invalid @let) - while self.peek() == ' ' || self.peek() == '\t' { + // Skip whitespace after `@let`, including newlines. Angular consumes all + // whitespace between `@let` and the declared name, so `@let\nfoo = ...;` is a + // valid declaration. Stopping at a newline here misclassified it as incomplete, + // leaving the value (and any object-literal braces in it) to be re-lexed as + // block content, which corrupted block nesting. + while chars::is_whitespace(self.peek()) { self.advance(); } diff --git a/crates/oxc_angular_compiler/tests/html_lexer_test.rs b/crates/oxc_angular_compiler/tests/html_lexer_test.rs index e50eadaab..380f985b9 100644 --- a/crates/oxc_angular_compiler/tests/html_lexer_test.rs +++ b/crates/oxc_angular_compiler/tests/html_lexer_test.rs @@ -1603,6 +1603,27 @@ mod let_declarations { // Should also have LET_VALUE assert!(types.contains(&HtmlTokenType::LetValue)); } + + #[test] + fn should_tokenize_let_declaration_with_newline_before_name() { + // Angular allows any whitespace - including newlines - between `@let` and the + // declared name (it skips whitespace via `isNotWhitespace`). A newline there must + // still produce a complete declaration, not an incomplete one. + let result = tokenize("@let\nfoo = 123;"); + + let types: Vec<_> = result.tokens.iter().map(|t| t.token_type).collect(); + + assert!(types.contains(&HtmlTokenType::LetStart), "Should have LetStart, got {types:?}"); + assert!(types.contains(&HtmlTokenType::LetValue), "Should have LetValue, got {types:?}"); + assert!(types.contains(&HtmlTokenType::LetEnd), "Should have LetEnd, got {types:?}"); + assert!( + !types.contains(&HtmlTokenType::IncompleteLet), + "Should not be treated as incomplete, got {types:?}" + ); + + let let_start = result.tokens.iter().find(|t| t.token_type == HtmlTokenType::LetStart); + assert_eq!(let_start.unwrap().value(), "foo"); + } } // ============================================================================ diff --git a/crates/oxc_angular_compiler/tests/html_parser_test.rs b/crates/oxc_angular_compiler/tests/html_parser_test.rs index a5a1158ec..3d6b349dd 100644 --- a/crates/oxc_angular_compiler/tests/html_parser_test.rs +++ b/crates/oxc_angular_compiler/tests/html_parser_test.rs @@ -797,6 +797,21 @@ mod let_declarations { let result = parse_and_humanize_no_ws("@let a = 1; @let b = 2;"); assert_eq!(result, vec![let_decl("a"), let_decl("b")]); } + + #[test] + fn should_parse_let_with_object_literal_value_and_newline_in_block() { + // Regression: when `@let` was followed by a newline (as prettier formats it), the + // declaration was misclassified as incomplete, so its value was never consumed. The + // object-literal braces in the value were then re-lexed as a stray block close, + // corrupting block nesting and surfacing as an "Unexpected closing tag" error. + let result = parse_and_humanize_no_ws( + "@if (cond) { @let\nlabel = value | translate: { section: id }; }", + ); + assert!( + result.iter().any(|n| n == &let_decl("label")), + "Expected a LetDeclaration named 'label', got {result:?}" + ); + } } // ============================================================================