fix(grid): reset scroll to top on external result replacement

Results replaced without an internal grid action (SQL re-executed from
the editor or a grid-embedded run button) kept the previous viewport, so
DOM-mode grids stayed stranded at the old scroll offset after running an
ORDER BY query. The result watch now resets to the first row unless the
replacement is an in-place refresh (prepareFullReload markers) or an
infinite-scroll append completion. A remounted grid also no longer
adopts a scroll-only pending-changes snapshot; cached scroll is only
replayed alongside unsaved edit state, while the KeepAlive reactivate
path still restores pure scroll.

Closes #7341
This commit is contained in:
t8y2 committed 2026-08-27 16:33:23 +08:00
1 parent 4eeeafa858
commit e8d6370b1e
4 files changed
+173 -1

No files matched your search

@@ -10061,6 +10061,11 @@ watch(
() => props.result,
(result, previousResult) => {
const selectionSnapshot = preservedSelectionOnNextResult?.selection;
// The four preserved markers are armed together by prepareFullReload (toolbar
// refresh / rollback / row-save reload). Any of them being pending means the
// incoming result is an in-place refresh that must keep the viewport, so the
// check has to run before the markers are consumed below.
const inPlaceRefreshPending = preservedSelectionOnNextResult !== null || preservedViewportAnchorOnNextResult !== null || preservedDetailsOnNextResult !== null || preserveTransposeOnNextResult.value;
preservedSelectionOnNextResult = null;
const viewportAnchorSnapshot = preservedViewportAnchorOnNextResult?.anchor;
preservedViewportAnchorOnNextResult = null;
@@ -10087,6 +10092,12 @@ watch(
if (getResetScrollAfterResult()) {
clearResetScrollAfterResult();
resetGridVerticalScroll();
} else if (!inPlaceRefreshPending) {
// A result replaced without an internal grid action (e.g. SQL re-executed
// from the editor or a grid-embedded run button) is a brand-new dataset:
// start from the first row instead of stranding the viewport at the old
// scroll offset (#7341). In-place refreshes opt out via prepareFullReload.
resetGridVerticalScroll();
}
closeReadonlyCellTextSelection();
clearCellSelection();
@@ -0,0 +1,56 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
function resultWatchSource(): string {
const start = dataGridSource.indexOf("watch(\n () => props.result,\n (result, previousResult) => {");
const end = dataGridSource.indexOf("// --- Context menu handlers ---");
expect(start).toBeGreaterThanOrEqual(0);
expect(end).toBeGreaterThan(start);
return dataGridSource.slice(start, end);
}
describe("DataGrid result-replacement vertical scroll", () => {
it("resets to the first row when the result is replaced without an internal grid action", () => {
const watchSource = resultWatchSource();
expect(watchSource).toContain("} else if (!inPlaceRefreshPending) {");
const externalBranch = watchSource.slice(watchSource.indexOf("} else if (!inPlaceRefreshPending) {"));
expect(externalBranch.indexOf("resetGridVerticalScroll();")).toBeGreaterThanOrEqual(0);
expect(externalBranch).toContain("#7341");
});
it("keeps the internal-action reset flag path unchanged", () => {
const watchSource = resultWatchSource();
expect(watchSource).toContain("if (getResetScrollAfterResult()) {\n clearResetScrollAfterResult();\n resetGridVerticalScroll();");
});
it("treats any pending prepareFullReload marker as an in-place refresh that keeps the viewport", () => {
const watchSource = resultWatchSource();
expect(watchSource).toContain("preservedSelectionOnNextResult !== null || preservedViewportAnchorOnNextResult !== null || preservedDetailsOnNextResult !== null || preserveTransposeOnNextResult.value");
// The pending-marker check must run before the watch consumes the markers.
const pendingCheck = watchSource.indexOf("const inPlaceRefreshPending =");
const selectionConsume = watchSource.indexOf("preservedSelectionOnNextResult = null;");
const anchorConsume = watchSource.indexOf("preservedViewportAnchorOnNextResult = null;");
const detailsConsume = watchSource.indexOf("preservedDetailsOnNextResult = null;");
const transposeConsume = watchSource.indexOf("preserveTransposeOnNextResult.value = false;");
expect(pendingCheck).toBeGreaterThanOrEqual(0);
for (const consume of [selectionConsume, anchorConsume, detailsConsume, transposeConsume]) {
expect(consume).toBeGreaterThan(pendingCheck);
}
});
it("returns before any scroll reset when the replacement completes an infinite-scroll append", () => {
const watchSource = resultWatchSource();
const appendReturn = watchSource.indexOf("if (appendCompletion) {");
const appendEarlyReturn = watchSource.indexOf("return;\n }");
const resetBranch = watchSource.indexOf("if (getResetScrollAfterResult()) {");
expect(appendReturn).toBeGreaterThanOrEqual(0);
expect(appendEarlyReturn).toBeGreaterThan(appendReturn);
expect(resetBranch).toBeGreaterThan(appendEarlyReturn);
});
});