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.
Цей коміт міститься в:
l0ng-ai committed 2026-11-06 22:52:43 +08:00
1 джерело d533750d1c
коміт 374f12b7e3
2 files changed
+94 -4

No files matched your search

+22 -1
Переглянути файл
@@ -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 = '<p [title]="t">{{ a + 1 }} @if (b) {</p>\n';
tokens(part);
// The same nodes at other offsets come from the memo; tokens are sliced at their positions.
const doc = `<div>${part}</div>\n${part}<span>x</span>${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 = "<ul>\n <li>Tom &amp; Jerry</li>\n <li>{{ a }} &amp; {{ b }}</li>\n</ul>\n@if (x) {\n}";
const tree = angularLanguage.parser.parse(doc);
+72 -3
Переглянути файл
@@ -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<string, Tree>();
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