fix(grid): append infinite scroll result segments

This commit is contained in:
t8y2 committed 2026-07-21 18:45:02 +08:00
1 parent 678033c82e
commit c153e93d54
9 files changed
+303 -30

No files matched your search

+26 -12
View File
@@ -2269,6 +2269,8 @@ const isInfiniteScrollPaginating = ref(false);
let lastInfiniteScrollPage = 0;
let infiniteScrollCheckScheduled = false;
let infiniteScrollAllLoaded = false;
let infiniteScrollRequestedOffset: number | undefined;
let infiniteScrollRequestedLimit: number | undefined;
// 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
// when the current page no longer exists after data was deleted.
@@ -2313,9 +2315,19 @@ watch(
if (prevLoading && !loading && infiniteScrollLoading.value) {
infiniteScrollLoading.value = false;
isInfiniteScrollPaginating.value = false;
// Detect if the backend returned no new data for this page
const expectedRows = currentPage.value * pageSize.value;
if (props.result.rows.length < expectedRows) {
const requestedOffset = infiniteScrollRequestedOffset;
const requestedLimit = infiniteScrollRequestedLimit;
infiniteScrollRequestedOffset = undefined;
infiniteScrollRequestedLimit = undefined;
if (requestedOffset === undefined || props.result.appended_from_row_count !== requestedOffset) {
// Failed/stale append requests preserve the old result. Roll back the
// optimistic page marker so a later scroll can retry the same segment.
currentPage.value = Math.max(1, currentPage.value - 1);
lastInfiniteScrollPage = Math.max(0, currentPage.value - 1);
return;
}
const appendedRows = props.result.rows.length - requestedOffset;
if (props.result.rows.length >= infiniteScrollMaxRows.value || appendedRows < (requestedLimit ?? pageSize.value)) {
infiniteScrollAllLoaded = true;
}
}
@@ -2489,21 +2501,21 @@ function nextPage() {
function infiniteScrollNextPage() {
if (infiniteScrollLoading.value || props.loading) return;
const nextOffset = props.result.rows.length;
const remainingRows = infiniteScrollMaxRows.value - nextOffset;
if (remainingRows <= 0) return;
const nextLimit = Math.min(pageSize.value, remainingRows);
const nextPageNum = currentPage.value + 1;
const cumulativeLimit = nextPageNum * pageSize.value;
if (cumulativeLimit > infiniteScrollMaxRows.value) return;
// Stop if we already know all data is loaded
if (infiniteScrollAllLoaded) return;
// Skip if we already have this many rows loaded (e.g. cached data)
if (props.result.rows.length >= cumulativeLimit) {
currentPage.value = nextPageNum;
return;
}
infiniteScrollLoading.value = true;
isInfiniteScrollPaginating.value = true;
infiniteScrollRequestedOffset = nextOffset;
infiniteScrollRequestedLimit = nextLimit;
currentPage.value = nextPageNum;
// Load cumulative data (all rows up to current page) to append instead of replace
emit("paginate", 0, cumulativeLimit, currentWhereInput(), currentOrderBy());
// Fetch only the missing segment. Re-reading offset 0 grows transfer and replaces
// row identities, which would invalidate pending edits while the user scrolls.
emit("paginate", nextOffset, nextLimit, currentWhereInput(), currentOrderBy());
}
function checkInfiniteScroll(scroller: HTMLElement) {
if (!infiniteScrollEnabled.value || infiniteScrollLoading.value || props.loading) return;
@@ -2969,6 +2981,8 @@ function resetInfiniteScrollState() {
currentPage.value = 1;
lastInfiniteScrollPage = 0;
infiniteScrollAllLoaded = false;
infiniteScrollRequestedOffset = undefined;
infiniteScrollRequestedLimit = undefined;
isInfiniteScrollPaginating.value = false;
infiniteScrollLoading.value = false;
infiniteScrollPositions = new WeakMap();
@@ -247,13 +247,16 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
async function onPaginate(offset: number, limit: number, whereInput?: string, orderBy?: string) {
const tab = activeTab.value;
if (!tab) return;
const appendResult = settingsStore.editorSettings.infiniteScroll && offset > 0 && offset === tab.result?.rows.length;
const appendOptions = appendResult ? { appendResult: { maxRows: settingsStore.editorSettings.infiniteScrollMaxRows } } : {};
if (tab.mode !== "data") {
const sortColumns = visibleQuerySortColumns(tab.result?.columns ?? [], tab.result?.hidden_column_indexes, tab.resultSortColumnIndex ?? -1);
const hasDatabaseSort = !!tab.result?.hidden_column_indexes?.length && tab.resultSortMode === "database" && !!tab.resultSortDirection && !!tab.resultSortColumn && !!sortColumns;
const baseSql = hasDatabaseSort ? queryResultBaseSql(tab) : queryResultExecutionSql(tab);
if (!baseSql.trim()) return;
const expectedNextOffset = (tab.resultPageOffset ?? 0) + (tab.resultPageLimit ?? limit);
const sessionId = tab.result?.has_more && tab.result?.session_id && offset === expectedNextOffset && limit === tab.resultPageLimit ? tab.result.session_id : undefined;
const expectedNextOffset = appendResult ? tab.result?.rows.length : (tab.resultPageOffset ?? 0) + (tab.resultPageLimit ?? limit);
const continuesResultSession = appendResult ? offset === expectedNextOffset : offset === expectedNextOffset && limit === tab.resultPageLimit;
const sessionId = tab.result?.has_more && tab.result?.session_id && continuesResultSession ? tab.result.session_id : undefined;
const resultBaseSql = queryResultBaseSql(tab);
await queryStore.executeTabSql(tab.id, baseSql, {
resultBaseSql,
@@ -269,6 +272,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
}
: {}),
pagination: { offset, limit, sessionId },
...appendOptions,
preserveResultDuringExecution: true,
preserveTotalRowCountDuringExecution: true,
replaceActiveResultInGroup: true,
@@ -282,6 +286,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
queryStore.updateSql(tab.id, sql);
await queryStore.executeTabSql(tab.id, sql, {
pagination: { offset, limit },
...appendOptions,
preserveResultDuringExecution: true,
preserveTotalRowCountDuringExecution: true,
});
@@ -1,4 +1,4 @@
import { ref, computed, nextTick, watch, getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, onMounted, type ComputedRef, type Ref } from "vue";
import { ref, computed, nextTick, watch, getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, onMounted, toRaw, type ComputedRef, type Ref } from "vue";
import * as api from "@/lib/backend/api";
import type { CellValue } from "@/lib/dataGrid/cellValue";
import { coerceDataGridCellValue, dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion";
@@ -139,10 +139,15 @@ const closingPendingSnapshotTabs = new Set<string>();
const BEFORE_TAB_SWITCH_EVENT = "dbx:before-tab-switch";
const MAX_PENDING_CHANGES_HISTORY = 100;
function dataGridRowsIdentityChanged(previousRows: CellValue[][] | undefined, nextRows: CellValue[][]): boolean {
function dataGridRowsIdentityChanged(previousRows: CellValue[][] | undefined, nextRows: CellValue[][], appendedFromRowCount?: number): boolean {
if (!previousRows) return true;
if (previousRows.length !== nextRows.length) return true;
return previousRows.some((row, index) => row !== nextRows[index]);
if (appendedFromRowCount !== previousRows.length || previousRows.length > nextRows.length) {
if (previousRows.length !== nextRows.length) return true;
return previousRows.some((row, index) => toRaw(row) !== toRaw(nextRows[index]));
}
// Infinite scrolling appends rows without changing existing source indexes.
// Preserve pending edits only when every previously loaded row is the same object.
return previousRows.some((row, index) => toRaw(row) !== toRaw(nextRows[index]));
}
function cacheKeyBelongsToTab(cacheKey: string, tabId: string) {
@@ -1334,13 +1339,13 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
exitTransaction();
}
// Pending changes reference rows by sourceIndex. When the result set changes
// (e.g. different WHERE clause, pagination), stale indices point to wrong rows.
// Pending changes reference rows by sourceIndex. Replacements (different WHERE,
// sort, normal pagination, refresh) invalidate them; prefix-only appends do not.
let previousResultRows = result.value.rows;
watch(
() => result.value.rows,
(rows) => {
if (!dataGridRowsIdentityChanged(previousResultRows, rows)) {
() => [result.value.rows, (result.value as { appended_from_row_count?: number }).appended_from_row_count] as const,
([rows, appendedFromRowCount]) => {
if (!dataGridRowsIdentityChanged(previousResultRows, rows, appendedFromRowCount)) {
previousResultRows = rows;
return;
}
+51 -5
View File
@@ -131,6 +131,30 @@ function markQueryResultsRowsRaw(results: QueryResult[]): QueryResult[] {
return results;
}
function appendQueryResultSegment(previous: QueryResult, segment: QueryResult, maxRows: number): QueryResult {
if (segment.execution_error) throw new Error(String(segment.rows[0]?.[0] ?? "Failed to load the next result segment"));
if (previous.columns.length !== segment.columns.length || previous.columns.some((column, index) => column !== segment.columns[index])) {
throw new Error("Result columns changed while loading the next segment");
}
const remainingRows = Math.max(0, maxRows - previous.rows.length);
const appendedRowCount = Math.min(remainingRows, segment.rows.length);
const appendParallelValues = <T>(existing: T[] | undefined, next: T[] | undefined): T[] | undefined => {
if (!existing && !next) return undefined;
return [...(existing ?? []), ...(next ?? []).slice(0, appendedRowCount)];
};
// Keep prior row objects intact so source-index based dirty/new/deleted state
// remains valid, while bounding the in-memory result by the configured cap.
return markQueryResultRowsRaw({
...segment,
appended_from_row_count: previous.rows.length,
rows: [...previous.rows, ...segment.rows.slice(0, appendedRowCount)],
mongo_documents: appendParallelValues(previous.mongo_documents, segment.mongo_documents),
mongo_copy_documents: appendParallelValues(previous.mongo_copy_documents, segment.mongo_copy_documents),
execution_time_ms: (previous.execution_time_ms ?? 0) + (segment.execution_time_ms ?? 0),
has_more: previous.rows.length + appendedRowCount >= maxRows ? false : segment.has_more,
});
}
function markQueryResultRunsRowsRaw(resultRuns: NonNullable<QueryTab["resultRuns"]>): NonNullable<QueryTab["resultRuns"]> {
for (const run of resultRuns) {
if (run.result) markQueryResultRowsRaw(run.result);
@@ -2797,6 +2821,7 @@ export const useQueryStore = defineStore("query", () => {
direction: "asc" | "desc";
};
pagination?: { limit: number; offset: number; sessionId?: string };
appendResult?: { maxRows: number };
mongoSafety?: MongoAggregateSafetyOptions;
preserveResultDuringExecution?: boolean;
preserveTotalRowCountDuringExecution?: boolean;
@@ -3405,8 +3430,20 @@ export const useQueryStore = defineStore("query", () => {
if (current?.executionId === executionId) {
const activeGroupIndex = current.activeResultIndex;
const activeGroupResults = current.results;
const shouldAppendResult = !!options?.appendResult && !!current.result;
const shouldReplaceActiveResultInGroup = options?.replaceActiveResultInGroup === true && results.length === 1 && Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length;
if (shouldReplaceActiveResultInGroup) {
if (shouldAppendResult) {
if (results.length !== 1) throw new Error("Expected one result while loading the next segment");
if (options.pagination?.offset !== current.result!.rows.length) {
throw new Error("Ignoring a stale result segment whose offset no longer matches the loaded rows");
}
const appendedResult = appendQueryResultSegment(current.result!, results[0]!, options.appendResult!.maxRows);
if (Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length) {
current.results = activeGroupResults.slice();
current.results[activeGroupIndex] = appendedResult;
}
current.result = appendedResult;
} else if (shouldReplaceActiveResultInGroup) {
current.results = activeGroupResults.slice();
current.results[activeGroupIndex] = results[0];
current.result = results[0];
@@ -3425,9 +3462,12 @@ export const useQueryStore = defineStore("query", () => {
current.resultBaseSql = shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql;
current.resultEditorFingerprint = shouldReplaceActiveResultInGroup ? (current.resultEditorFingerprint ?? executionEditorFingerprint) : executionEditorFingerprint;
current.resultSortedSql = resultSortedSql;
current.resultPageSql = pageSql;
// Appended rows form one logical result starting at the original page.
// Keep the base page state so later table refresh/cache recovery does
// not re-execute only the most recently fetched tail segment.
current.resultPageSql = shouldAppendResult ? (current.resultPageSql ?? pageSql) : pageSql;
current.resultPageLimit = pageLimit;
current.resultPageOffset = pageOffset;
current.resultPageOffset = shouldAppendResult ? (current.resultPageOffset ?? 0) : pageOffset;
current.resultCountSql = countSql;
current.resultSessionId = current.result?.session_id ?? undefined;
if (!options?.preserveTotalRowCountDuringExecution) {
@@ -3451,7 +3491,7 @@ export const useQueryStore = defineStore("query", () => {
};
})()
: undefined;
const canAutoCalculateTotalRows = !!current.result && resultRowCount > 0 && !totalKnownFromIncompletePage && settingsStore.editorSettings.autoCalculateTotalRows && ((current.mode === "query" && !!countSql) || (current.mode === "data" && !!dataCountTarget));
const canAutoCalculateTotalRows = !options?.appendResult && !!current.result && resultRowCount > 0 && !totalKnownFromIncompletePage && settingsStore.editorSettings.autoCalculateTotalRows && ((current.mode === "query" && !!countSql) || (current.mode === "data" && !!dataCountTarget));
current.resultTotalRowCountLoading = canAutoCalculateTotalRows;
// Server-side pagination without a countSql: the backend (currently
// the Elasticsearch driver) already reports the true match total via
@@ -3465,7 +3505,7 @@ export const useQueryStore = defineStore("query", () => {
}
touchResult(current);
syncDisplayedResultRun(current, queryBaseSql);
if (!totalRowCountResolved && (current.mode === "query" || current.mode === "data") && current.result) {
if (!options?.appendResult && !totalRowCountResolved && (current.mode === "query" || current.mode === "data") && current.result) {
countQueryTotalRowsInBackground({
tabId: id,
connectionId: current.connectionId,
@@ -3520,6 +3560,12 @@ export const useQueryStore = defineStore("query", () => {
}
const current = tabs.value.find((t) => t.id === id);
if (current?.executionId === executionId) {
if (options?.appendResult && current.result) {
// A failed background segment must not replace the visible result or
// silently invalidate pending edits. The next explicit refresh can retry.
queryExecutionLog("warn", "append-result:preserved-after-error", { traceId, elapsed: elapsed() });
return;
}
const errorResult = toErrorResult(e);
const activeGroupIndex = current.activeResultIndex;
const activeGroupResults = current.results;
+2
View File
@@ -500,6 +500,8 @@ export interface OwnerInfo {
export interface QueryResult {
columns: string[];
/** Internal marker for a result built by appending a page to existing rows. */
appended_from_row_count?: number;
/** Set for synthesized query execution failures. */
execution_error?: true;
/** Zero-based index of the submitted statement that produced this result. */