дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
fix(vcs): keep Git Log rows painted while dragging the scrollbar (#1914)
Dragging the Git Log scrollbar through a long history stuttered and showed blank rows: the native scrollbar scrolls asynchronously in WebKit, so the virtual list rendered rows a frame or more after the new offset was painted. The list now uses a scrollbar drawn in the DOM (`Scrollbar` in `src/ui`) that sets the offset from pointer events and notifies scroll listeners at once, so the virtualizer renders the matching rows before the next paint. Rows are keyed by slot so a large jump updates the mounted rows instead of rebuilding them, overscan is lowered, and rows rendered mid-scroll draw their graph without edge hit targets, which come back once scrolling stops. Adds a `gitLogScrollbar` perf scenario with budgets.
Цей коміт міститься в:
1 джерело
159c2d6fd2
коміт
b99fe1704b
8 files changed
+414
-46
No files matched your search
@@ -0,0 +1,27 @@
|
||||
/* Same look as the global ::-webkit-scrollbar (global.css). */
|
||||
.track {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 10px;
|
||||
z-index: 1;
|
||||
}
|
||||
.thumb {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
background-clip: content-box;
|
||||
background-color: var(--scrollbar-thumb);
|
||||
will-change: transform;
|
||||
}
|
||||
.thumb:hover,
|
||||
.thumb[data-dragging] {
|
||||
background-color: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
/* For the scroller whose vertical scrollbar `Scrollbar` draws. */
|
||||
.hideNative::-webkit-scrollbar:vertical {
|
||||
width: 0;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Scrollbar } from "./Scrollbar";
|
||||
|
||||
function Harness({ onScroll }: { onScroll: (top: number) => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<Scrollbar target={ref} contentHeight={1000} />
|
||||
<div ref={ref} data-testid="scroller" onScroll={(e) => onScroll(e.currentTarget.scrollTop)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Scrollbar", () => {
|
||||
it("notifies scroll listeners synchronously while the thumb is dragged", () => {
|
||||
const onScroll = vi.fn();
|
||||
const { getByTestId, container } = render(<Harness onScroll={onScroll} />);
|
||||
const scroller = getByTestId("scroller");
|
||||
let top = 0;
|
||||
Object.defineProperties(scroller, {
|
||||
clientHeight: { value: 100 },
|
||||
scrollHeight: { value: 1000 },
|
||||
scrollTop: { get: () => top, set: (v: number) => (top = v) },
|
||||
});
|
||||
scroller.scrollTo = ((options: ScrollToOptions) => {
|
||||
top = Math.max(0, Math.min(900, options.top ?? top));
|
||||
}) as typeof scroller.scrollTo;
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
onScroll.mockClear();
|
||||
|
||||
const track = container.querySelector<HTMLElement>("[aria-hidden]")!;
|
||||
const thumb = track.firstElementChild as HTMLElement;
|
||||
track.setPointerCapture = () => {};
|
||||
thumb.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, clientY: 10 }));
|
||||
track.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientY: 46 }));
|
||||
|
||||
// A 32px thumb travels 68px of the track for 900px of content: 36px ≈ 476px.
|
||||
expect(top).toBeCloseTo((36 / 68) * 900);
|
||||
expect(onScroll).toHaveBeenCalledTimes(1);
|
||||
expect(onScroll).toHaveBeenLastCalledWith(top);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useLayoutEffect, useRef, type RefObject } from "react";
|
||||
import styles from "./Scrollbar.module.css";
|
||||
|
||||
const MIN_THUMB = 32;
|
||||
|
||||
/** Class for the scroller: hides its native vertical scrollbar, which `Scrollbar` replaces. */
|
||||
export const hideNativeScrollbar = styles.hideNative;
|
||||
|
||||
/**
|
||||
* A vertical scrollbar drawn in the DOM over `target` (place both in a
|
||||
* `position: relative` box). Dragging it sets `scrollTop` from pointer events,
|
||||
* so content rendered on scroll (virtual lists) lands in the same frame. The
|
||||
* native one scrolls asynchronously in WebKit and shows blank rows until the
|
||||
* page catches up.
|
||||
*
|
||||
* `contentHeight` re-measures when the scroller's content grows.
|
||||
*/
|
||||
export function Scrollbar({
|
||||
target,
|
||||
contentHeight,
|
||||
}: {
|
||||
target: RefObject<HTMLElement | null>;
|
||||
contentHeight?: number;
|
||||
}) {
|
||||
const track = useRef<HTMLDivElement>(null);
|
||||
const thumb = useRef<HTMLDivElement>(null);
|
||||
const drag = useRef<{ y: number; top: number } | null>(null);
|
||||
const geometry = useRef<Geometry | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
geometry.current = layout(target.current, track.current, thumb.current);
|
||||
}, [target, contentHeight]);
|
||||
useEffect(() => {
|
||||
const el = target.current;
|
||||
if (!el) return;
|
||||
const sync = () => {
|
||||
geometry.current = layout(el, track.current, thumb.current);
|
||||
};
|
||||
el.addEventListener("scroll", sync, { passive: true });
|
||||
const observer = new ResizeObserver(sync);
|
||||
observer.observe(el);
|
||||
return () => {
|
||||
el.removeEventListener("scroll", sync);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [target]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={track}
|
||||
className={styles.track}
|
||||
aria-hidden
|
||||
onPointerDown={(e) => {
|
||||
const el = target.current;
|
||||
if (!el || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
// A click on the track pages towards it, as the native scrollbar does.
|
||||
if (e.target !== thumb.current) {
|
||||
const { top, bottom } = thumb.current!.getBoundingClientRect();
|
||||
if (e.clientY < top) scrollNow(el, el.scrollTop - el.clientHeight);
|
||||
else if (e.clientY > bottom) scrollNow(el, el.scrollTop + el.clientHeight);
|
||||
return;
|
||||
}
|
||||
drag.current = { y: e.clientY, top: el.scrollTop };
|
||||
thumb.current!.dataset.dragging = "";
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const el = target.current;
|
||||
if (!el || !drag.current) return;
|
||||
const g = geometry.current;
|
||||
if (g) scrollNow(el, drag.current.top + ((e.clientY - drag.current.y) / g.range) * g.max);
|
||||
}}
|
||||
onLostPointerCapture={() => {
|
||||
drag.current = null;
|
||||
delete thumb.current?.dataset.dragging;
|
||||
}}
|
||||
>
|
||||
<div ref={thumb} className={styles.thumb} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Geometry = { range: number; max: number };
|
||||
|
||||
/** Positions the track over `el` and the thumb at its scroll offset; null when nothing scrolls. */
|
||||
function layout(el: HTMLElement | null, t: HTMLElement | null, th: HTMLElement | null): Geometry | null {
|
||||
if (!el || !t || !th) return null;
|
||||
const height = el.clientHeight;
|
||||
const max = el.scrollHeight - height;
|
||||
t.style.top = `${el.offsetTop}px`;
|
||||
t.style.height = `${height}px`;
|
||||
t.style.display = max > 0 ? "" : "none";
|
||||
if (max <= 0) return null;
|
||||
const size = Math.max(MIN_THUMB, (height * height) / el.scrollHeight);
|
||||
const range = height - size;
|
||||
th.style.height = `${size}px`;
|
||||
th.style.transform = `translateY(${(Math.min(max, el.scrollTop) / max) * range}px)`;
|
||||
return range > 0 ? { range, max } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls `el` and tells its scroll listeners at once. WebKit fires `scroll` for
|
||||
* a script scroll only at a later rendering step and may paint the new offset
|
||||
* first, so a virtual list would show a frame of blank rows mid-drag.
|
||||
*/
|
||||
function scrollNow(el: HTMLElement, top: number) {
|
||||
const before = el.scrollTop;
|
||||
el.scrollTo({ top, behavior: "instant" });
|
||||
if (el.scrollTop !== before) el.dispatchEvent(new Event("scroll"));
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export * from "./Popup";
|
||||
export * from "./Menu";
|
||||
export * from "./Dialog";
|
||||
export * from "./Splitter";
|
||||
export { Scrollbar, hideNativeScrollbar } from "./Scrollbar";
|
||||
export * from "./SourcePreview";
|
||||
export * from "./Feedback";
|
||||
export * from "./HighlightedText";
|
||||
|
||||
Reference in new issue
Заблокувати користувача