fix(grid): preserve data tab scroll and search across switches

This commit is contained in:
onenewcode authored and GitHub committed 2026-09-09 12:01:49 +08:00
1 parent ed29ef9eed
commit 451e50b2fb
11 files changed
+652 -15

No files matched your search

@@ -38,7 +38,7 @@ vi.mock("@/stores/productionSafetyStore", () => ({
useProductionSafetyStore: () => ({}),
}));
function createEditor(
function createEditorWithResult(
sourceColumns?: Array<string | undefined>,
confirmDangerousRowDeletion = true,
cacheKey?: string,
@@ -125,7 +125,33 @@ function createEditor(
});
editor.newRows.value = [[null, null, null]];
return editor;
return { editor, result };
}
function createEditor(...args: Parameters<typeof createEditorWithResult>) {
return createEditorWithResult(...args).editor;
}
function beforeTabSwitchEvent(fromTabId: string, tabId = "next-tab") {
return new CustomEvent("dbx:before-tab-switch", { detail: { tabId, fromTabId } });
}
function setupTabSwitchScrollFixture() {
class TestScroller {
scrollTop = 0;
scrollLeft = 0;
scrollTo({ top, left }: ScrollToOptions) {
if (typeof top === "number") this.scrollTop = top;
if (typeof left === "number") this.scrollLeft = left;
}
}
vi.stubGlobal("HTMLElement", TestScroller);
const rows: CellValue[][] = [
["a", null, 1],
["b", null, 2],
["c", null, 3],
];
return { TestScroller, rows };
}
describe("useDataGridEditor result snapshots", () => {
@@ -200,6 +226,112 @@ describe("useDataGridEditor result snapshots", () => {
expect(remountedScroller.scrollLeft).toBe(0);
});
it("adopts a scroll-only snapshot when the previous instance was torn down by a tab switch (#8524)", () => {
const { TestScroller, rows } = setupTabSwitchScrollFixture();
const key = "table-tab-8524";
const previous = createEditor(undefined, true, key, undefined, rows);
previous.newRows.value = [];
const previousScroller = new TestScroller();
previousScroller.scrollTop = 6_400;
previousScroller.scrollLeft = 24;
previous.scrollerRef.value = previousScroller as unknown as NonNullable<typeof previous.scrollerRef.value>;
previous.onBeforeTabSwitch(beforeTabSwitchEvent(key));
// Unmount path runs right after the switch and must not clobber the provenance.
previous.savePendingSnapshot(true, true);
const remounted = createEditor(undefined, true, key, undefined, rows);
const remountedScroller = new TestScroller();
remounted.scrollerRef.value = remountedScroller as unknown as NonNullable<typeof remounted.scrollerRef.value>;
remounted.restorePendingSnapshotFocus();
expect(remountedScroller.scrollTop).toBe(6_400);
expect(remountedScroller.scrollLeft).toBe(24);
});
it("ignores a tab switch that names a different tab", () => {
const { TestScroller, rows } = setupTabSwitchScrollFixture();
const key = "table-tab-other-group";
const previous = createEditor(undefined, true, key, undefined, rows);
previous.newRows.value = [];
const previousScroller = new TestScroller();
previousScroller.scrollTop = 6_400;
previousScroller.scrollLeft = 24;
previous.scrollerRef.value = previousScroller as unknown as NonNullable<typeof previous.scrollerRef.value>;
// Split groups keep several grids mounted; this one did not change tabs.
previous.onBeforeTabSwitch(beforeTabSwitchEvent("some-other-tab"));
previous.savePendingSnapshot(true, true);
const remounted = createEditor(undefined, true, key, undefined, rows);
const remountedScroller = new TestScroller();
remounted.scrollerRef.value = remountedScroller as unknown as NonNullable<typeof remounted.scrollerRef.value>;
remounted.restorePendingSnapshotFocus();
expect(remountedScroller.scrollTop).toBe(0);
expect(remountedScroller.scrollLeft).toBe(0);
});
it("ignores a tab switch event without a fromTabId", () => {
const { TestScroller, rows } = setupTabSwitchScrollFixture();
const key = "table-tab-no-origin";
const previous = createEditor(undefined, true, key, undefined, rows);
previous.newRows.value = [];
const previousScroller = new TestScroller();
previousScroller.scrollTop = 6_400;
previousScroller.scrollLeft = 24;
previous.scrollerRef.value = previousScroller as unknown as NonNullable<typeof previous.scrollerRef.value>;
previous.onBeforeTabSwitch(new CustomEvent("dbx:before-tab-switch", { detail: { tabId: "next-tab" } }));
previous.savePendingSnapshot(true, true);
const remounted = createEditor(undefined, true, key, undefined, rows);
const remountedScroller = new TestScroller();
remounted.scrollerRef.value = remountedScroller as unknown as NonNullable<typeof remounted.scrollerRef.value>;
remounted.restorePendingSnapshotFocus();
expect(remountedScroller.scrollTop).toBe(0);
});
it("drops the tab-switch scroll once the result identity changes", async () => {
const { TestScroller, rows } = setupTabSwitchScrollFixture();
const key = "table-tab-reloaded";
const { editor: previous, result } = createEditorWithResult(undefined, true, key, undefined, rows);
previous.newRows.value = [];
const previousScroller = new TestScroller();
previousScroller.scrollTop = 6_400;
previousScroller.scrollLeft = 24;
previous.scrollerRef.value = previousScroller as unknown as NonNullable<typeof previous.scrollerRef.value>;
previous.onBeforeTabSwitch(beforeTabSwitchEvent(key));
previous.savePendingSnapshot(true, true);
// A reload lands a new row array: the grid must start at the first row (#7341).
result.value = { columns: ["first", "hidden", "last"], rows: rows.map((row) => [...row]) };
await nextTick();
previous.savePendingSnapshot(true, true);
const remounted = createEditor(undefined, true, key, undefined, rows);
const remountedScroller = new TestScroller();
remounted.scrollerRef.value = remountedScroller as unknown as NonNullable<typeof remounted.scrollerRef.value>;
remounted.restorePendingSnapshotFocus();
expect(remountedScroller.scrollTop).toBe(0);
});
it("clears the tab-switch provenance when the grid is explicitly scrolled to the top", () => {
const { TestScroller, rows } = setupTabSwitchScrollFixture();
const key = "table-tab-reset-scroll";
const previous = createEditor(undefined, true, key, undefined, rows);
previous.newRows.value = [];
const previousScroller = new TestScroller();
previousScroller.scrollTop = 6_400;
previousScroller.scrollLeft = 24;
previous.scrollerRef.value = previousScroller as unknown as NonNullable<typeof previous.scrollerRef.value>;
previous.onBeforeTabSwitch(beforeTabSwitchEvent(key));
// Sort/filter/paginate/refresh all route through here and mean "new viewport".
previous.resetGridVerticalScroll(true);
previous.savePendingSnapshot(true, true);
const remounted = createEditor(undefined, true, key, undefined, rows);
const remountedScroller = new TestScroller();
remounted.scrollerRef.value = remountedScroller as unknown as NonNullable<typeof remounted.scrollerRef.value>;
remounted.restorePendingSnapshotFocus();
expect(remountedScroller.scrollTop).toBe(0);
});
it("still adopts the cached scroll when the snapshot carries pending edits", () => {
class TestScroller {
scrollTop = 0;
@@ -1,6 +1,7 @@
import { nextTick, ref } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDataGridSearch } from "@/composables/useDataGridSearch";
import { clearDataGridSearchStates, createDataGridSearchScopeKey, loadDataGridSearchState, saveDataGridSearchState } from "@/lib/dataGrid/dataGridSearchStatePersistence";
afterEach(() => {
vi.useRealTimers();
@@ -123,3 +124,133 @@ describe("useDataGridSearch", () => {
expect(search.currentMatchIndex.value).toBe(-1);
});
});
describe("useDataGridSearch persistence (#8524)", () => {
const COLUMNS = ["left", "right"];
const SCOPE = createDataGridSearchScopeKey(COLUMNS);
const ROWS = [
["hit", "hit"],
["none", "hit"],
];
function createSearch({ onNavigate = vi.fn(), persistenceKey = "tab-1" as string | undefined } = {}) {
const search = useDataGridSearch({
columns: COLUMNS,
rows: ROWS,
getCellSearchText: (row: string[], column: number) => row[column],
onNavigate,
persistenceKey,
persistenceScopeKey: () => SCOPE,
});
return { search, onNavigate };
}
beforeEach(() => clearDataGridSearchStates());
it("persists the query, overlay and match index as the user searches", async () => {
vi.useFakeTimers();
const { search } = createSearch();
search.overlayVisible.value = true;
search.searchText.value = "hit";
await flushSearchDebounce();
search.navigateMatch(1);
await nextTick();
const saved = loadDataGridSearchState("tab-1", SCOPE);
expect(saved).toMatchObject({ searchText: "hit", deferredSearchText: "hit", overlayVisible: true, currentMatchIndex: 1 });
});
it("restores text, overlay and match index without auto-navigating or debouncing", async () => {
const { search, onNavigate } = createSearch();
saveDataGridSearchState("tab-1", { scopeKey: SCOPE, searchText: "hit", deferredSearchText: "hit", overlayVisible: true, currentMatchIndex: 2 });
expect(search.restorePersistedState()).toBe(true);
// No advanceTimersByTime: the restore bypasses the debounce entirely.
await nextTick();
await nextTick();
expect(search.overlayVisible.value).toBe(true);
expect(search.deferredSearchText.value).toBe("hit");
expect(search.matchCount.value).toBe(3);
expect(search.currentMatchIndex.value).toBe(2);
// The grid's own scroll restore owns the viewport when returning to a tab.
expect(onNavigate).not.toHaveBeenCalled();
});
it("clamps a stale restored match index", async () => {
const { search } = createSearch();
saveDataGridSearchState("tab-1", { scopeKey: SCOPE, searchText: "none", deferredSearchText: "none", overlayVisible: true, currentMatchIndex: 9 });
search.restorePersistedState();
await nextTick();
await nextTick();
expect(search.matchCount.value).toBe(1);
expect(search.currentMatchIndex.value).toBe(0);
});
it("does not reopen the suggestion popup on restore", async () => {
const { search } = createSearch();
saveDataGridSearchState("tab-1", { scopeKey: SCOPE, searchText: "le", deferredSearchText: "le", overlayVisible: true, currentMatchIndex: 0 });
search.restorePersistedState();
await nextTick();
// "le" prefixes the "left" column, so the non-restore path would suggest it.
expect(search.suggestions.value).toEqual([]);
expect(search.suggestionIndex.value).toBe(-1);
});
it("does not restore across a column signature change", () => {
const { search } = createSearch();
saveDataGridSearchState("tab-1", { scopeKey: createDataGridSearchScopeKey(["renamed"]), searchText: "hit", deferredSearchText: "hit", overlayVisible: true, currentMatchIndex: 0 });
expect(search.restorePersistedState()).toBe(false);
expect(search.searchText.value).toBe("");
});
it("lets a keystroke during the restore window cancel the token", async () => {
vi.useFakeTimers();
const { search, onNavigate } = createSearch();
saveDataGridSearchState("tab-1", { scopeKey: SCOPE, searchText: "hit", deferredSearchText: "hit", overlayVisible: true, currentMatchIndex: 2 });
search.restorePersistedState();
search.searchText.value = "none";
await flushSearchDebounce();
// Normal typing behaviour resumes: debounced, auto-navigated, index reset.
expect(search.deferredSearchText.value).toBe("none");
expect(search.currentMatchIndex.value).toBe(0);
expect(onNavigate).toHaveBeenCalled();
});
it("clears the persisted entry when the search bar is closed", async () => {
vi.useFakeTimers();
const { search } = createSearch();
search.overlayVisible.value = true;
search.searchText.value = "hit";
await flushSearchDebounce();
expect(loadDataGridSearchState("tab-1", SCOPE)).toBeDefined();
search.close();
await nextTick();
expect(loadDataGridSearchState("tab-1", SCOPE)).toBeUndefined();
});
it("opts out entirely when the host passes no persistence key", async () => {
vi.useFakeTimers();
// Built without the helper: an explicit `undefined` argument would hit its
// default and silently re-enable persistence.
const search = useDataGridSearch({
columns: COLUMNS,
rows: ROWS,
getCellSearchText: (row: string[], column: number) => row[column],
});
search.overlayVisible.value = true;
search.searchText.value = "hit";
await flushSearchDebounce();
expect(search.restorePersistedState()).toBe(false);
expect(loadDataGridSearchState("tab-1", SCOPE)).toBeUndefined();
});
});
@@ -141,6 +141,12 @@ interface PendingChangesSnapshot {
editValue?: string;
transactionActive?: boolean;
scroll?: { top: number; left: number };
// True only when this snapshot was written because the user navigated away from
// this grid's own tab. A scroll-only snapshot may be replayed on remount only
// with that provenance (#8524); every other remount reason — refresh,
// re-execute, sort, paginate, eviction reload — still starts at the first row
// (#7341).
scrollFromTabSwitch?: boolean;
columnCount: number;
rowCount: number;
}
@@ -308,6 +314,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
let draftPromotionScheduled = false;
const savingNewRows = new WeakSet<CellValue[]>();
let pendingScrollRestore: PendingChangesSnapshot["scroll"] | undefined;
// Instance-scoped so it survives the second save: a tab switch saves once from
// onBeforeTabSwitch and again from onBeforeUnmount on this same instance.
let scrollSnapshotFromTabSwitch = false;
let saveScrollSnapshotTimer = 0;
let componentActive = true;
@@ -326,13 +335,14 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
restoredEditingCell = !!cached.editingCell;
restoredTransactionActive = cached.transactionActive === true;
// A scroll-only snapshot (no pending edits, draft row, or active cell editor)
// must not drag a remounted grid back to the previous viewport: the fresh
// result should start at the first row (#7341). Scroll is only replayed
// alongside edit state so the user lands back on their edited rows. The
// KeepAlive activate path keeps pure scroll restore via the in-instance
// pendingScrollRestore, which this gate does not touch.
// must not drag a remounted grid back to the previous viewport unless we know
// why the previous instance went away. Two reasons justify replaying it: the
// snapshot carries edit state the user must land back on, or the previous
// instance was torn down by a tab switch (#8524) — data tabs render a single
// active pane keyed by tab id, so returning to a tab remounts the grid. A
// fresh or reloaded result still starts at the first row (#7341).
const snapshotHasEditState = cached.newRows.length > 0 || cached.dirtyRows.size > 0 || cached.deletedRows.size > 0 || !!cached.editingCell || !!cached.quickEntryDraftRow;
pendingScrollRestore = snapshotHasEditState ? cached.scroll : undefined;
pendingScrollRestore = snapshotHasEditState || cached.scrollFromTabSwitch === true ? cached.scroll : undefined;
pendingChangesCache.delete(key);
} else {
pendingChangesCache.delete(key);
@@ -510,8 +520,22 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
if (el) el.scrollTop = 0;
}
// Every caller below expresses an explicit "new viewport" intent (sort, filter,
// paginate, refresh, result identity change). Drop the tab-switch provenance so a
// snapshot written earlier cannot drag the next mount back to the old offset.
// The already-written cache entry is fixed up in place because a switch can be
// dispatched without completing, and with split groups the global activeTabId
// watcher can name a tab belonging to another still-mounted grid.
function clearTabSwitchScrollProvenance() {
scrollSnapshotFromTabSwitch = false;
const k = cacheKey?.value;
const cached = k ? pendingChangesCache.get(k) : undefined;
if (cached?.scrollFromTabSwitch) cached.scrollFromTabSwitch = false;
}
function resetGridVerticalScroll(afterResult = false) {
if (afterResult) resetScrollAfterResult = true;
clearTabSwitchScrollProvenance();
if (resetScrollFrame) cancelAnimationFrame(resetScrollFrame);
scrollGridToTop();
nextTick(() => {
@@ -2006,6 +2030,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
previousResultRows = rows;
pendingScrollRestore = undefined;
clearTabSwitchScrollProvenance();
discardChanges();
},
);
@@ -2034,6 +2059,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
editValue: editValue.value,
transactionActive: transactionActive.value,
scroll,
scrollFromTabSwitch: scroll ? scrollSnapshotFromTabSwitch : false,
columnCount: result.value.columns.length,
rowCount: result.value.rows.length,
});
@@ -2045,8 +2071,14 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
applyScrollPosition(pendingScrollRestore);
}
function onBeforeTabSwitch() {
function onBeforeTabSwitch(event?: Event) {
if (!componentActive) return;
const detail = (event as CustomEvent<{ tabId?: string; fromTabId?: string }> | undefined)?.detail;
const key = cacheKey?.value;
// Split editor groups keep several grids mounted at once and every one of them
// receives this window event, so only the grid whose own tab is being left may
// claim the scroll restore. A missing fromTabId falls through to "start at top".
if (key && detail?.fromTabId && cacheKeyBelongsToTab(key, detail.fromTabId)) scrollSnapshotFromTabSwitch = true;
savePendingSnapshot(true, true);
if (editingCell.value) suppressNextBlurCommit = true;
}
@@ -2184,6 +2216,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
isPreviewLoading,
previewChanges,
savePendingSnapshot,
// Exposed for tests: the window listener is only registered inside a component
// instance, so specs drive the tab-switch path directly.
onBeforeTabSwitch,
restorePendingSnapshotFocus,
syncHeaderScroll: (headerRef: Ref<HTMLDivElement | undefined>) => (e: Event) => {
if (headerRef.value) {
@@ -1,5 +1,6 @@
import { computed, getCurrentScope, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
import { dataGridSearchMatchKey } from "@/lib/dataGrid/canvasDataGridRenderer";
import { loadDataGridSearchState, saveDataGridSearchState } from "@/lib/dataGrid/dataGridSearchStatePersistence";
export type DataGridSearchMatch = {
kind: "cell" | "column";
@@ -18,6 +19,10 @@ export type UseDataGridSearchOptions<Row> = {
getCellSearchText: (row: Row, columnIndex: number) => string;
debounceMs?: number;
onNavigate?: (match: DataGridSearchMatch) => void;
/** 按标签页键控的持久化键;不传则该宿主不参与搜索状态保留。 */
persistenceKey?: MaybeRefOrGetter<string | undefined>;
/** 列签名,用于避免把陈旧搜索恢复到结构不同的结果上。 */
persistenceScopeKey?: MaybeRefOrGetter<string>;
};
const SEARCH_TOKEN_SEPARATOR = /[\s,()><=!&|]+/;
@@ -72,12 +77,31 @@ export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
searchTimer = undefined;
}
// A single-use token rather than a boolean flag: it is immune to Vue's async
// watcher flush and self-invalidates if the user types during the restore window.
let restoreToken: { searchText: string; matchIndex: number } | null = null;
watch(searchText, (value) => {
clearTimer();
const query = value.trim().toLowerCase();
const restoring = restoreToken !== null && restoreToken.searchText === value;
if (!query) deferredSearchText.value = "";
// Restoring is not typing: resolve the query now so the row set (and, in
// "filter" search mode, the content height) is settled before the grid's
// scroll restore measures it.
else if (restoring) deferredSearchText.value = query;
else searchTimer = setTimeout(() => (deferredSearchText.value = query), options.debounceMs ?? 150);
if (restoring) {
// Returning to a tab must not reopen the typeahead popup.
suggestions.value = [];
suggestionIndex.value = -1;
return;
}
// The user typed before the restore resolved: drop the token so the query they
// are writing keeps the normal debounce, index reset and auto-navigate.
restoreToken = null;
const lastToken = value.trim().split(SEARCH_TOKEN_SEPARATOR).pop()?.toLowerCase() ?? "";
suggestions.value = lastToken
? toValue(options.suggestionColumns ?? options.columns)
@@ -88,7 +112,20 @@ export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
});
watch(matchKeys, (value) => {
currentMatchIndex.value = value.length ? 0 : -1;
const restored = restoreToken;
restoreToken = null;
if (!value.length) {
currentMatchIndex.value = -1;
return;
}
if (restored) {
// Re-entering the tab must not scroll to the first match: the grid's own
// scroll restore owns the viewport (#8524). Clamp because the match list may
// have shifted while the tab was away.
currentMatchIndex.value = Math.min(Math.max(restored.matchIndex, 0), value.length - 1);
return;
}
currentMatchIndex.value = 0;
const firstMatch = matchAt(0);
if (firstMatch) nextTick(() => options.onNavigate?.(firstMatch));
});
@@ -126,6 +163,41 @@ export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
suggestions.value = [];
}
/**
* Must be called from onMounted, never from the setup body: watch(matchKeys)
* eagerly evaluates its source, and seeding a non-empty query would push
* matchState past its empty-query early return into `options.rows`, which the
* host declares later in its own setup (#8524).
*/
function restorePersistedState(): boolean {
const key = toValue(options.persistenceKey);
if (!key) return false;
const state = loadDataGridSearchState(key, toValue(options.persistenceScopeKey) ?? "");
if (!state) return false;
overlayVisible.value = state.overlayVisible;
// An empty query never re-triggers the searchText watcher, so the token would
// never be consumed — only arm it when there is text to restore.
if (!state.searchText) return state.overlayVisible;
restoreToken = { searchText: state.searchText, matchIndex: state.currentMatchIndex };
searchText.value = state.searchText;
return true;
}
if (options.persistenceKey) {
watch(
[searchText, deferredSearchText, overlayVisible, currentMatchIndex],
() =>
saveDataGridSearchState(toValue(options.persistenceKey), {
scopeKey: toValue(options.persistenceScopeKey) ?? "",
searchText: searchText.value,
deferredSearchText: deferredSearchText.value,
overlayVisible: overlayVisible.value,
currentMatchIndex: currentMatchIndex.value,
}),
{ flush: "post" },
);
}
function onKeydown(event: KeyboardEvent) {
const pair = SEARCH_PAIRS[event.key];
const input = event.target as HTMLInputElement;
@@ -152,7 +224,7 @@ export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
if (getCurrentScope()) onScopeDispose(clearTimer);
return { searchText, deferredSearchText, overlayVisible, currentMatchIndex, suggestions, suggestionIndex, matches, matchKeys, matchCount, matchAt, matchSet, currentMatch, acceptSuggestion, navigateSuggestion, navigateMatch, close, onKeydown };
return { searchText, deferredSearchText, overlayVisible, currentMatchIndex, suggestions, suggestionIndex, matches, matchKeys, matchCount, matchAt, matchSet, currentMatch, acceptSuggestion, navigateSuggestion, navigateMatch, close, onKeydown, restorePersistedState };
}
function dataGridSearchMatchFromKey(key: number): DataGridSearchMatch {