fix(grid): reload from first row when load-all jumps pages

This commit is contained in:
dienaso
2026-10-16 12:00:00 +08:00
committed by GitHub
parent 0264cfbb03
commit 58671d9cc5
4 changed files with 166 additions and 19 deletions
+60 -14
View File
@@ -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();
@@ -117,7 +117,7 @@ function handlePageInputKeydown(event: KeyboardEvent) {
</div>
</div>
<Loader2 class="w-3 h-3 text-muted-foreground" :class="loading ? 'animate-spin' : 'invisible'" aria-hidden="true" />
<template v-if="paginationEnabled && (infiniteScrollEnabled || loadAllRowsActive)">
<template v-if="paginationEnabled && (infiniteScrollEnabled || loadAllRowsActive || infiniteScrollAllLoaded)">
<span v-if="infiniteScrollAllLoaded" class="text-xs text-muted-foreground shrink-0">{{ t("grid.allLoaded") }}</span>
</template>
<template v-if="paginationEnabled && !infiniteScrollEnabled && !loadAllRowsActive">
@@ -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<HTMLButtonElement>(`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<HTMLButtonElement>(`button[aria-label="${i18n.global.t("grid.loadAllAndGoToLastRow")}"]`)!.click();
await settle();
const confirmButton = Array.from(document.querySelectorAll<HTMLButtonElement>("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<HTMLButtonElement>(`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 });
+11 -4
View File
@@ -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/);
});