From c938d712b5c0d339aef994ba482b9326f4413f12 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Sun, 1 Nov 2026 10:09:47 +0800 Subject: [PATCH] fix(editor): pause expensive decorations during fast scroll bursts (#401) * fix(editor): pause expensive decorations during fast scroll bursts Add a scrollThrottle plugin that flags the editor with a .cm-scrolling class while the wheel is moving. While the flag is up the sticky-scroll header and indent guides are hidden via theme CSS, and their view plugins skip per-viewport re-measures/rebuilds; a trailing scrollSettled effect re-runs them once the wheel rests for 140ms. This keeps fast mouse-wheel scrolling from painting rows of stale decorations over freshly rendered code. Fixes l0ng-ai/openidea#375 * fix(editor): keep decorations painted during scroll bursts The scroll throttle hid the sticky header and indent guides entirely while scrolling, which flickered on every burst - and the sticky header is exactly what you want to see while scrolling. Drop the display/background hiding from the theme and keep the previous paint instead: the plugins skip re-measuring during a burst and catch up once on the trailing scrollSettled update. * fix(editor): throttle the first scroll event and queued sticky measurements --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- src/editor/core/cm/indentGuides.ts | 9 +- src/editor/core/cm/scrollThrottle.test.ts | 111 ++++++++++++++++++++++ src/editor/core/cm/scrollThrottle.ts | 52 ++++++++++ src/editor/core/cm/setup.ts | 5 + src/editor/core/cm/stickyScroll.ts | 26 ++++- 5 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 src/editor/core/cm/scrollThrottle.test.ts create mode 100644 src/editor/core/cm/scrollThrottle.ts diff --git a/src/editor/core/cm/indentGuides.ts b/src/editor/core/cm/indentGuides.ts index 7ff452d96..150a23449 100644 --- a/src/editor/core/cm/indentGuides.ts +++ b/src/editor/core/cm/indentGuides.ts @@ -6,6 +6,7 @@ import { RangeSetBuilder, type EditorState, type Extension } from "@codemirror/state"; import { Decoration, ViewPlugin, type DecorationSet, type EditorView, type ViewUpdate } from "@codemirror/view"; import { getIndentUnit } from "@codemirror/language"; +import { isScrollSettled } from "./scrollThrottle"; const cache = new Map(); function guideDeco(levels: number, step: number) { @@ -76,8 +77,14 @@ export const indentGuides: Extension = ViewPlugin.fromClass( this.decorations = build(view); } update(u: ViewUpdate) { - if (u.docChanged || u.viewportChanged || u.startState.tabSize !== u.state.tabSize) + if (u.docChanged || u.startState.tabSize !== u.state.tabSize || isScrollSettled(u)) { this.decorations = build(u.view); + return; + } + // During a scroll burst keep the previous decorations (stale ranges + // for a moment) instead of rebuilding per frame; the scrollSettled + // effect above triggers one rebuild once scrolling stops. + if (u.viewportChanged && !u.view.dom.classList.contains("cm-scrolling")) this.decorations = build(u.view); } }, { decorations: (v) => v.decorations }, diff --git a/src/editor/core/cm/scrollThrottle.test.ts b/src/editor/core/cm/scrollThrottle.test.ts new file mode 100644 index 000000000..672e9be9e --- /dev/null +++ b/src/editor/core/cm/scrollThrottle.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Compartment, EditorState } from "@codemirror/state"; +import { EditorView, ViewPlugin } from "@codemirror/view"; +import { isScrollSettled, scrollThrottle } from "./scrollThrottle"; +import { stickyScroll } from "./stickyScroll"; + +let view: EditorView | undefined; +type MeasureRequest = NonNullable[0]>; + +afterEach(() => { + view?.destroy(); + view = undefined; + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +function editor(extensions: Parameters[0] = {}) { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + view = new EditorView({ state: EditorState.create(extensions), parent: document.body }); + return view; +} + +describe("scroll throttle", () => { + it("marks scrolling before previously installed bubble listeners run", () => { + const seen: boolean[] = []; + const listener = ViewPlugin.define((v) => { + const onScroll = () => seen.push(v.dom.classList.contains("cm-scrolling")); + v.scrollDOM.addEventListener("scroll", onScroll); + return { destroy: () => v.scrollDOM.removeEventListener("scroll", onScroll) }; + }); + const v = editor({ extensions: [listener, scrollThrottle] }); + v.scrollDOM.dispatchEvent(new Event("scroll")); + expect(seen).toEqual([true]); + }); + + it("settles once after the last event in a burst", () => { + const settled = vi.fn(); + const observer = EditorView.updateListener.of((u) => { + if (isScrollSettled(u)) settled(u.view.dom.classList.contains("cm-scrolling")); + }); + const v = editor({ extensions: [scrollThrottle, observer] }); + v.scrollDOM.dispatchEvent(new Event("scroll")); + vi.advanceTimersByTime(100); + v.scrollDOM.dispatchEvent(new Event("scroll")); + vi.advanceTimersByTime(139); + expect(v.dom.classList.contains("cm-scrolling")).toBe(true); + expect(settled).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(settled).toHaveBeenCalledExactlyOnceWith(false); + }); + + it("removes its capture listener and pending timer when disabled", () => { + const compartment = new Compartment(); + const settled = vi.fn(); + const v = editor({ + extensions: [ + compartment.of(scrollThrottle), + EditorView.updateListener.of((u) => { + if (isScrollSettled(u)) settled(); + }), + ], + }); + v.scrollDOM.dispatchEvent(new Event("scroll")); + v.dispatch({ effects: compartment.reconfigure([]) }); + expect(v.dom.classList.contains("cm-scrolling")).toBe(false); + v.scrollDOM.dispatchEvent(new Event("scroll")); + expect(v.dom.classList.contains("cm-scrolling")).toBe(false); + vi.advanceTimersByTime(200); + expect(settled).not.toHaveBeenCalled(); + }); + + it("defers sticky measurements from the first scroll event until settling", () => { + const requests: MeasureRequest[] = []; + const measure = vi.spyOn(EditorView.prototype, "requestMeasure").mockImplementation((request) => { + if (request) requests.push(request); + }); + // Match setup.ts: stickyScroll is installed before scrollThrottle. + const v = editor({ extensions: [stickyScroll, scrollThrottle] }); + const initial = requests.shift()!; + initial.write?.(initial.read(v), v); + measure.mockClear(); + v.scrollDOM.dispatchEvent(new Event("scroll")); + expect(measure).not.toHaveBeenCalled(); + vi.advanceTimersByTime(140); + expect(requests).toHaveLength(1); + }); + + it("keeps painted sticky rows when a queued measurement meets a new burst", () => { + const requests: MeasureRequest[] = []; + vi.spyOn(EditorView.prototype, "requestMeasure").mockImplementation((request) => { + if (request) requests.push(request); + }); + const v = editor({ extensions: [stickyScroll, scrollThrottle] }); + const sticky = v.dom.querySelector(".cm-openidea-sticky")!; + sticky.style.display = ""; + sticky.textContent = "previous header"; + const initial = requests.shift()!; + const geometry = vi.spyOn(v.scrollDOM, "getBoundingClientRect"); + v.scrollDOM.dispatchEvent(new Event("scroll")); + initial.write?.(initial.read(v), v); + expect(geometry).not.toHaveBeenCalled(); + expect(sticky.textContent).toBe("previous header"); + expect(sticky.style.display).toBe(""); + vi.advanceTimersByTime(140); + expect(requests).toHaveLength(1); + const settled = requests.shift()!; + settled.write?.(settled.read(v), v); + expect(geometry).toHaveBeenCalled(); + expect(sticky.style.display).toBe("none"); + }); +}); diff --git a/src/editor/core/cm/scrollThrottle.ts b/src/editor/core/cm/scrollThrottle.ts new file mode 100644 index 000000000..f41744051 --- /dev/null +++ b/src/editor/core/cm/scrollThrottle.ts @@ -0,0 +1,52 @@ +/** + * Marks the editor with a `cm-scrolling` class while the user is actively + * scrolling; expensive decorations (sticky header, indent guides) keep + * their previous paint and defer re-measuring until the wheel / trackpad + * rests for a moment. Keeps fast mouse-wheel scrolling from spending every + * frame on layout work that will be stale by the next scroll event anyway. + */ +import { StateEffect, type Extension } from "@codemirror/state"; +import { ViewPlugin, type EditorView, type ViewUpdate } from "@codemirror/view"; + +const IDLE_MS = 140; + +/** Dispatched once a scroll burst ends so plugins that skipped work can catch up. */ +export const scrollSettled = StateEffect.define(); + +class ScrollThrottle { + private timer = 0; + private active = false; + + constructor(private view: EditorView) { + // Run before bubble listeners, including stickyScroll when it was + // installed first, so the very first event is throttled too. + view.scrollDOM.addEventListener("scroll", this.onScroll, { passive: true, capture: true }); + } + + private onScroll = () => { + if (!this.active) { + this.active = true; + this.view.dom.classList.add("cm-scrolling"); + } + clearTimeout(this.timer); + this.timer = window.setTimeout(() => { + this.active = false; + this.view.dom.classList.remove("cm-scrolling"); + // Wake plugins that suppressed viewport work during the burst. + this.view.dispatch({ effects: scrollSettled.of(null) }); + }, IDLE_MS); + }; + + destroy() { + clearTimeout(this.timer); + this.view.scrollDOM.removeEventListener("scroll", this.onScroll, true); + this.view.dom.classList.remove("cm-scrolling"); + } +} + +/** Check whether an update marks the end of a scroll burst. */ +export function isScrollSettled(u: ViewUpdate): boolean { + return u.transactions.some((tr) => tr.effects.some((e) => e.is(scrollSettled))); +} + +export const scrollThrottle: Extension = ViewPlugin.fromClass(ScrollThrottle); diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index bcecc965e..bd33cea9c 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -39,6 +39,7 @@ import { syntaxHighlightField } from "./syntaxHighlight"; import { indentGuides } from "./indentGuides"; import { rainbowBrackets } from "./rainbowBrackets"; import { stickyMaxLines, stickyScroll } from "./stickyScroll"; +import { scrollThrottle } from "./scrollThrottle"; import { findExtension } from "./find"; import { codeLensExtension } from "./codeLens"; import { caretExtension, fontZoom, methodLinesField, prefsExtension, softWrapActive } from "./prefs"; @@ -364,6 +365,10 @@ export function baseExtensions(o: SetupOptions): Extension { compartments.wrap.of(wrapExtension(o.settings, o.ctx.path, o.wrapOverride)), compartments.sticky.of(stickyExtension(o.settings)), compartments.heavy.of(heavyExtension(o.docLength, o.settings)), + // Flag "cm-scrolling" while the wheel is moving; expensive decorations + // (sticky header, indent guides, ...) keep their previous paint and + // defer re-measuring until scrolling settles. + scrollThrottle, compartments.prefs.of( prefsFor({ settings: o.settings, diff --git a/src/editor/core/cm/stickyScroll.ts b/src/editor/core/cm/stickyScroll.ts index 7bb8b4aa4..c30e50310 100644 --- a/src/editor/core/cm/stickyScroll.ts +++ b/src/editor/core/cm/stickyScroll.ts @@ -10,6 +10,7 @@ import { foldNodeProp, syntaxTree } from "@codemirror/language"; import { highlightTree } from "@lezer/highlight"; import { backendFolds } from "./folding"; import { openideaHighlightStyle } from "./highlight"; +import { isScrollSettled } from "./scrollThrottle"; /** Maximum number of sticky lines (Settings › Editor › Code Editing). */ export const stickyMaxLines = Facet.define({ @@ -120,23 +121,36 @@ class StickyScroll { update(u: ViewUpdate) { if ( u.docChanged || - u.viewportChanged || u.geometryChanged || syntaxTree(u.startState) !== syntaxTree(u.state) || - u.startState.field(backendFolds, false) !== u.state.field(backendFolds, false) + u.startState.field(backendFolds, false) !== u.state.field(backendFolds, false) || + isScrollSettled(u) ) this.schedule(); + // Viewport moves during a scroll burst are wasteful: the previous rows + // stay painted (just no longer aligned), so skip re-measuring until the + // scrollSettled effect above re-runs one pass. + if (u.viewportChanged && !u.view.dom.classList.contains("cm-scrolling")) this.schedule(); } - private onScroll = () => this.schedule(); + private onScroll = () => { + // The class is set by the scrollThrottle plugin on the same element; + // during a burst the stale rows stay visible instead of re-measuring + // every frame — catch up on the trailing update after scrolling settles. + if (this.view.dom.classList.contains("cm-scrolling")) return; + this.schedule(); + }; private schedule() { - if (this.scheduled) return; + if (this.scheduled || this.view.dom.classList.contains("cm-scrolling")) return; this.scheduled = true; this.view.requestMeasure({ key: this, read: (view) => { this.scheduled = false; + // A queued measurement may outlive the start of a scroll burst. + // Preserve the painted rows until scrollSettled schedules a fresh pass. + if (view.dom.classList.contains("cm-scrolling")) return null; const scroller = view.scrollDOM; const scrollTop = scroller.scrollTop; const docOffset = view.documentTop - scroller.getBoundingClientRect().top + scrollTop; @@ -166,7 +180,9 @@ class StickyScroll { } return { lines: headers, gutterWidth, numberWidth, numberLeft, scrollLeft: scroller.scrollLeft }; }, - write: ({ lines, gutterWidth, numberWidth, numberLeft, scrollLeft }) => { + write: (measured) => { + if (!measured) return; + const { lines, gutterWidth, numberWidth, numberLeft, scrollLeft } = measured; const state = this.view.state; const texts = lines.map((n) => (n <= state.doc.lines ? state.doc.line(n).text : "")); const tree = syntaxTree(state);