From 830f4aa802bdcd45e4809eb18fb0eda8be469a57 Mon Sep 17 00:00:00 2001 From: Clarence <36807519+Clarence404@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:18:34 +0800 Subject: [PATCH] perf(editor): don't rescan rendered doc comments on every keystroke (#3052) With Render Documentation Comments on, every keystroke rescanned all comment nodes and rebuilt every rendered comment, so typing in a large file cost about 10 ms per keystroke against about 1 ms with rendering off. A keystroke (input.type / delete) that touches no comment's lines now only maps the rendered comments and their decorations through the change and marks the view stale; the file is scanned again 300 ms after typing and parsing pause, by the scanner that already does this while rendering is off (renamed deferredScanner). Paste, drop, undo/redo, formatting, reloads, keystrokes on a comment's lines, toggles and relabels still rebuild at once, and parser progress rebuilds at once unless a scan is already waiting, so files still open with visible comments rendered. Co-authored-by: Clarence404 <36807519+Clarence404@users.noreply.github.com> --- .../core/cm/renderedDocs.incremental.test.ts | 104 +++++++++++++++ src/editor/core/cm/renderedDocs.ts | 126 +++++++++++++----- 2 files changed, 197 insertions(+), 33 deletions(-) create mode 100644 src/editor/core/cm/renderedDocs.incremental.test.ts diff --git a/src/editor/core/cm/renderedDocs.incremental.test.ts b/src/editor/core/cm/renderedDocs.incremental.test.ts new file mode 100644 index 000000000..d51005cc7 --- /dev/null +++ b/src/editor/core/cm/renderedDocs.incremental.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { EditorSelection, EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { java } from "@codemirror/lang-java"; +import { history, undo } from "@codemirror/commands"; +import { renderDocComments, renderedDocsExtension, renderedRanges } from "./renderedDocs"; + +let view: EditorView | null = null; +beforeEach(() => void vi.useFakeTimers()); +afterEach(() => { + view?.destroy(); + view = null; + vi.useRealTimers(); +}); + +const DOC = ["class Rooms {", " int n;", "", " /** Books a room. */", " void book() {}", "}"].join("\n"); + +function open(doc = DOC, extra: ReturnType[] = []) { + view = new EditorView({ + state: EditorState.create({ + doc, + extensions: [java(), renderedDocsExtension, renderDocComments.of(true), ...extra], + }), + parent: document.body, + }); + return view; +} + +/** Types `text` at `at` the way a keystroke does. */ +const type = (v: EditorView, at: number, text: string) => + v.dispatch({ changes: { from: at, insert: text }, userEvent: "input.type" }); + +/** What a full rebuild of the same text renders, in a fresh editor state. */ +function fullRebuild(doc: string): Array<[number, number]> { + return renderedRanges( + EditorState.create({ doc, extensions: [java(), renderedDocsExtension, renderDocComments.of(true)] }), + ); +} + +const pause = () => vi.advanceTimersByTime(300); + +it("moves rendered comments along with text typed in code, and rebuilds once typing pauses", () => { + const v = open(); + const [before] = renderedRanges(v.state); + // `/*` in code makes everything after it a comment: a full rebuild would drop the doc + // comment at once. Typing only moves it along until the pause. + type(v, v.state.doc.line(2).from + 2, "/*"); + expect(renderedRanges(v.state)).toEqual([[before![0] + 2, before![1] + 2]]); + pause(); + expect(renderedRanges(v.state)).toEqual(fullRebuild(v.state.doc.toString())); + expect(renderedRanges(v.state)).toHaveLength(0); +}); + +it("renders a pasted comment at once", () => { + const v = open(); + v.dispatch({ + changes: { from: v.state.doc.line(5).to + 1, insert: " /** Pasted. */\n void paste() {}\n" }, + userEvent: "input.paste", + }); + expect(renderedRanges(v.state)).toHaveLength(2); +}); + +it("brings a deleted comment back rendered at once on undo", () => { + const v = open(DOC, [history()]); + const line = v.state.doc.line(4); + v.dispatch({ changes: { from: line.from, to: line.to + 1 }, userEvent: "delete.selection" }); + expect(renderedRanges(v.state)).toHaveLength(0); + undo(v); + expect(renderedRanges(v.state)).toHaveLength(1); +}); + +it("rebuilds at once for a keystroke on a comment's line", () => { + const v = open(); + // Code after the closing marker: the comment no longer has its line to itself. + type(v, v.state.doc.line(4).to, " int x;"); + expect(renderedRanges(v.state)).toHaveLength(0); +}); + +it("still shows the source of a comment the caret lands in after fast typing", () => { + const v = open(); + type(v, v.state.doc.line(2).from + 2, "xx"); + const at = v.state.doc.toString().indexOf("Books"); + v.dispatch({ selection: EditorSelection.cursor(at) }); + expect(renderedRanges(v.state)).toHaveLength(0); +}); + +it("after the pause, matches a full rebuild of the same text, whatever was typed", () => { + // A seeded generator, so a failure replays. + let seed = 7; + const next = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648; + const lines = ["class A {"]; + for (let i = 0; i < 12; i++) lines.push(` /** Doc ${i}. */`, ` void m${i}() {}`, " /// line doc", ""); + lines.push("}"); + const v = open(lines.join("\n")); + const keys = ["/", "*", "\n", "x", " ", "{", "}"]; + for (let i = 0; i < 400; i++) { + const at = Math.floor(next() * (v.state.doc.length + 1)); + if (next() < 0.3 && at > 0) v.dispatch({ changes: { from: at - 1, to: at }, userEvent: "delete.backward" }); + else type(v, at, keys[Math.floor(next() * keys.length)]!); + if (next() < 0.05) pause(); + } + pause(); + expect(renderedRanges(v.state)).toEqual(fullRebuild(v.state.doc.toString())); +}); diff --git a/src/editor/core/cm/renderedDocs.ts b/src/editor/core/cm/renderedDocs.ts index e27e6c78d..a6d8401bc 100644 --- a/src/editor/core/cm/renderedDocs.ts +++ b/src/editor/core/cm/renderedDocs.ts @@ -25,8 +25,13 @@ * Off, which is the default, the icon still shows over a comment, so one comment can be * read rendered, as in IDEA. That costs typing nothing: while the file renders no * comments and none is flipped, its comments are found only once typing pauses - * (`passiveScanner`), the Markdown parser is not loaded, and the gutter has a column + * (`deferredScanner`), the Markdown parser is not loaded, and the gutter has a column * only if the file has doc comments. + * + * On, typing costs about as little: a keystroke away from every comment's lines only + * moves the rendered comments along with the text, and the file is scanned again once + * typing pauses. Anything else that changes the text (paste, undo, a reload) rebuilds at + * once, so the comments it brings in render at once. */ import { EditorState, @@ -36,6 +41,7 @@ import { RangeSet, StateEffect, StateField, + type ChangeSet, type Extension, type Transaction, } from "@codemirror/state"; @@ -369,11 +375,55 @@ type PlacedComment = DocComment & { line: number; end: number; rendered: boolean interface DocsView { decorations: DecorationSet; - /** Every doc comment, rendered or not. */ + /** Every doc comment, rendered or not, in document order. */ comments: ReadonlyArray; + /** + * Moved along with keystrokes since the last scan, instead of found again: what the + * parser made of them may differ, until `deferredScanner` rebuilds. + */ + stale: boolean; } -const INACTIVE: DocsView = { decorations: Decoration.none, comments: [] }; +const INACTIVE: DocsView = { decorations: Decoration.none, comments: [], stale: false }; + +/** Asks for the scan a stale view is waiting for. */ +const rescanDocs = StateEffect.define(); + +/** Comments moved along with a change, as an edit elsewhere moves them; their text is unchanged. */ +function mapComments(comments: ReadonlyArray, changes: ChangeSet): PlacedComment[] { + return comments.map((c) => ({ + ...c, + from: changes.mapPos(c.from, 1), + to: changes.mapPos(c.to, -1), + line: changes.mapPos(c.line, 1), + end: changes.mapPos(c.end, -1), + })); +} + +/** + * Whether a change can skip the scan: a keystroke (typed or deleted characters) that + * touches no comment's lines. Everything else (paste, undo, formatting, a reload) brings + * comments in or out at once, so it rebuilds at once; a keystroke on a comment's line + * moves its edges. A keystroke elsewhere can still change comments (`/*` makes the rest of + * the file one), which the scan after the pause takes in. + */ +function typedOutsideComments(view: DocsView, tr: Transaction): boolean { + if (!(tr.isUserEvent("input.type") || tr.isUserEvent("delete"))) return false; + const { comments } = view; + let touches = false; + tr.changes.iterChangedRanges((fromA, toA) => { + // The first comment that does not end before the change. + let lo = 0; + let hi = comments.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (comments[mid]!.end < fromA) lo = mid + 1; + else hi = mid; + } + if (lo < comments.length && comments[lo]!.line <= toA) touches = true; + }); + return !touches; +} /** * Rendered comments by their source, shared by every editor, least recently used first. @@ -442,7 +492,7 @@ function build(state: EditorState): DocsView { ), ); } - return { decorations: Decoration.set(decorations), comments }; + return { decorations: Decoration.set(decorations), comments, stale: false }; } /** Start of the comment the mouse is over, or null. Cleared by any edit, which moves the text under it. */ @@ -476,7 +526,7 @@ const setPassive = StateEffect.define(); /** * The doc comments of a file that renders none, for their icons. Rescanning the file on * each keystroke would make typing in a large file several times slower for a feature - * that is off, so `passiveScanner` rescans once typing pauses; meanwhile the comments + * that is off, so `deferredScanner` rescans once typing pauses; meanwhile the comments * found are mapped through the edits. Empty while the file renders comments: `docsField` * has them then. */ @@ -487,32 +537,29 @@ const passiveDocs = StateField.define({ // Switched off: the comments rendered a moment ago are known only to the last scan. if (active(tr.startState)) return placeComments(tr.state); for (const e of tr.effects) if (e.is(setPassive)) return e.value; - if (!tr.docChanged) return value; - const map = (pos: number, assoc: number) => tr.changes.mapPos(pos, assoc); - return value.map((c) => ({ - ...c, - from: map(c.from, 1), - to: map(c.to, -1), - line: map(c.line, 1), - end: map(c.end, -1), - })); + return tr.docChanged ? mapComments(value, tr.changes) : value; }, }); -/** Scans a file that renders no comments once the edits and the parser have paused. */ -const passiveScanner = ViewPlugin.fromClass( +/** + * Scans the file once edits and the parser have paused: a file that renders no comments, + * for their icons; one that does, when keystrokes have only moved its comments along. + */ +const deferredScanner = ViewPlugin.fromClass( class { timer: ReturnType | null = null; constructor(readonly view: EditorView) {} update(update: ViewUpdate) { - if (active(update.state)) return this.cancel(); - if (update.docChanged || syntaxTree(update.startState) !== syntaxTree(update.state)) { - this.cancel(); - this.timer = setTimeout(() => { - this.timer = null; - if (!active(this.view.state)) this.view.dispatch({ effects: setPassive.of(placeComments(this.view.state)) }); - }, PASSIVE_SCAN_DELAY); - } + if (!update.docChanged && syntaxTree(update.startState) === syntaxTree(update.state)) return; + // Rendering and up to date: the view was just built, nothing is waiting. + if (active(update.state) && !update.state.field(docsField).stale) return this.cancel(); + this.cancel(); + this.timer = setTimeout(() => { + this.timer = null; + const { state } = this.view; + if (!active(state)) this.view.dispatch({ effects: setPassive.of(placeComments(state)) }); + else if (state.field(docsField).stale) this.view.dispatch({ effects: rescanDocs.of(null) }); + }, RESCAN_DELAY); } cancel() { if (this.timer !== null) clearTimeout(this.timer); @@ -524,8 +571,8 @@ const passiveScanner = ViewPlugin.fromClass( }, ); -/** How long edits and parsing must pause before a file that renders no comments is scanned. */ -const PASSIVE_SCAN_DELAY = 300; +/** How long edits and parsing must pause before the file is scanned again. */ +const RESCAN_DELAY = 300; /** Every doc comment of the file, as last found. */ function placedComments(state: EditorState): ReadonlyArray { @@ -587,13 +634,26 @@ const docsField = StateField.define({ create: build, update(value, tr) { if (tr.effects.some((e) => e.is(markdownLoaded))) renderCache.clear(); - const rebuild = - tr.docChanged || + if ( tr.reconfigured || - tr.effects.some((e) => e.is(toggleRenderedView) || e.is(markdownLoaded) || e.is(relabel)) || - // The parser finishing more of the file can bring comments it had not reached. - syntaxTree(tr.startState) !== syntaxTree(tr.state); - return rebuild ? build(tr.state) : value; + tr.effects.some((e) => e.is(toggleRenderedView) || e.is(markdownLoaded) || e.is(relabel) || e.is(rescanDocs)) + ) + return build(tr.state); + if (tr.docChanged) { + // A keystroke away from every comment moves them along; the scan waits for a pause. + if (active(tr.state) && typedOutsideComments(value, tr)) + return { + decorations: value.decorations.map(tr.changes), + comments: mapComments(value.comments, tr.changes), + stale: true, + }; + return build(tr.state); + } + // The parser finishing more of the file can bring comments it had not reached: built + // at once, so a file opens with the comments on screen rendered, unless keystrokes + // already have the scan waiting for a pause. + if (syntaxTree(tr.startState) !== syntaxTree(tr.state)) return value.stale ? value : build(tr.state); + return value; }, provide: (f) => EditorView.decorations.from(f, (v) => v.decorations), }); @@ -838,7 +898,7 @@ export const renderedDocsExtension: Extension = [ flipped, docsField, passiveDocs, - passiveScanner, + deferredScanner, hovered, hoverTracker, showSourceAtCaret,