mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 03:21:53 +00:00
fix(grid): reload from first row when load-all jumps pages
This commit is contained in:
@@ -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">
|
||||
|
||||
+94
@@ -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
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user