From 234f26ada8d8dc6e789c7b74b6e6b6c27d333bf2 Mon Sep 17 00:00:00 2001 From: zipg Date: Wed, 29 Jul 2026 00:59:37 +0800 Subject: [PATCH] fix(grid): stop infinite scroll at exact total --- apps/desktop/src/components/grid/DataGrid.vue | 15 +++++++++++++- .../src/lib/dataGrid/dataGridPagination.ts | 20 +++++++++++++++++++ .../app-tests/dataGridInfiniteScroll.test.ts | 1 + packages/app-tests/dataGridPagination.test.ts | 18 ++++++++++++++++- 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 7897735d53..43c15c1829 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -134,7 +134,7 @@ import { applyColumnFormatter, buildColumnFormatterKey, getSupportedTimeZoneOpti import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor"; import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts"; import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter"; -import { canGoNextDataGridPage, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal } from "@/lib/dataGrid/dataGridPagination"; +import { canFetchNextDataGridSegment, canGoNextDataGridPage, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal } from "@/lib/dataGrid/dataGridPagination"; import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions"; import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll"; import { CANVAS_DATA_GRID_ROW_HEIGHT, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer"; @@ -2469,6 +2469,15 @@ const canGoNextPage = computed(() => { allRowsLoaded: allRowsLoaded.value, }); }); +const canFetchNextInfiniteScrollSegment = computed(() => + canFetchNextDataGridSegment({ + hasMore: props.result.has_more, + loadedRowCount: props.result.rows.length, + pageSize: pageSize.value, + totalRowCount: hasKnownPaginationTotalRowCount.value ? paginationTotalRowCount.value : undefined, + allRowsLoaded: allRowsLoaded.value, + }), +); const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownPaginationTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql)); const totalRowCountBusy = computed(() => props.totalRowCountLoading === true || manualTotalRowCountLoading.value); const canCalculateTotalRowCount = computed(() => !!props.connectionId && (!!props.tableMeta || !!props.countSql)); @@ -2596,6 +2605,10 @@ function nextPage() { function infiniteScrollNextPage() { if (infiniteScrollLoading.value || props.loading) return; + if (!canFetchNextInfiniteScrollSegment.value) { + infiniteScrollAllLoaded = true; + return; + } const nextOffset = props.result.rows.length; const remainingRows = infiniteScrollMaxRows.value - nextOffset; if (remainingRows <= 0) return; diff --git a/apps/desktop/src/lib/dataGrid/dataGridPagination.ts b/apps/desktop/src/lib/dataGrid/dataGridPagination.ts index eb04e5bd8e..099be5ecde 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridPagination.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridPagination.ts @@ -21,6 +21,14 @@ export interface CompleteLocalDataGridResultOptions { hasMore?: boolean; } +export interface CanFetchNextDataGridSegmentOptions { + hasMore?: boolean; + loadedRowCount: number; + pageSize: number; + totalRowCount?: number; + allRowsLoaded?: boolean; +} + export function resolveDataGridPaginationTotal(options: { paginationTotalRowCount?: number; serverKnownTotalRowCount?: number; totalRowCountIsExact: boolean }): number | undefined { if (options.paginationTotalRowCount !== undefined) return options.paginationTotalRowCount; return options.totalRowCountIsExact ? options.serverKnownTotalRowCount : undefined; @@ -55,3 +63,15 @@ export function canGoNextDataGridPage(options: CanGoNextDataGridPageOptions): bo return options.rowCount >= pageSize; } + +export function canFetchNextDataGridSegment(options: CanFetchNextDataGridSegmentOptions): boolean { + if (options.hasMore === true) return true; + + const totalRowCount = options.totalRowCount; + if (typeof totalRowCount === "number" && Number.isFinite(totalRowCount) && totalRowCount >= 0) { + return options.loadedRowCount < totalRowCount; + } + + if (options.allRowsLoaded === true) return false; + return options.loadedRowCount >= Math.max(1, options.pageSize); +} diff --git a/packages/app-tests/dataGridInfiniteScroll.test.ts b/packages/app-tests/dataGridInfiniteScroll.test.ts index 4894c5749c..277e031f2a 100644 --- a/packages/app-tests/dataGridInfiniteScroll.test.ts +++ b/packages/app-tests/dataGridInfiniteScroll.test.ts @@ -27,6 +27,7 @@ test("near-bottom check matches the grid threshold", () => { test("infinite scroll requests only the next bounded segment", () => { const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); + assert.match(source, /function infiniteScrollNextPage\(\) \{[\s\S]*?if \(!canFetchNextInfiniteScrollSegment\.value\) \{[\s\S]*?infiniteScrollAllLoaded = true;[\s\S]*?return;[\s\S]*?\}/); assert.match(source, /const nextOffset = props\.result\.rows\.length/); assert.match(source, /Math\.min\(pageSize\.value, remainingRows\)/); assert.doesNotMatch(source, /emit\("paginate", 0, cumulativeLimit/); diff --git a/packages/app-tests/dataGridPagination.test.ts b/packages/app-tests/dataGridPagination.test.ts index c3d7bdd356..04feefbbfe 100644 --- a/packages/app-tests/dataGridPagination.test.ts +++ b/packages/app-tests/dataGridPagination.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { canGoNextDataGridPage, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal } from "../../apps/desktop/src/lib/dataGrid/dataGridPagination.ts"; +import { canFetchNextDataGridSegment, canGoNextDataGridPage, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal } from "../../apps/desktop/src/lib/dataGrid/dataGridPagination.ts"; test("estimated display totals do not become pagination bounds", () => { assert.equal( @@ -99,6 +99,22 @@ test("unknown total falls back to full-page heuristic", () => { assert.equal(canGoNextDataGridPage({ rowCount: 0, pageSize: 1 }), false); }); +test("infinite scroll compares cumulative loaded rows with a known total", () => { + assert.equal(canFetchNextDataGridSegment({ loadedRowCount: 1_000, pageSize: 1_000, totalRowCount: 2_000 }), true); + assert.equal(canFetchNextDataGridSegment({ loadedRowCount: 2_000, pageSize: 1_000, totalRowCount: 2_000 }), false); +}); + +test("infinite scroll stops on a short unknown segment and probes a full unknown segment", () => { + assert.equal(canFetchNextDataGridSegment({ loadedRowCount: 673, pageSize: 1_000 }), false); + assert.equal(canFetchNextDataGridSegment({ loadedRowCount: 1_000, pageSize: 1_000 }), true); + assert.equal(canFetchNextDataGridSegment({ loadedRowCount: 2_000, pageSize: 1_000 }), true); +}); + +test("infinite scroll preserves authoritative has-more and complete-local-result signals", () => { + assert.equal(canFetchNextDataGridSegment({ hasMore: true, loadedRowCount: 673, pageSize: 1_000, totalRowCount: 673 }), true); + assert.equal(canFetchNextDataGridSegment({ loadedRowCount: 1_000, pageSize: 1_000, allRowsLoaded: true }), false); +}); + // --- auto-redirect page calculation after refresh --- // These tests document the math used in DataGrid.vue's loading watcher: // lastPageNum = Math.max(1, Math.ceil(total / pageSize))