From 374f12b7e333335e335feadd4e654bc21012e5a8 Mon Sep 17 00:00:00 2001
From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Date: Fri, 6 Nov 2026 22:52:43 +0800
Subject: [PATCH] perf(angular): reuse nested trees of unchanged text and
attributes
An edit makes the mixed parser recreate many text and attribute nodes it
does not keep: the document's own text and the nodes up to the viewport. It
reuses nested trees only of nodes it kept, so each of those was parsed again
as Angular on every keystroke (~170 nested parses per key in a 12k-line
template). Their parse depends only on their text, so the trees are now
remembered by text, up to about 2M characters.
Typing in the middle of a 12k-line template: main thread per key 57 -> 22 ms,
p50 latency 30 -> 19.5 ms, no long tasks.
---
src/editor/core/cm/angular/angular.test.ts | 23 ++++++-
src/editor/core/cm/angular/index.ts | 75 +++++++++++++++++++++-
2 files changed, 94 insertions(+), 4 deletions(-)
diff --git a/src/editor/core/cm/angular/angular.test.ts b/src/editor/core/cm/angular/angular.test.ts
index 646af4e1b..f24390120 100644
--- a/src/editor/core/cm/angular/angular.test.ts
+++ b/src/editor/core/cm/angular/angular.test.ts
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { EditorState } from "@codemirror/state";
import { ensureSyntaxTree, foldable, matchBrackets } from "@codemirror/language";
-import { NodeProp, type Tree } from "@lezer/common";
+import { IterMode, NodeProp, type Tree } from "@lezer/common";
import { buildParserFile } from "@lezer/generator";
import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight";
import { findLanguageDescription, loadSupport } from "../languages";
@@ -556,6 +556,27 @@ describe("Angular templates", () => {
}
});
+ it("reuses the trees of identical text and attributes wherever they are", () => {
+ const part = '
{{ a + 1 }} @if (b) {
\n';
+ tokens(part);
+ // The same nodes at other offsets come from the memo; tokens are sliced at their positions.
+ const doc = `${part}
\n${part}x${part}`;
+ const all = tokens(doc);
+ expect(all.filter((tok) => tok == "a:var")).toHaveLength(3);
+ expect(all.filter((tok) => tok == "title:attr")).toHaveLength(3);
+ expect(all.filter((tok) => tok == "@if:control")).toHaveLength(3);
+ const mounts: unknown[] = [];
+ angularLanguage.parser.parse(doc).iterate({
+ mode: IterMode.IgnoreMounts,
+ enter: (n) => {
+ const mount = n.name == "Text" && n.tree?.prop(NodeProp.mounted);
+ if (mount) mounts.push(mount.tree);
+ },
+ });
+ expect(mounts).toHaveLength(3);
+ expect(new Set(mounts).size).toBe(1);
+ });
+
it("parses only text with Angular syntax as Angular", () => {
const doc = "\n - Tom & Jerry
\n - {{ a }} & {{ b }}
\n
\n@if (x) {\n}";
const tree = angularLanguage.parser.parse(doc);
diff --git a/src/editor/core/cm/angular/index.ts b/src/editor/core/cm/angular/index.ts
index 652ccb5c0..f787dd2d0 100644
--- a/src/editor/core/cm/angular/index.ts
+++ b/src/editor/core/cm/angular/index.ts
@@ -10,7 +10,18 @@ import { LRLanguage, LanguageSupport, foldService, matchBrackets, syntaxTree } f
import { html } from "@codemirror/lang-html";
import { javascriptLanguage } from "@codemirror/lang-javascript";
import { styleTags, tags as t } from "@lezer/highlight";
-import { IterMode, parseMixed, type Input, type NestedParse, type SyntaxNode, type SyntaxNodeRef } from "@lezer/common";
+import {
+ IterMode,
+ Parser,
+ parseMixed,
+ type Input,
+ type NestedParse,
+ type PartialParse,
+ type SyntaxNode,
+ type SyntaxNodeRef,
+ type Tree,
+ type TreeFragment,
+} from "@lezer/common";
import { parser } from "./parser";
// @lezer/javascript styles `.` as a deref operator, but not `?.`.
@@ -84,8 +95,66 @@ const attrParser = baseParser.configure({
top: "Attribute",
});
-const textMixed = { parser: textParser },
- attrMixed = { parser: attrParser };
+/**
+ * `inner` with its trees remembered by text. Most text and attribute nodes
+ * an edit recreates (the document's own text, nodes up to the viewport) are
+ * unchanged, but the mixed parser only reuses nested trees of nodes it kept, so
+ * each would be parsed again on every keystroke. Their parse depends on
+ * nothing but their text, and trees are relative to their start.
+ */
+class TextMemo extends Parser {
+ private readonly trees = new Map();
+ private chars = 0;
+ constructor(private readonly inner: Parser) {
+ super();
+ }
+ createParse(
+ input: Input,
+ fragments: readonly TreeFragment[],
+ ranges: readonly { from: number; to: number }[],
+ ): PartialParse {
+ const range = ranges.length == 1 ? ranges[0]! : null;
+ if (!range || range.to - range.from > MEMO_MAX_TEXT) return this.inner.startParse(input, fragments, ranges);
+ const text = input.read(range.from, range.to);
+ const known = this.trees.get(text);
+ if (known) return { advance: () => known, parsedPos: range.to, stoppedAt: null, stopAt() {} };
+ const parse = this.inner.startParse(input, fragments, ranges);
+ const remember = (tree: Tree) => this.remember(text, tree);
+ return {
+ advance() {
+ const tree = parse.advance();
+ // Stopping past the text (the outer parse's stop point) still parses all of it.
+ if (tree && (parse.stoppedAt == null || parse.stoppedAt >= range.to)) remember(tree);
+ return tree;
+ },
+ get parsedPos() {
+ return parse.parsedPos;
+ },
+ get stoppedAt() {
+ return parse.stoppedAt;
+ },
+ stopAt: (pos) => parse.stopAt(pos),
+ };
+ }
+ private remember(text: string, tree: Tree) {
+ if (this.trees.has(text)) return;
+ this.trees.set(text, tree);
+ this.chars += text.length;
+ // Oldest first.
+ for (const [old] of this.trees) {
+ if (this.chars <= MEMO_CHARS) break;
+ this.trees.delete(old);
+ this.chars -= old.length;
+ }
+ }
+}
+
+/** Longer texts are parsed incrementally as usual; the memo holds about this much text. */
+const MEMO_MAX_TEXT = 4096,
+ MEMO_CHARS = 2_000_000;
+
+const textMixed = { parser: new TextMemo(textParser) },
+ attrMixed = { parser: new TextMemo(attrParser) };
// Text is parsed per node, like upstream, so an unfinished `{{` or `@if (`
// ends with it. But HTML splits text at each `&` (`@if (a && b) {`), so a run