From d533750d1c8b5e33dcbea34f22a722320b762c53 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:22:53 +0800 Subject: [PATCH] perf(angular): parse only text with Angular syntax as Angular Every text node of a template got its own nested Angular parse, including the whitespace between tags, which is most of them. Text without a `{`, `}` or `@` holds no interpolation or block, so it stays HTML text. A full parse of a 12k-line template takes about a fifth less work. --- src/editor/core/cm/angular/angular.test.ts | 12 ++++++++++++ src/editor/core/cm/angular/index.ts | 19 +++++++++++++------ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/editor/core/cm/angular/angular.test.ts b/src/editor/core/cm/angular/angular.test.ts index e0085036d..646af4e1b 100644 --- a/src/editor/core/cm/angular/angular.test.ts +++ b/src/editor/core/cm/angular/angular.test.ts @@ -556,6 +556,18 @@ describe("Angular templates", () => { } }); + it("parses only text with Angular syntax as Angular", () => { + const doc = "\n@if (x) {\n}"; + const tree = angularLanguage.parser.parse(doc); + const parentAt = (text: string) => tree.resolveInner(doc.indexOf(text) + 1, 1).parent?.name; + // Plain text and the whitespace between tags stay HTML text. + expect(parentAt("Tom")).toBe("Element"); + expect(parentAt("\n
  • ")).toBe("Element"); + expect(parentAt("{{ a")).toBe("Interpolation"); + expect(tokens(doc)).toContain("@if:control"); + expect(tokens(doc)).toContain("b:var"); + }); + it("is generated from angular.grammar", () => { const dir = import.meta.dirname; const { parser, terms } = buildParserFile(readFileSync(resolve(dir, "angular.grammar"), "utf8"), { diff --git a/src/editor/core/cm/angular/index.ts b/src/editor/core/cm/angular/index.ts index 544880f41..652ccb5c0 100644 --- a/src/editor/core/cm/angular/index.ts +++ b/src/editor/core/cm/angular/index.ts @@ -93,8 +93,15 @@ const textMixed = { parser: textParser }, // document) holding it. const TEXT_NODES = new Set(["Text", "EntityReference", "CharacterReference", "InvalidEntity"]); -/** The runs of text directly in `node` that entities split, `null` without any. */ -function entityRuns(node: SyntaxNode): { from: number; to: number }[] | null { +/** + * Whether text holds Angular syntax: interpolations, blocks and their braces all + * need a `{`, `}` or `@`. Other text (mostly the whitespace between tags) stays + * plain HTML text instead of costing a nested parse each. + */ +const ANGULAR_TEXT = /[{}@]/; + +/** The runs of Angular text directly in `node` that entities split, `null` without any. */ +function entityRuns(node: SyntaxNode, input: Input): { from: number; to: number }[] | null { const runs: { from: number; to: number }[] = []; let from = -1, to = -1, @@ -108,12 +115,12 @@ function entityRuns(node: SyntaxNode): { from: number; to: number }[] | null { parts++; continue; } - if (parts > 1) runs.push({ from, to }); + if (parts > 1 && ANGULAR_TEXT.test(input.read(from, to))) runs.push({ from, to }); ({ from, to } = child); parts = 1; } while (child.nextSibling()); } - if (parts > 1) runs.push({ from, to }); + if (parts > 1 && ANGULAR_TEXT.test(input.read(from, to))) runs.push({ from, to }); return runs.length ? runs : null; } @@ -125,10 +132,10 @@ function mixAngular(node: SyntaxNodeRef, input: Input): NestedParse | null { return /^[*#([]|\{\{/.test(input.read(node.from, node.to)) ? attrMixed : null; // Text in a run is covered by its parent's overlay, so not asked about. case "Text": - return textMixed; + return ANGULAR_TEXT.test(input.read(node.from, node.to)) ? textMixed : null; case "Document": case "Element": { - const overlay = entityRuns(node.node); + const overlay = entityRuns(node.node, input); return overlay && { parser: node.name == "Document" ? documentRunsParser : runsParser, overlay }; } }