From d79a72ac0297487104b9c1855d790c684ee074dd Mon Sep 17 00:00:00 2001 From: JounQin Date: Tue, 13 Oct 2026 06:55:28 +0000 Subject: [PATCH] fix(compiler): don't hang on a lowercase `@` in selectorless tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `selectorlessEnabled`, the attribute loop in `_consumeTagOpen` only consumed `@` when it was followed by a selectorless name start (`_` or an uppercase letter, i.e. a PascalCase component/directive reference). Any other `@` — a lowercase animation trigger such as `@triggerName`, or a lone `@` — matched neither branch, so the cursor never advanced and the lexer looped forever. Fall through to `_consumeAttribute()` instead, which is what the lexer already does when selectorless syntax is disabled. Fixes #57 --- .../angular-html-parser/test/index_spec.ts | 48 +++++++++++++++++++ packages/compiler/src/ml_parser/lexer.ts | 11 +++-- .../compiler/test/ml_parser/lexer_spec.ts | 38 +++++++++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/packages/angular-html-parser/test/index_spec.ts b/packages/angular-html-parser/test/index_spec.ts index 89e101cb94f9..8488ed5c9d62 100644 --- a/packages/angular-html-parser/test/index_spec.ts +++ b/packages/angular-html-parser/test/index_spec.ts @@ -188,6 +188,54 @@ describe("AST format", () => { ]); } + { + // A lowercase `@` is not a selectorless directive — those are referenced by their + // PascalCase class name — so it stays an attribute. This used to hang the lexer: + // https://github.com/prettier/angular-html-parser/issues/57 + const result = parse("
", { + enableAngularSelectorlessSyntax: true, + }); + expect(result.errors).toEqual([]); + expect(result.rootNodes).toEqual([ + expect.objectContaining({ + name: "div", + kind: "element", + directives: [], + attrs: [expect.objectContaining({ name: "@myDir" })], + }), + ]); + } + + { + const result = parse("", { + enableAngularSelectorlessSyntax: true, + }); + expect(result.rootNodes).toEqual([ + expect.objectContaining({ + fullName: "MyComp", + componentName: "MyComp", + kind: "component", + directives: [], + attrs: [expect.objectContaining({ name: "@myDir" })], + }), + ]); + } + + { + // Any `@` that cannot start a directive has to fall back to an attribute name. + const result = parse("
", { + enableAngularSelectorlessSyntax: true, + }); + expect(result.errors).toEqual([]); + expect(result.rootNodes).toEqual([ + expect.objectContaining({ + name: "div", + kind: "element", + attrs: [expect.objectContaining({ name: "@" })], + }), + ]); + } + { const result = parse("Hello", { enableAngularSelectorlessSyntax: true, diff --git a/packages/compiler/src/ml_parser/lexer.ts b/packages/compiler/src/ml_parser/lexer.ts index d47944a1ce4c..9d489662e191 100644 --- a/packages/compiler/src/ml_parser/lexer.ts +++ b/packages/compiler/src/ml_parser/lexer.ts @@ -980,13 +980,18 @@ class _Tokenizer { const nameStart = start.clone(); nameStart.advance(); + // Selectorless directives are referenced by their PascalCase class name (`@Dir`). if (isSelectorlessNameStart(nameStart.peek())) { this._consumeDirective(start, nameStart); + continue; } - } else { - const attr = this._consumeAttribute(); - attrs.push(attr); } + + // Any other `@` — a lowercase animation trigger like `@triggerName`, or a lone `@` — is + // part of an attribute name. Falling through is also what makes the loop advance: before + // this, a lowercase `@` was consumed by neither branch and the lexer looped forever. + const attr = this._consumeAttribute(); + attrs.push(attr); } if (openToken.type === TokenType.COMPONENT_OPEN_START) { diff --git a/packages/compiler/test/ml_parser/lexer_spec.ts b/packages/compiler/test/ml_parser/lexer_spec.ts index db6d270fc540..0b8054a70b56 100644 --- a/packages/compiler/test/ml_parser/lexer_spec.ts +++ b/packages/compiler/test/ml_parser/lexer_spec.ts @@ -664,6 +664,44 @@ describe('HtmlLexer', () => { [TokenType.EOF, ''], ]); }); + + // https://github.com/prettier/angular-html-parser/issues/57 + it('should treat a lowercase `@` as an attribute name instead of looping forever', () => { + expect(tokenizeAndHumanizeParts('
', options)).toEqual([ + [TokenType.TAG_OPEN_START, '', 'div'], + [TokenType.ATTR_NAME, '', '@myDir'], + [TokenType.TAG_OPEN_END], + [TokenType.TAG_CLOSE, '', 'div'], + [TokenType.EOF], + ]); + expect(tokenizeAndHumanizeParts('
', options)).toEqual([ + [TokenType.TAG_OPEN_START, '', 'div'], + [TokenType.ATTR_NAME, '', '@myDir'], + [TokenType.ATTR_QUOTE, '"'], + [TokenType.ATTR_VALUE_TEXT, 'x'], + [TokenType.ATTR_QUOTE, '"'], + [TokenType.TAG_OPEN_END], + [TokenType.TAG_CLOSE, '', 'div'], + [TokenType.EOF], + ]); + }); + + it('should treat any other `@` that cannot start a directive as an attribute name', () => { + expect(tokenizeAndHumanizeParts('
', options)).toEqual([ + [TokenType.TAG_OPEN_START, '', 'div'], + [TokenType.ATTR_NAME, '', '@'], + [TokenType.TAG_OPEN_END], + [TokenType.TAG_CLOSE, '', 'div'], + [TokenType.EOF], + ]); + expect(tokenizeAndHumanizeParts('
', options)).toEqual([ + [TokenType.TAG_OPEN_START, '', 'div'], + [TokenType.ATTR_NAME, '', '@1'], + [TokenType.TAG_OPEN_END], + [TokenType.TAG_CLOSE, '', 'div'], + [TokenType.EOF], + ]); + }); }); describe('escapable raw text', () => {