Revert "fix(editor): pause expensive decorations during fast scroll bursts (#401)" (#635)

This reverts commit c938d712b5.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-01 10:30:09 +08:00
1 джерело 2c3eabb786
коміт 16033c481b
5 files changed
+6 -197

No files matched your search

+1 -8
Переглянути файл
@@ -6,7 +6,6 @@
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) {
@@ -77,14 +76,8 @@ export const indentGuides: Extension = ViewPlugin.fromClass(
this.decorations = build(view);
}
update(u: ViewUpdate) {
if (u.docChanged || u.startState.tabSize !== u.state.tabSize || isScrollSettled(u)) {
if (u.docChanged || u.viewportChanged || u.startState.tabSize !== u.state.tabSize)
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
Переглянути файл
@@ -1,111 +0,0 @@
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
Переглянути файл
@@ -1,52 +0,0 @@
/**
* 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,7 +39,6 @@ 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";
@@ -365,10 +364,6 @@ 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,
+5 -21
Переглянути файл
@@ -10,7 +10,6 @@ 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>({
@@ -121,36 +120,23 @@ 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) ||
isScrollSettled(u)
u.startState.field(backendFolds, false) !== u.state.field(backendFolds, false)
)
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 = () => {
// 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 onScroll = () => this.schedule();
private schedule() {
if (this.scheduled || this.view.dom.classList.contains("cm-scrolling")) return;
if (this.scheduled) 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;
@@ -180,9 +166,7 @@ class StickyScroll {
}
return { lines: headers, gutterWidth, numberWidth, numberLeft, scrollLeft: scroller.scrollLeft };
},
write: (measured) => {
if (!measured) return;
const { lines, gutterWidth, numberWidth, numberLeft, scrollLeft } = measured;
write: ({ lines, gutterWidth, numberWidth, numberLeft, scrollLeft }) => {
const state = this.view.state;
const texts = lines.map((n) => (n <= state.doc.lines ? state.doc.line(n).text : ""));
const tree = syntaxTree(state);