fix(desktop): reduce grid scroll jank

This commit is contained in:
t8y2 2026-07-09 17:55:01 +08:00
parent c87d42fb5e
commit 87efe469a4
5 changed files with 146 additions and 51 deletions

View File

@ -110,6 +110,7 @@ const view = shallowRef<EditorViewType | null>(null);
let viewportEmitFrame: number | null = null;
let viewportRestoreFrame: number | null = null;
let latestViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport;
let lastEmittedViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport;
let latestSelection: { anchor: number; head: number } | undefined = props.initialSelection;
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
@ -3226,6 +3227,10 @@ function readEditorViewport(currentView: EditorViewType) {
};
}
function sameEditorViewport(a: { scrollTop: number; scrollLeft: number } | undefined, b: { scrollTop: number; scrollLeft: number }) {
return a?.scrollTop === b.scrollTop && a.scrollLeft === b.scrollLeft;
}
function normalizedEditorSelection(selection: { anchor: number; head: number } | undefined, docLength: number) {
if (!selection) return undefined;
return {
@ -3270,6 +3275,8 @@ function restoreEditorFocus() {
}
function emitEditorViewport(viewport: { scrollTop: number; scrollLeft: number }) {
if (sameEditorViewport(lastEmittedViewport, viewport)) return;
lastEmittedViewport = { ...viewport };
emit("viewportChange", viewport);
}

View File

@ -156,6 +156,7 @@ import { getDataGridConditionSuggestionPosition } from "@/lib/dataGrid/dataGridC
import { caretPositionInsideInsertedSqlSingleQuotes, insertedSqlSingleQuoteAtCaret } from "@/lib/sql/sqlQuoteCaret";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { isMacOS } from "@/lib/backend/platform";
import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { formatShortcut } from "@/lib/editor/shortcutRegistry";
import { SearchableSelect } from "@/components/ui/searchable-select";
@ -252,6 +253,10 @@ const saveShortcutLabel = computed(() => formatShortcut(settingsStore.editorSett
const DATA_GRID_COMPACT_TOPBAR_WIDTH = 900;
const AUTO_REFRESH_INTERVAL_OPTIONS = [5, 10, 30, 60, 300];
function logDataGridTiming(message: string, payload?: Record<string, unknown>) {
appendDebugLog("info", message, payload);
}
const emit = defineEmits<{
reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number];
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string];
@ -265,14 +270,16 @@ const autoRefreshEnabled = ref(false);
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
const autoRefreshLabel = computed(() => (autoRefreshEnabled.value ? t("tabs.autoRefreshEvery", { seconds: autoRefreshIntervalSeconds.value }) : t("tabs.autoRefresh")));
console.info("[DBX][DataGrid:setup]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
rowCount: props.result.rows.length,
columnCount: props.result.columns.length,
backendMs: props.result.execution_time_ms,
loading: props.loading,
});
if (isDebugLoggingEnabled()) {
logDataGridTiming("[DBX][DataGrid:setup]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
rowCount: props.result.rows.length,
columnCount: props.result.columns.length,
backendMs: props.result.execution_time_ms,
loading: props.loading,
});
}
const transposeRowIndex = ref<number | null>(null);
const showTranspose = ref(false);
@ -282,8 +289,9 @@ const preserveTransposeOnNextResult = ref(false);
watch(
() => props.result,
(result) => {
if (!isDebugLoggingEnabled()) return;
const startedAt = performance.now();
console.info("[DBX][DataGrid:result:prop]", {
logDataGridTiming("[DBX][DataGrid:result:prop]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
rowCount: result.rows.length,
@ -294,14 +302,14 @@ watch(
});
nextTick(() => {
console.info("[DBX][DataGrid:result:nextTick]", {
logDataGridTiming("[DBX][DataGrid:result:nextTick]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
elapsed: `${Math.round(performance.now() - startedAt)}ms`,
loading: props.loading,
});
requestAnimationFrame(() => {
console.info("[DBX][DataGrid:result:first-frame]", {
logDataGridTiming("[DBX][DataGrid:result:first-frame]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
elapsed: `${Math.round(performance.now() - startedAt)}ms`,
@ -2471,6 +2479,8 @@ const gridVerticalScrollbarDragging = ref(false);
let gridHorizontalScrollbarFrame = 0;
let gridHorizontalScrollbarDragFrame = 0;
let gridHorizontalScrollbarPendingClientX = 0;
let gridVerticalScrollbarDragFrame = 0;
let gridVerticalScrollbarPendingClientY = 0;
let gridHorizontalScrollbarResizeObserver: ResizeObserver | null = null;
let dataGridTopbarResizeObserver: ResizeObserver | null = null;
let cellEditResizeObserver: ResizeObserver | null = null;
@ -2482,8 +2492,10 @@ let gridHorizontalScrollbarDragState: {
maxScrollLeft: number;
} | null = null;
let gridVerticalScrollbarDragState: {
scroller: HTMLElement;
trackRect: DOMRect;
thumbOffsetPx: number;
maxScrollTop: number;
} | null = null;
const hiddenColumnIndexes = ref<Set<number>>(new Set());
const nullColumnsHidden = ref(false);
@ -2856,6 +2868,7 @@ function stopGridHorizontalScrollbarDrag() {
function stopGridVerticalScrollbarDrag() {
if (!gridVerticalScrollbarDragState) return;
flushGridVerticalScrollbarDrag();
gridVerticalScrollbarDragState = null;
gridVerticalScrollbarDragging.value = false;
window.removeEventListener("pointermove", onGridVerticalScrollbarPointerMove, true);
@ -2892,26 +2905,42 @@ function startGridHorizontalScrollbarDrag(event: PointerEvent) {
scheduleGridHorizontalScrollbarDrag(event.clientX);
}
function applyGridVerticalScrollbarDrag(clientY: number) {
const scroller = gridScrollerElement();
function applyPendingGridVerticalScrollbarDrag() {
gridVerticalScrollbarDragFrame = 0;
const dragState = gridVerticalScrollbarDragState;
if (!scroller || !dragState) return;
const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
if (maxScrollTop <= 1) return;
if (!dragState) return;
const thumbHeightPx = dragState.trackRect.height * (gridVerticalScrollbarThumbHeightPercent.value / 100);
const maxThumbTopPx = Math.max(1, dragState.trackRect.height - thumbHeightPx);
const thumbTopPx = Math.min(maxThumbTopPx, Math.max(0, clientY - dragState.trackRect.top - dragState.thumbOffsetPx));
scroller.scrollTop = (thumbTopPx / maxThumbTopPx) * maxScrollTop;
const thumbTopPx = Math.min(maxThumbTopPx, Math.max(0, gridVerticalScrollbarPendingClientY - dragState.trackRect.top - dragState.thumbOffsetPx));
const scroller = dragState.scroller;
const nextScrollTop = (thumbTopPx / maxThumbTopPx) * dragState.maxScrollTop;
if (Math.abs(scroller.scrollTop - nextScrollTop) < 0.5) return;
scroller.scrollTop = nextScrollTop;
updateGridVerticalScrollbar(scroller);
if (useCanvasGridRows.value) syncCanvasViewport();
if (useCanvasGridRows.value) {
canvasScrollTop.value = scroller.scrollTop;
drawCanvasGridNow();
}
}
function scheduleGridVerticalScrollbarDrag(clientY: number) {
gridVerticalScrollbarPendingClientY = clientY;
if (gridVerticalScrollbarDragFrame) return;
// Match horizontal dragging: coalesce pointermove bursts to one canvas/layout update per frame.
gridVerticalScrollbarDragFrame = requestAnimationFrame(applyPendingGridVerticalScrollbarDrag);
}
function flushGridVerticalScrollbarDrag() {
if (!gridVerticalScrollbarDragFrame) return;
cancelAnimationFrame(gridVerticalScrollbarDragFrame);
applyPendingGridVerticalScrollbarDrag();
}
function onGridVerticalScrollbarPointerMove(event: PointerEvent) {
if (!gridVerticalScrollbarDragState) return;
event.preventDefault();
applyGridVerticalScrollbarDrag(event.clientY);
scheduleGridVerticalScrollbarDrag(event.clientY);
}
function startGridVerticalScrollbarDrag(event: PointerEvent) {
@ -2919,6 +2948,8 @@ function startGridVerticalScrollbarDrag(event: PointerEvent) {
const track = gridVerticalScrollbarTrackRef.value;
if (!scroller || !track || !hasGridVerticalOverflow.value) return;
const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
if (maxScrollTop <= 1) return;
const trackRect = track.getBoundingClientRect();
const thumbTopPx = trackRect.height * (gridVerticalScrollbarThumbTopPercent.value / 100);
const thumbHeightPx = trackRect.height * (gridVerticalScrollbarThumbHeightPercent.value / 100);
@ -2926,8 +2957,10 @@ function startGridVerticalScrollbarDrag(event: PointerEvent) {
const pointerInsideThumb = pointerY >= thumbTopPx && pointerY <= thumbTopPx + thumbHeightPx;
gridVerticalScrollbarDragState = {
scroller,
trackRect,
thumbOffsetPx: pointerInsideThumb ? pointerY - thumbTopPx : thumbHeightPx / 2,
maxScrollTop,
};
gridVerticalScrollbarDragging.value = true;
document.body.style.userSelect = "none";
@ -2935,7 +2968,7 @@ function startGridVerticalScrollbarDrag(event: PointerEvent) {
window.addEventListener("pointerup", stopGridVerticalScrollbarDrag, true);
window.addEventListener("pointercancel", stopGridVerticalScrollbarDrag, true);
event.preventDefault();
applyGridVerticalScrollbarDrag(event.clientY);
scheduleGridVerticalScrollbarDrag(event.clientY);
}
const gridHorizontalScrollbarThumbStyle = computed<CSSProperties>(() => ({
@ -4258,23 +4291,27 @@ const displayItems = computed<RowItem[]>(() => displayRowRefs.value.map(rowItemF
watch(
() => displayRowCount.value,
(length) => {
const startedAt = performance.now();
console.info("[DBX][DataGrid:display-items:ready]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
displayItemCount: length,
sourceRowCount: props.result.rows.length,
elapsedSinceSetup: dataGridElapsed(),
});
const shouldLogTiming = isDebugLoggingEnabled();
const startedAt = shouldLogTiming ? performance.now() : 0;
if (shouldLogTiming) {
logDataGridTiming("[DBX][DataGrid:display-items:ready]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
displayItemCount: length,
sourceRowCount: props.result.rows.length,
elapsedSinceSetup: dataGridElapsed(),
});
}
nextTick(() => {
const scrollerEl = gridRef.value?.querySelector<HTMLElement>(".data-grid-scroller");
if (scrollerEl) {
updateGridScrollbarGutter(scrollerEl);
updateGridHorizontalViewport(scrollerEl);
}
if (!shouldLogTiming) return;
requestAnimationFrame(() => {
const renderedRows = gridRef.value?.querySelectorAll(".vue-recycle-scroller__item-view").length;
console.info("[DBX][DataGrid:display-items:first-frame]", {
logDataGridTiming("[DBX][DataGrid:display-items:first-frame]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
displayItemCount: length,
@ -5961,10 +5998,12 @@ watch(
detailCell,
showCellDetail,
editingCell,
// Pending edit structures can contain large nested cell maps; the editor
// version ref gives the canvas a cheap invalidation signal without a deep watch.
pendingChangesVersion,
],
scheduleCanvasDraw,
);
watch([dirtyRows, newRows, deletedRows], scheduleCanvasDraw, { deep: true });
function pauseCanvasGridWork() {
dataGridIsActive = false;
@ -6022,6 +6061,9 @@ onUnmounted(() => {
if (gridHorizontalScrollbarFrame && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(gridHorizontalScrollbarFrame);
}
if (gridVerticalScrollbarDragFrame && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(gridVerticalScrollbarDragFrame);
}
if (typeof window === "undefined") return;
window.removeEventListener("resize", scheduleCanvasPixelRatioRefresh);
window.removeEventListener("resize", resizeFocusedConditionInputs);
@ -7974,23 +8016,27 @@ watch(
() => props.loading,
(isLoading) => {
stopLoadingElapsedTimer();
console.info(isLoading ? "[DBX][DataGrid:loading:start]" : "[DBX][DataGrid:loading:stop]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
elapsedSinceSetup: dataGridElapsed(),
});
if (isDebugLoggingEnabled()) {
logDataGridTiming(isLoading ? "[DBX][DataGrid:loading:start]" : "[DBX][DataGrid:loading:stop]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
elapsedSinceSetup: dataGridElapsed(),
});
}
if (isLoading) {
startLoadingElapsedTimer();
} else {
nextTick(() => {
requestAnimationFrame(() => {
console.info("[DBX][DataGrid:loading:stop:first-frame]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
elapsedSinceSetup: dataGridElapsed(),
if (isDebugLoggingEnabled()) {
nextTick(() => {
requestAnimationFrame(() => {
logDataGridTiming("[DBX][DataGrid:loading:stop:first-frame]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
elapsedSinceSetup: dataGridElapsed(),
});
});
});
});
}
}
},
);

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, defineAsyncComponent, watch, nextTick, onMounted, onUnmounted } from "vue";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { Check, Columns3, EyeOff, Loader2, Search, GitBranch, BarChart3, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Timer, Wrench, Toolbox, ListChecks, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus } from "@lucide/vue";
@ -20,10 +21,11 @@ let dataGridComponentPromise: Promise<typeof import("@/components/grid/DataGrid.
function loadDataGridComponent() {
if (!dataGridComponentPromise) {
dataGridComponentPromise = (async () => {
const startedAt = performance.now();
console.info("[DBX][DataGrid:load:start]");
const shouldLogTiming = isDebugLoggingEnabled();
const startedAt = shouldLogTiming ? performance.now() : 0;
if (shouldLogTiming) appendDebugLog("info", "[DBX][DataGrid:load:start]");
const component = await import("@/components/grid/DataGrid.vue");
console.info("[DBX][DataGrid:load:done]", { elapsed: `${Math.round(performance.now() - startedAt)}ms` });
if (shouldLogTiming) appendDebugLog("info", "[DBX][DataGrid:load:done]", { elapsed: `${Math.round(performance.now() - startedAt)}ms` });
return component;
})();
}
@ -477,8 +479,9 @@ watch(
() => props.activeTab.result,
(result) => {
if (!result) return;
if (!isDebugLoggingEnabled()) return;
const startedAt = performance.now();
console.info("[DBX][ContentArea:result:observed]", {
appendDebugLog("info", "[DBX][ContentArea:result:observed]", {
tabId: props.activeTab.id,
rowCount: result.rows.length,
columnCount: result.columns.length,
@ -486,13 +489,13 @@ watch(
isExecuting: props.activeTab.isExecuting,
});
nextTick(() => {
console.info("[DBX][ContentArea:result:nextTick]", {
appendDebugLog("info", "[DBX][ContentArea:result:nextTick]", {
tabId: props.activeTab.id,
elapsed: `${Math.round(performance.now() - startedAt)}ms`,
isExecuting: props.activeTab.isExecuting,
});
requestAnimationFrame(() => {
console.info("[DBX][ContentArea:result:first-frame]", {
appendDebugLog("info", "[DBX][ContentArea:result:first-frame]", {
tabId: props.activeTab.id,
elapsed: `${Math.round(performance.now() - startedAt)}ms`,
isExecuting: props.activeTab.isExecuting,

View File

@ -322,6 +322,9 @@ export const useQueryStore = defineStore("query", () => {
const closeConfirmContext = ref<CloseConfirmContext>("tab");
const tableStructureRefreshVersions = ref<Record<string, number>>({});
const savedSqlEditorPositionTimers = new Map<string, ReturnType<typeof setTimeout>>();
let resultCacheTrimScheduled = false;
let resultCacheTrimRunning = false;
let resultCacheTrimRequested = false;
function tableStructureKey(connectionId: string, database: string, schema: string | undefined, tableName: string): string {
return [connectionId, database, schema || "", tableName].map((part) => part.toLowerCase()).join("\u0000");
@ -1561,6 +1564,7 @@ export const useQueryStore = defineStore("query", () => {
function updateEditorViewport(id: string, viewport: { scrollTop: number; scrollLeft: number }) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return;
if (tab.editorViewport?.scrollTop === viewport.scrollTop && tab.editorViewport?.scrollLeft === viewport.scrollLeft) return;
tab.editorViewport = viewport;
queueSavedSqlEditorPositionPersist(tab);
}
@ -2691,7 +2695,7 @@ export const useQueryStore = defineStore("query", () => {
});
}
}
await trimResultCache();
scheduleResultCacheTrim();
}
async function explainTabSql(id: string, sql: string, databaseType?: DatabaseType, explainMode?: string) {
@ -2887,6 +2891,39 @@ export const useQueryStore = defineStore("query", () => {
}
}
function scheduleResultCacheTrim() {
resultCacheTrimRequested = true;
if (resultCacheTrimScheduled || resultCacheTrimRunning) return;
resultCacheTrimScheduled = true;
const run = () => {
resultCacheTrimScheduled = false;
void runRequestedResultCacheTrim();
};
// Eviction serializes large result payloads; schedule it after the result
// assignment so the grid can paint before cache maintenance starts.
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
window.requestIdleCallback(run, { timeout: 1500 });
} else {
setTimeout(run, 0);
}
}
async function runRequestedResultCacheTrim() {
if (resultCacheTrimRunning) return;
resultCacheTrimRunning = true;
try {
while (resultCacheTrimRequested) {
resultCacheTrimRequested = false;
await trimResultCache();
}
} finally {
resultCacheTrimRunning = false;
if (resultCacheTrimRequested) scheduleResultCacheTrim();
}
}
function rememberActiveTab(id: string | null) {
if (!id || !tabs.value.some((tab) => tab.id === id)) return;
activeTabHistory.value = [...activeTabHistory.value.filter((tabId) => tabId !== id), id];

View File

@ -1686,6 +1686,7 @@ test("evicting cached tab results releases multi-result payloads and sessions",
await store.executeTabSql(tabId, `select ${i + 1}; select ${i + 1} as detail`);
}
await waitFor(() => store.tabs.find((tab) => tab.id === tabIds[0])?.resultEvicted === true);
const evicted = store.tabs.find((tab) => tab.id === tabIds[0]);
assert.equal(executeCount, 7);
assert.equal(evicted?.result, undefined);
@ -1761,6 +1762,7 @@ test("result cache eviction keeps recently accessed inactive tabs", async () =>
tabIds.push(tabId);
await store.executeTabSql(tabId, "select 7");
await waitFor(() => store.tabs.find((tab) => tab.id === tabIds[1])?.resultEvicted === true);
const recentlyViewed = store.tabs.find((tab) => tab.id === tabIds[0]);
const leastRecentlyUsed = store.tabs.find((tab) => tab.id === tabIds[1]);
assert.ok(recentlyViewed?.result);