From 58671d9cc573ce5a149c914fc900a9eec4fa91a9 Mon Sep 17 00:00:00 2001
From: dienaso <89768641@qq.com>
Date: Fri, 16 Oct 2026 12:00:00 +0800
Subject: [PATCH] fix(grid): reload from first row when load-all jumps pages
---
apps/desktop/src/components/grid/DataGrid.vue | 74 ++++++++++++---
.../components/grid/DataGridPagination.vue | 2 +-
.../DataGridPaginationShortcuts.spec.ts | 94 +++++++++++++++++++
.../app-tests/dataGridInfiniteScroll.test.ts | 15 ++-
4 files changed, 166 insertions(+), 19 deletions(-)
diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue
index 6f05176d47..8908cd9167 100644
--- a/apps/desktop/src/components/grid/DataGrid.vue
+++ b/apps/desktop/src/components/grid/DataGrid.vue
@@ -3551,6 +3551,9 @@ let infiniteScrollLoadAllPending = false;
// row cap bounds each request, not the run — the run only stops when the server
// runs out of rows, so a table larger than the cap does not silently stop halfway (#10752).
let loadAllRowsLoopActive = false;
+// Set when a "load all" reloads a jumped-to discrete page from the first row;
+// the replacing result must still land the view on the last row (#11940).
+let loadAllRowsReloadPending = false;
const loadAllRowsActive = ref(false);
// Tracks whether the current loading cycle was triggered by a refresh/rollback
// (as opposed to a normal paginate). Used to decide whether to auto-redirect
@@ -3639,7 +3642,7 @@ const pendingEsDeepPageJump = ref<{ targetPage: number; requestCount: number; up
// large shot needs an explicit confirmation the way ES deep page jumps do.
const LOAD_ALL_ROWS_CONFIRM_ROW_THRESHOLD = 100_000;
const loadAllRowsConfirmOpen = ref(false);
-const pendingLoadAllRows = ref<{ remaining: number }>();
+const pendingLoadAllRows = ref<{ remaining: number; reloadFromFirstRow?: boolean }>();
watch(loadAllRowsConfirmOpen, (open) => {
if (!open) pendingLoadAllRows.value = undefined;
});
@@ -4062,6 +4065,23 @@ function loadAllRowsAndGoToLast() {
// against those cursors is untested, so ES/Easysearch grids keep the
// reveal-only shortcut instead of loading everything.
if (isResultsContext.value && (resolvedDatabaseType.value === "elasticsearch" || resolvedDatabaseType.value === "easysearch")) return;
+ // A result sitting on a jumped-to page is a discrete window, not a
+ // contiguous 0..rows.length prefix: continuing the run from rows.length
+ // would splice rows starting at the wrong offset (issue #11940 — jumping
+ // past the end left a stale page and "load all" then appended from that
+ // stale boundary, corrupting the row set). "Load all" from here means
+ // reloading every row from the first one in a single request.
+ if (!loadAllRowsLoopActive && (props.pageOffset ?? 0) > 0) {
+ const total = paginationTotalRowCount.value ?? displayedTotalRowCount.value;
+ const reloadLimit = typeof total === "number" && Number.isFinite(total) && total > 0 ? total : infiniteScrollMaxRows.value;
+ if (reloadLimit > LOAD_ALL_ROWS_CONFIRM_ROW_THRESHOLD) {
+ pendingLoadAllRows.value = { remaining: reloadLimit, reloadFromFirstRow: true };
+ loadAllRowsConfirmOpen.value = true;
+ return;
+ }
+ startLoadAllRows({ offset: 0, limit: reloadLimit }, true);
+ return;
+ }
const segment = nextLoadAllSegment();
if (!segment) {
loadAllRowsActive.value = true;
@@ -4079,17 +4099,23 @@ function loadAllRowsAndGoToLast() {
startLoadAllRows(segment);
}
-function startLoadAllRows(segment: { offset: number; limit: number }) {
+function startLoadAllRows(segment: { offset: number; limit: number }, reloadFromFirstRow = false) {
loadAllRowsActive.value = true;
- loadAllRowsLoopActive = true;
+ loadAllRowsLoopActive = !reloadFromFirstRow;
+ loadAllRowsReloadPending = reloadFromFirstRow;
infiniteScrollAllLoaded = false;
- infiniteScrollLoadAllPending = true;
- infiniteScrollLoading.value = true;
- isInfiniteScrollPaginating.value = true;
- infiniteScrollRequestedOffset = segment.offset;
- infiniteScrollRequestedLimit = segment.limit;
- currentPage.value++;
- emit("paginate", segment.offset, segment.limit, currentWhereInput(), effectiveOrderBy(), true);
+ // A reload-from-first-row run replaces the page through the normal (non-
+ // append) execution path; the append watcher must not treat it as one of
+ // its own chunks.
+ infiniteScrollLoadAllPending = !reloadFromFirstRow;
+ infiniteScrollLoading.value = !reloadFromFirstRow;
+ isInfiniteScrollPaginating.value = !reloadFromFirstRow;
+ infiniteScrollRequestedOffset = reloadFromFirstRow ? undefined : segment.offset;
+ infiniteScrollRequestedLimit = reloadFromFirstRow ? undefined : segment.limit;
+ if (!reloadFromFirstRow) currentPage.value++;
+ // A reload-from-first-row run replaces the discrete page with the full
+ // result in one request (appendRequested=false); chunked runs append.
+ emit("paginate", segment.offset, segment.limit, currentWhereInput(), effectiveOrderBy(), !reloadFromFirstRow);
}
// Continues an explicit "load all" run chunk by chunk until the server runs
@@ -4136,6 +4162,12 @@ function confirmLoadAllRows() {
if (!pending) return;
pendingLoadAllRows.value = undefined;
loadAllRowsConfirmOpen.value = false;
+ if (pending.reloadFromFirstRow) {
+ const total = paginationTotalRowCount.value ?? displayedTotalRowCount.value;
+ const reloadLimit = typeof total === "number" && Number.isFinite(total) && total > 0 ? total : infiniteScrollMaxRows.value;
+ startLoadAllRows({ offset: 0, limit: reloadLimit }, true);
+ return;
+ }
const segment = nextLoadAllSegment();
if (segment) startLoadAllRows(segment);
}
@@ -4162,6 +4194,7 @@ function changePageSize(size: number) {
infiniteScrollAllLoaded = false;
loadAllRowsActive.value = false;
loadAllRowsLoopActive = false;
+ loadAllRowsReloadPending = false;
infiniteScrollPositions = new WeakMap();
resetGridVerticalScroll(true);
emit("paginate", 0, normalizedSize, currentWhereInput(), effectiveOrderBy());
@@ -5099,6 +5132,7 @@ function resetInfiniteScrollState() {
infiniteScrollLoadAllPending = false;
loadAllRowsActive.value = false;
loadAllRowsLoopActive = false;
+ loadAllRowsReloadPending = false;
infiniteScrollPositions = new WeakMap();
resetGridVerticalScroll(true);
}
@@ -11748,12 +11782,24 @@ watch(
resetDistinctValueCache();
filterValueSuggestionLoader.reset({ clearCache: true });
// A non-append result replaces the whole data set, so a running "load all" is over.
+ const reloadedFromFirstRow = loadAllRowsReloadPending;
+ loadAllRowsReloadPending = false;
loadAllRowsActive.value = false;
loadAllRowsLoopActive = false;
- // The replacement also invalidates the all-loaded marker: a filter change or
- // page jump swaps in a fresh first page whose remaining segments must be
- // re-derived instead of being silently skipped.
- infiniteScrollAllLoaded = false;
+ if (reloadedFromFirstRow) {
+ // A capped/truncated reload must not claim completeness — that would
+ // block the #10752 continuation past the cap; only an uncapped full
+ // reload marks the end (#11940).
+ if (!props.result.truncated && props.result.has_more !== true) {
+ infiniteScrollAllLoaded = true;
+ }
+ selectAndRevealLastLoadedRow();
+ } else {
+ // The replacement also invalidates the all-loaded marker: a filter change or
+ // page jump swaps in a fresh first page whose remaining segments must be
+ // re-derived instead of being silently skipped.
+ infiniteScrollAllLoaded = false;
+ }
if (getResetScrollAfterResult()) {
clearResetScrollAfterResult();
resetGridVerticalScroll();
diff --git a/apps/desktop/src/components/grid/DataGridPagination.vue b/apps/desktop/src/components/grid/DataGridPagination.vue
index 50deab205a..52ea53d45d 100644
--- a/apps/desktop/src/components/grid/DataGridPagination.vue
+++ b/apps/desktop/src/components/grid/DataGridPagination.vue
@@ -117,7 +117,7 @@ function handlePageInputKeydown(event: KeyboardEvent) {
-
+
{{ t("grid.allLoaded") }}
diff --git a/apps/desktop/src/components/grid/__tests__/DataGridPaginationShortcuts.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridPaginationShortcuts.spec.ts
index 7c5beb7f58..74e83e4a10 100644
--- a/apps/desktop/src/components/grid/__tests__/DataGridPaginationShortcuts.spec.ts
+++ b/apps/desktop/src/components/grid/__tests__/DataGridPaginationShortcuts.spec.ts
@@ -475,6 +475,100 @@ describe("DataGrid SQL load-all exhaustion", () => {
});
});
+describe("DataGrid load-all on a jumped-to discrete page (#11940)", () => {
+ it("reloads from the first row instead of splicing from the stale page boundary", async () => {
+ // 30-row table, 10 rows/page, jumped to page 9 (offset 80, past the
+ // end): the grid still holds the stale first page. Continuing the run
+ // from rows.length would append rows starting at the wrong offset and
+ // mark the broken set as fully loaded. "Load all" must reload every row
+ // from row 0 in one non-append request instead.
+ const { host, paginate, gridState } = mountGrid({ pageOffset: 80, totalRowCount: 30 });
+ gridState.value = {
+ context: "table-data",
+ pageLimit: 10,
+ pageOffset: 80,
+ totalRowCount: 30,
+ result: { columns: ["id"], rows: [[1]], has_more: false, affected_rows: 0, execution_time_ms: 0 },
+ };
+ await settle();
+ const button = host.querySelector(`button[aria-label="${i18n.global.t("grid.loadAllAndGoToLastRow")}"]`)!;
+ button.click();
+ await settle();
+
+ expect(paginate).toHaveBeenCalledOnce();
+ // The reload limit comes from the displayed total, which the #10968
+ // reconcile may have extended past the stale COUNT — it only needs to
+ // cover every row; the server stops at the real end.
+ expect(paginate.mock.calls[0]?.[0]).toBe(0);
+ expect(paginate.mock.calls[0]?.[1]).toBeGreaterThanOrEqual(30);
+ expect(paginate.mock.calls[0]?.[4]).toBe(false);
+ });
+
+ it("reloads through the confirm dialog when the reload is large", async () => {
+ const { host, paginate, gridState } = mountGrid({ pageOffset: 80, totalRowCount: 260_000 });
+ gridState.value = {
+ context: "table-data",
+ pageLimit: 10,
+ pageOffset: 80,
+ totalRowCount: 260_000,
+ result: { columns: ["id"], rows: [[1]], has_more: false, affected_rows: 0, execution_time_ms: 0 },
+ };
+ await settle();
+ host.querySelector(`button[aria-label="${i18n.global.t("grid.loadAllAndGoToLastRow")}"]`)!.click();
+ await settle();
+ const confirmButton = Array.from(document.querySelectorAll("button")).find((b) => b.textContent?.trim() === i18n.global.t("grid.loadAllRowsContinue"));
+ expect(confirmButton).toBeDefined();
+ confirmButton!.click();
+ await settle();
+
+ expect(paginate).toHaveBeenCalledOnce();
+ expect(paginate.mock.calls[0]?.[0]).toBe(0);
+ expect(paginate.mock.calls[0]?.[1]).toBeGreaterThanOrEqual(260_000);
+ expect(paginate.mock.calls[0]?.[4]).toBe(false);
+ });
+
+ it("a finished reload lands on the last row, reports all loaded, and restarts row numbers at 1", async () => {
+ const { host, settingsStore, gridState } = mountGrid({ pageOffset: 80, totalRowCount: 30 });
+ // DOM rendering so row numbers and the footer are inspectable.
+ settingsStore.updateEditorSettings({ dataGridRenderMode: "dom" });
+ gridState.value = {
+ context: "table-data",
+ pageLimit: 10,
+ pageOffset: 80,
+ totalRowCount: 30,
+ result: { columns: ["id"], rows: [[1]], has_more: false, affected_rows: 0, execution_time_ms: 0 },
+ };
+ await settle();
+ host.querySelector(`button[aria-label="${i18n.global.t("grid.loadAllAndGoToLastRow")}"]`)!.click();
+ await settle();
+
+ // The reload replaces the discrete page through the normal (non-append)
+ // path: offset 0, all 30 rows, no appended_from_row_count.
+ gridState.value = {
+ context: "table-data",
+ pageLimit: 30,
+ pageOffset: 0,
+ totalRowCount: 30,
+ result: {
+ columns: ["id"],
+ rows: Array.from({ length: 30 }, (_, index) => [index + 1]),
+ has_more: false,
+ affected_rows: 0,
+ execution_time_ms: 0,
+ },
+ };
+ await settle();
+
+ const footer = host.textContent || "";
+ expect(footer).toMatch(/all loaded/i);
+ // Row numbers restart at 1 and never run past the reloaded set.
+ const rowNumbers = [...host.querySelectorAll("*")].map((el) => (el.childElementCount === 0 ? Number(el.textContent) : NaN)).filter((n) => Number.isInteger(n) && n > 0 && n <= 999);
+ expect(rowNumbers.length).toBeGreaterThan(0);
+ expect(Math.max(...rowNumbers)).toBeLessThanOrEqual(30);
+ expect(Math.min(...rowNumbers)).toBe(1);
+ });
+});
+
describe("DataGrid multi-chunk load-all run", () => {
it("keeps loading consecutive chunks across multiple post-flush ticks until total is reached (#11230)", async () => {
const { host, paginate, settingsStore, gridState } = mountGrid({ infiniteScroll: true, pageOffset: 0, totalRowCount: 260_000 });
diff --git a/packages/app-tests/dataGridInfiniteScroll.test.ts b/packages/app-tests/dataGridInfiniteScroll.test.ts
index efa901b29c..8488e739d3 100644
--- a/packages/app-tests/dataGridInfiniteScroll.test.ts
+++ b/packages/app-tests/dataGridInfiniteScroll.test.ts
@@ -54,8 +54,10 @@ test("load-all selects the last loaded row only after a valid append completes",
assert.match(loadAllFn, /if \(!segment\) \{[\s\S]*?selectAndRevealLastLoadedRow\(\);[\s\S]*?return;/);
assert.match(loadAllFn, /resolvedDatabaseType\.value === "elasticsearch" \|\| resolvedDatabaseType\.value === "easysearch"/);
assert.match(loadAllFn, /LOAD_ALL_ROWS_CONFIRM_ROW_THRESHOLD/);
- assert.match(loadAllFn, /infiniteScrollLoadAllPending = true/);
- assert.match(loadAllFn, /emit\("paginate", segment\.offset, segment\.limit, currentWhereInput\(\), effectiveOrderBy\(\), true\)/);
+ assert.match(loadAllFn, /infiniteScrollLoadAllPending = !reloadFromFirstRow/);
+ // A reload of a jumped-to discrete page (#11940) replaces the page through
+ // the non-append path; only chunked runs append.
+ assert.match(loadAllFn, /emit\("paginate", segment\.offset, segment\.limit, currentWhereInput\(\), effectiveOrderBy\(\), !reloadFromFirstRow\)/);
assert.match(source, /\(\) => \[props\.loading, props\.result\.rows\.length, props\.result\.appended_from_row_count\] as const[\s\S]*?\{ flush: "post" \}/);
assert.match(source, /props\.result\.appended_from_row_count !== requestedOffset[\s\S]*?return;[\s\S]*?if \(shouldSelectLastRow\) selectAndRevealLastLoadedRow\(\)/);
assert.match(source, /function selectAndRevealLastLoadedRow\(\)[\s\S]*?selectRow\(rowIndex\)[\s\S]*?remainingFrames = 12[\s\S]*?scrollCanvasRowIntoView\(rowIndex, "end"\)[\s\S]*?scrollDomRowIntoView\(rowIndex, "end"\)[\s\S]*?requestAnimationFrame\(revealWhenReady\)/);
@@ -69,8 +71,13 @@ test("load-all selects the last loaded row only after a valid append completes",
assert.equal(contentAreaSource.match(/emit\('paginate', activeTab\.id, offset, limit, whereInput, orderBy, appendResult\)/g)?.length, 2);
assert.match(documentBrowserSource, /:load-all-rows-enabled="false"/);
// A non-append result replacement must clear the all-loaded marker (stale all-loaded
- // used to lock scrolling and degrade the load-all button to locate-only after WHERE edits).
+ // used to lock scrolling and degrade the load-all button to locate-only after WHERE edits) —
+ // except for a #11940 reload of a jumped-to discrete page, which owns the full result
+ // and keeps the marker when its (uncapped) result is complete.
const nonAppendIdx = source.indexOf("// A non-append result replaces the whole data set");
assert.ok(nonAppendIdx > 0, "non-append replacement branch must exist");
- assert.match(source.slice(nonAppendIdx, nonAppendIdx + 420), /infiniteScrollAllLoaded = false;/);
+ const window = source.slice(nonAppendIdx, nonAppendIdx + 1100);
+ assert.match(window, /if \(reloadedFromFirstRow\) \{[\s\S]*?infiniteScrollAllLoaded = true;[\s\S]*?selectAndRevealLastLoadedRow\(\);[\s\S]*?\} else \{[\s\S]*?infiniteScrollAllLoaded = false;/);
+ // The reload must not claim completeness over a capped/truncated result.
+ assert.match(window, /truncated && props\.result\.has_more !== true/);
});