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 <[email protected]>
Цей коміт міститься в:
arookieofcтаl0ng-ai authored and GitHub committed 2026-11-01 10:09:47 +08:00
1 джерело 7cf4f7948d
коміт c938d712b5
5 files changed
+197 -6

No files matched your search

+8 -1
Переглянути файл
@@ -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<string, Decoration>();
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 },
+111
Переглянути файл
@@ -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<Parameters<EditorView["requestMeasure"]>[0]>;
afterEach(() => {
view?.destroy();
view = undefined;
vi.restoreAllMocks();
vi.useRealTimers();
});
function editor(extensions: Parameters<typeof EditorState.create>[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<HTMLElement>(".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");
});
});
+52
Переглянути файл
@@ -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<null>();
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);
+5
Переглянути файл
@@ -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,
+21 -5
Переглянути файл
@@ -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<number, number>({
@@ -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);