mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:43:49 +00:00
fix(redis): preserve viewport after fetch all
This commit is contained in:
+205
-12
@@ -29,7 +29,12 @@ const mocks = vi.hoisted(() => ({
|
||||
createRedisKeyTreeIndex: vi.fn(),
|
||||
flattenVisibleRedisKeyTree: vi.fn(),
|
||||
scrollerUpdateVisibleItems: vi.fn(),
|
||||
scrollerScrollToItem: vi.fn(),
|
||||
scrollerCallOrder: [] as string[],
|
||||
scrollerVisiblePositions: [] as number[],
|
||||
scrollerRefreshSnapshots: [] as Array<{ length: number; ids: Array<string | undefined>; hasHoles: boolean }>,
|
||||
scrollerItems: [] as unknown[][],
|
||||
scrollerVisibleStartIndex: 0,
|
||||
toast: vi.fn(),
|
||||
updateRedisDbKeyStats: vi.fn(),
|
||||
listRedisCompletionCommandDocs: vi.fn(),
|
||||
@@ -344,17 +349,38 @@ vi.mock("vue-virtual-scroller", async () => {
|
||||
// Mirror the real scroller: interaction tests should render a viewport,
|
||||
// not every row in a deliberately large result set.
|
||||
const visibleItemCount = 50;
|
||||
let layoutItemCount = props.items.length;
|
||||
const renderedItems = shallowRef<unknown[]>([]);
|
||||
const bindVisibleItems = () => {
|
||||
renderedItems.value = props.items.slice(0, visibleItemCount);
|
||||
renderedItems.value = props.items.slice(mocks.scrollerVisibleStartIndex, mocks.scrollerVisibleStartIndex + visibleItemCount);
|
||||
};
|
||||
watch(() => props.items, bindVisibleItems, { immediate: true });
|
||||
expose({
|
||||
updateVisibleItems: (itemsChanged?: boolean) => {
|
||||
mocks.scrollerUpdateVisibleItems(itemsChanged);
|
||||
mocks.scrollerCallOrder.push(`update:${(props.items[mocks.scrollerVisibleStartIndex] as { id?: string } | undefined)?.id ?? "missing"}`);
|
||||
const viewportItems = props.items.slice(mocks.scrollerVisibleStartIndex, mocks.scrollerVisibleStartIndex + visibleItemCount) as Array<{ id?: string } | undefined>;
|
||||
mocks.scrollerRefreshSnapshots.push({
|
||||
length: props.items.length,
|
||||
ids: Array.from(viewportItems, (item) => item?.id),
|
||||
hasHoles: Array.from(viewportItems).some((item) => item === undefined),
|
||||
});
|
||||
// The real scroller keeps a keyed view pool when false. Rebind only
|
||||
// when callers identify in-place item changes explicitly.
|
||||
if (itemsChanged) bindVisibleItems();
|
||||
layoutItemCount = props.items.length;
|
||||
},
|
||||
getScroll: () => ({ start: mocks.scrollerVisibleStartIndex * 30, end: (mocks.scrollerVisibleStartIndex + visibleItemCount) * 30 }),
|
||||
findItemIndex: (offset: number) => Math.floor(offset / 30),
|
||||
scrollToItem: (index: number, options?: { align?: string }) => {
|
||||
mocks.scrollerScrollToItem(index, options);
|
||||
mocks.scrollerCallOrder.push(`scroll:${(props.items[index] as { id?: string } | undefined)?.id ?? "missing"}`);
|
||||
// A browser clamps scrollTop to the currently rendered spacer.
|
||||
// The scroller's reactive total size reaches the DOM only after
|
||||
// updateVisibleItems, so model that old-layout constraint here.
|
||||
mocks.scrollerVisibleStartIndex = Math.min(index, Math.max(0, layoutItemCount - 1));
|
||||
mocks.scrollerVisiblePositions.push(mocks.scrollerVisibleStartIndex);
|
||||
bindVisibleItems();
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
@@ -403,6 +429,26 @@ function redisKeyInfo(keyType = "json") {
|
||||
return { key_display: KEY_NAME, key_raw: KEY_RAW, key_type: keyType, ttl: 90, size: 7, value_preview: "{}" };
|
||||
}
|
||||
|
||||
function redisFlatRow(id: string, label: string, keyRaw = id) {
|
||||
return {
|
||||
id,
|
||||
depth: 0,
|
||||
node: {
|
||||
kind: "leaf" as const,
|
||||
id,
|
||||
label,
|
||||
fullKeyDisplay: label,
|
||||
keyRaw,
|
||||
db: 0,
|
||||
keyType: "string",
|
||||
ttl: -1,
|
||||
size: 0,
|
||||
valuePreview: "",
|
||||
pathSegments: [label],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const completionCommands = [
|
||||
{ name: "GET", group: "string", arity: 2, keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] },
|
||||
{ name: "GETEX", group: "string", arity: -2, keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] },
|
||||
@@ -430,6 +476,10 @@ function resetApiMocks() {
|
||||
mocks.queryResultMaxRows = 5000;
|
||||
mocks.loadedTtl = -1;
|
||||
mocks.scrollerItems.length = 0;
|
||||
mocks.scrollerCallOrder.length = 0;
|
||||
mocks.scrollerVisiblePositions.length = 0;
|
||||
mocks.scrollerRefreshSnapshots.length = 0;
|
||||
mocks.scrollerVisibleStartIndex = 0;
|
||||
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 });
|
||||
mocks.redisGetValue.mockImplementation((_connectionId: string, _db: number, keyRaw: string) => Promise.resolve(redisValue(keyRaw)));
|
||||
mocks.redisSetString.mockResolvedValue(undefined);
|
||||
@@ -451,6 +501,14 @@ function resetApiMocks() {
|
||||
mocks.canBuildRedisFuzzyTree.mockImplementation((loadedKeyCount: number) => loadedKeyCount <= 200_000);
|
||||
}
|
||||
|
||||
function resetScrollerObservations() {
|
||||
mocks.scrollerUpdateVisibleItems.mockClear();
|
||||
mocks.scrollerScrollToItem.mockClear();
|
||||
mocks.scrollerCallOrder.length = 0;
|
||||
mocks.scrollerVisiblePositions.length = 0;
|
||||
mocks.scrollerRefreshSnapshots.length = 0;
|
||||
}
|
||||
|
||||
function mountBrowser(withDeleteDetails = false) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
@@ -1794,29 +1852,37 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => {
|
||||
});
|
||||
|
||||
describe("RedisKeyBrowser interrupted Fetch All", () => {
|
||||
it("publishes Fetch All rows through the existing scroller array and explicitly refreshes its viewport", async () => {
|
||||
it("publishes Fetch All rows through one stable Array facade and explicitly refreshes its viewport", async () => {
|
||||
const initial = { key_display: "initial", key_raw: "aW5pdGlhbA==", key_type: "string", ttl: -1 };
|
||||
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: [initial], total_keys: 2 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
|
||||
mountBrowser();
|
||||
await settle();
|
||||
const itemsBeforeFetchAll = mocks.scrollerItems[mocks.scrollerItems.length - 1];
|
||||
resetScrollerObservations();
|
||||
|
||||
clickButtonWithText("redis.fetchAllKeys");
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain("buffered"));
|
||||
await vi.waitFor(() => expect(mocks.scrollerUpdateVisibleItems).toHaveBeenCalled());
|
||||
|
||||
expect(mocks.scrollerItems[mocks.scrollerItems.length - 1]).toBe(itemsBeforeFetchAll);
|
||||
const publishedItems = mocks.scrollerItems[mocks.scrollerItems.length - 1];
|
||||
expect(publishedItems).toBe(itemsBeforeFetchAll);
|
||||
expect(Array.isArray(publishedItems)).toBe(true);
|
||||
expect(publishedItems).toHaveLength(2);
|
||||
expect((publishedItems[0] as { id?: string } | undefined)?.id).toBeTruthy();
|
||||
expect([...(publishedItems as Array<{ node: { label: string } }>)].map((row) => row.node.label).sort()).toEqual(["buffered", "initial"]);
|
||||
expect(mocks.scrollerUpdateVisibleItems).toHaveBeenCalledWith(true);
|
||||
expect((publishedItems as Array<{ node: { label: string } }>).some((row) => row.node.label === "buffered")).toBe(true);
|
||||
expect(document.body.textContent).toContain("initial");
|
||||
expect(document.body.textContent).not.toContain("redis.stopFetchAll");
|
||||
});
|
||||
|
||||
it("rebinds rendered row labels after multi-chunk in-place publication", async () => {
|
||||
it("rebinds rendered labels from a complete large facade source without exposing holes", async () => {
|
||||
const initial = { key_display: "initial", key_raw: "aW5pdGlhbA==", key_type: "string", ttl: -1 };
|
||||
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: [initial], total_keys: 2 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
|
||||
mountBrowser();
|
||||
await settle();
|
||||
resetScrollerObservations();
|
||||
|
||||
const visibleRows = Array.from({ length: 25_001 }, (_, index) => {
|
||||
const label = `next-${String(index).padStart(5, "0")}`;
|
||||
@@ -1855,24 +1921,144 @@ describe("RedisKeyBrowser interrupted Fetch All", () => {
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain("next-00000"));
|
||||
await vi.waitFor(() => expect(document.body.textContent).not.toContain("redis.stopFetchAll"));
|
||||
|
||||
expect(mocks.scrollerUpdateVisibleItems.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(mocks.scrollerUpdateVisibleItems).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.scrollerUpdateVisibleItems.mock.calls.every(([itemsChanged]) => itemsChanged === true)).toBe(true);
|
||||
expect(mocks.scrollerRefreshSnapshots[0]).toMatchObject({ length: 25_001, hasHoles: false });
|
||||
expect(document.body.textContent).toContain("next-00049");
|
||||
expect(document.body.textContent).not.toContain("initial");
|
||||
});
|
||||
|
||||
it("restores a visible row by stable ID when final ordering moves it across publication chunks", async () => {
|
||||
const initialKeys = [
|
||||
{ key_display: "a-before", key_raw: "YS1iZWZvcmU=", key_type: "string", ttl: -1 },
|
||||
{ key_display: "m-anchor", key_raw: "bS1hbmNob3I=", key_type: "string", ttl: -1 },
|
||||
{ key_display: "z-after", key_raw: "ei1hZnRlcg==", key_type: "string", ttl: -1 },
|
||||
];
|
||||
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: initialKeys, total_keys: 4 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
|
||||
mountBrowser();
|
||||
await settle();
|
||||
resetScrollerObservations();
|
||||
|
||||
const initialRows = mocks.scrollerItems[mocks.scrollerItems.length - 1] as Array<{ id: string }>;
|
||||
const anchorRowId = initialRows[1]?.id;
|
||||
expect(anchorRowId).toBeTruthy();
|
||||
mocks.scrollerVisibleStartIndex = 1;
|
||||
const visibleRows = Array.from({ length: 25_001 }, (_, index) => redisFlatRow(`next:${index}`, `next-${index}`));
|
||||
visibleRows[25_000] = redisFlatRow(anchorRowId!, "m-anchor", initialKeys[1]!.key_raw);
|
||||
mocks.buildRedisKeySnapshotCooperatively.mockResolvedValueOnce({
|
||||
flatKeys: [...initialKeys, buffered],
|
||||
flatKeyByRaw: new Map([...initialKeys, buffered].map((key) => [key.key_raw, key])),
|
||||
loadedKeyRaws: new Set([...initialKeys, buffered].map((key) => key.key_raw)),
|
||||
filteredKeyCount: 4,
|
||||
treeIndex: null,
|
||||
expandedGroupIds: new Set(),
|
||||
visibleRows,
|
||||
});
|
||||
|
||||
clickButtonWithText("redis.fetchAllKeys");
|
||||
await vi.waitFor(() => expect(mocks.scrollerScrollToItem).toHaveBeenCalled());
|
||||
|
||||
expect(mocks.scrollerScrollToItem).toHaveBeenCalledWith(25_000, { align: "start" });
|
||||
expect(mocks.scrollerVisiblePositions).toEqual([2, 25_000]);
|
||||
expect(mocks.scrollerCallOrder).toEqual([`scroll:${anchorRowId}`, "update:next:2", `scroll:${anchorRowId}`, `update:${anchorRowId}`]);
|
||||
expect(document.body.textContent).toContain("m-anchor");
|
||||
});
|
||||
|
||||
it("does not move the viewport when its stable row anchor is absent from the final projection", async () => {
|
||||
const initialKeys = [
|
||||
{ key_display: "a-before", key_raw: "YS1iZWZvcmU=", key_type: "string", ttl: -1 },
|
||||
{ key_display: "m-missing", key_raw: "bS1taXNzaW5n", key_type: "string", ttl: -1 },
|
||||
];
|
||||
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: initialKeys, total_keys: 3 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
|
||||
mountBrowser();
|
||||
await settle();
|
||||
resetScrollerObservations();
|
||||
mocks.scrollerVisibleStartIndex = 1;
|
||||
mocks.buildRedisKeySnapshotCooperatively.mockResolvedValueOnce({
|
||||
flatKeys: [...initialKeys, buffered],
|
||||
flatKeyByRaw: new Map([...initialKeys, buffered].map((key) => [key.key_raw, key])),
|
||||
loadedKeyRaws: new Set([...initialKeys, buffered].map((key) => key.key_raw)),
|
||||
filteredKeyCount: 3,
|
||||
treeIndex: null,
|
||||
expandedGroupIds: new Set(),
|
||||
visibleRows: [redisFlatRow("replacement", "replacement")],
|
||||
});
|
||||
|
||||
clickButtonWithText("redis.fetchAllKeys");
|
||||
await vi.waitFor(() => expect(mocks.scrollerUpdateVisibleItems).toHaveBeenCalled());
|
||||
|
||||
expect(mocks.scrollerScrollToItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers the latest visible stable row when the user scrolls between publication chunks", async () => {
|
||||
const initialKeys = Array.from({ length: 20 }, (_, index) => ({
|
||||
key_display: `initial-${String(index).padStart(2, "0")}`,
|
||||
key_raw: `initial-raw-${index}`,
|
||||
key_type: "string",
|
||||
ttl: -1,
|
||||
}));
|
||||
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: initialKeys, total_keys: 21 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
|
||||
mountBrowser();
|
||||
await settle();
|
||||
resetScrollerObservations();
|
||||
|
||||
const initialRows = mocks.scrollerItems[mocks.scrollerItems.length - 1] as Array<{ id: string }>;
|
||||
const initialRowId = initialRows[0]!.id;
|
||||
const latestAnchorRowId = initialRows[10]!.id;
|
||||
const visibleRows = Array.from({ length: 25_001 }, (_, index) => redisFlatRow(`next:${index}`, `next-${index}`));
|
||||
visibleRows[5] = redisFlatRow(latestAnchorRowId, "initial-10", initialKeys[10]!.key_raw);
|
||||
visibleRows[25_000] = redisFlatRow(initialRowId, "initial-00", initialKeys[0]!.key_raw);
|
||||
mocks.buildRedisKeySnapshotCooperatively.mockResolvedValueOnce({
|
||||
flatKeys: [...initialKeys, buffered],
|
||||
flatKeyByRaw: new Map([...initialKeys, buffered].map((key) => [key.key_raw, key])),
|
||||
loadedKeyRaws: new Set([...initialKeys, buffered].map((key) => key.key_raw)),
|
||||
filteredKeyCount: 21,
|
||||
treeIndex: null,
|
||||
expandedGroupIds: new Set(),
|
||||
visibleRows,
|
||||
});
|
||||
const pendingFrames: FrameRequestCallback[] = [];
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
window.requestAnimationFrame = (callback: FrameRequestCallback) => {
|
||||
pendingFrames.push(callback);
|
||||
return pendingFrames.length;
|
||||
};
|
||||
|
||||
try {
|
||||
clickButtonWithText("redis.fetchAllKeys");
|
||||
await vi.waitFor(() => expect(pendingFrames).toHaveLength(1));
|
||||
expect(mocks.scrollerUpdateVisibleItems).not.toHaveBeenCalled();
|
||||
mocks.scrollerVisibleStartIndex = 10;
|
||||
requiredElement<HTMLElement>(".redis-key-scroller").dispatchEvent(new Event("scroll"));
|
||||
pendingFrames[0]!(0);
|
||||
await vi.waitFor(() => expect(mocks.scrollerUpdateVisibleItems).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(mocks.scrollerScrollToItem).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.scrollerScrollToItem).toHaveBeenCalledWith(5, { align: "start" });
|
||||
expect(mocks.scrollerCallOrder).toEqual([`scroll:${latestAnchorRowId}`, `update:${latestAnchorRowId}`, `scroll:${latestAnchorRowId}`, `update:${latestAnchorRowId}`]);
|
||||
} finally {
|
||||
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps stable Fetch All rows for metadata-only detail updates and exits them for no-expiry membership changes", async () => {
|
||||
const initial = { key_display: "initial", key_raw: "aW5pdGlhbA==", key_type: "string", ttl: -1 };
|
||||
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: [initial], total_keys: 2 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
|
||||
mountBrowser();
|
||||
await settle();
|
||||
resetScrollerObservations();
|
||||
requiredElement<HTMLButtonElement>("[data-redis-no-expiry-filter]").click();
|
||||
await settle();
|
||||
resetScrollerObservations();
|
||||
|
||||
clickButtonWithText("redis.fetchAllKeys");
|
||||
await vi.waitFor(() => expect(document.querySelector(`[data-redis-leaf="${buffered.key_raw}"]`)).not.toBeNull());
|
||||
await vi.waitFor(() => expect(mocks.scrollerUpdateVisibleItems).toHaveBeenCalled());
|
||||
const stableItems = mocks.scrollerItems[mocks.scrollerItems.length - 1];
|
||||
expect((stableItems as Array<{ node: { keyRaw?: string } }>).some((row) => row.node.keyRaw === buffered.key_raw)).toBe(true);
|
||||
requiredElement<HTMLElement>(`[data-redis-leaf="${initial.key_raw}"]`).closest<HTMLElement>(".group")?.click();
|
||||
await settle();
|
||||
|
||||
@@ -1884,9 +2070,9 @@ describe("RedisKeyBrowser interrupted Fetch All", () => {
|
||||
mocks.loadedTtl = 60;
|
||||
requiredElement<HTMLButtonElement>("[data-test-emit-key-loaded]").click();
|
||||
await settle();
|
||||
expect(mocks.scrollerItems[mocks.scrollerItems.length - 1]).not.toBe(stableItems);
|
||||
expect(mocks.scrollerItems[mocks.scrollerItems.length - 1]).toBe(stableItems);
|
||||
expect(document.querySelector(`[data-redis-leaf="${initial.key_raw}"]`)).toBeNull();
|
||||
expect(document.querySelector(`[data-redis-leaf="${buffered.key_raw}"]`)).not.toBeNull();
|
||||
expect((mocks.scrollerItems[mocks.scrollerItems.length - 1] as Array<{ node: { keyRaw?: string } }>).some((row) => row.node.keyRaw === buffered.key_raw)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the previously published rows when Fetch All is stopped during cooperative preparation", async () => {
|
||||
@@ -1904,6 +2090,7 @@ describe("RedisKeyBrowser interrupted Fetch All", () => {
|
||||
});
|
||||
mountBrowser();
|
||||
await settle();
|
||||
resetScrollerObservations();
|
||||
|
||||
clickButtonWithText("redis.fetchAllKeys");
|
||||
await buildStarted.promise;
|
||||
@@ -1914,15 +2101,17 @@ describe("RedisKeyBrowser interrupted Fetch All", () => {
|
||||
expect(document.body.textContent).toContain("initial");
|
||||
expect(document.body.textContent).not.toContain("buffered");
|
||||
expect(document.body.textContent).toContain("redis.fetchAllKeys");
|
||||
expect(mocks.scrollerUpdateVisibleItems).not.toHaveBeenCalled();
|
||||
expect(mocks.scrollerScrollToItem).not.toHaveBeenCalled();
|
||||
expect((mocks.scrollerItems[mocks.scrollerItems.length - 1] as Array<{ node: { label: string } }>).map((row) => row.node.label)).toEqual(["initial"]);
|
||||
});
|
||||
|
||||
it("rolls back a partially published stable row buffer when Fetch All is stopped", async () => {
|
||||
it("keeps the old facade source when Fetch All is stopped during cooperative anchor resolution", async () => {
|
||||
const initial = { key_display: "initial", key_raw: "aW5pdGlhbA==", key_type: "string", ttl: -1 };
|
||||
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: [initial], total_keys: 2 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
|
||||
mountBrowser();
|
||||
await settle();
|
||||
resetScrollerObservations();
|
||||
|
||||
const row = {
|
||||
id: "leaf:0:buffered",
|
||||
@@ -1962,7 +2151,8 @@ describe("RedisKeyBrowser interrupted Fetch All", () => {
|
||||
|
||||
try {
|
||||
clickButtonWithText("redis.fetchAllKeys");
|
||||
await vi.waitFor(() => expect(mocks.scrollerUpdateVisibleItems).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(pendingFrames).toHaveLength(1));
|
||||
expect(mocks.scrollerUpdateVisibleItems).not.toHaveBeenCalled();
|
||||
clickButtonWithText("redis.stopFetchAll");
|
||||
expect(pendingFrames).toHaveLength(1);
|
||||
pendingFrames[0](0);
|
||||
@@ -1971,6 +2161,9 @@ describe("RedisKeyBrowser interrupted Fetch All", () => {
|
||||
expect(document.body.textContent).toContain("initial");
|
||||
expect(document.body.textContent).not.toContain("buffered");
|
||||
expect(document.body.textContent).toContain("redis.fetchAllKeys");
|
||||
expect(mocks.scrollerScrollToItem).not.toHaveBeenCalled();
|
||||
expect((mocks.scrollerItems[mocks.scrollerItems.length - 1] as Array<{ node: { label: string } }>).map((item) => item.node.label)).toEqual(["initial"]);
|
||||
expect(mocks.scrollerRefreshSnapshots.every((snapshot) => !snapshot.ids.includes(row.id))).toBe(true);
|
||||
} finally {
|
||||
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, shallowRef, onMounted, onUnmounted, onActivated, onDeactivated, watch } from "vue";
|
||||
import { computed, markRaw, nextTick, ref, shallowRef, onMounted, onUnmounted, onActivated, onDeactivated, watch } from "vue";
|
||||
import type { CalendarDateTime } from "@internationalized/date";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Search, RefreshCw, Loader2, ChevronRight, ChevronDown, FolderClosed, FolderOpen, Trash2, Plus, KeyRound, TerminalSquare, Asterisk, History, Radio, Clock, Copy } from "@lucide/vue";
|
||||
@@ -113,6 +113,8 @@ const fetchAllLoadedCount = ref(0);
|
||||
const rootRef = ref<HTMLElement>();
|
||||
const keyPaneRef = ref<HTMLElement>();
|
||||
const redisKeyScrollerRef = ref<InstanceType<typeof RecycleScroller> | null>(null);
|
||||
let redisKeyScrollRevision = 0;
|
||||
let latestRedisKeyScrollAnchor: RedisKeyViewportAnchor | null = null;
|
||||
const valueViewerRef = ref<{ focusSearch: () => boolean } | null>(null);
|
||||
const commandTerminalRef = ref<HTMLElement>();
|
||||
const searchPattern = ref("");
|
||||
@@ -405,26 +407,59 @@ watch(activeCreateKeyTypeHelp, () => {
|
||||
const regularVisibleRows = computed(() => {
|
||||
return useFlatKeySearchRows.value ? filteredFlatKeys.value.map((key) => redisKeyToFlatTreeRow(key, props.db)) : flattenVisibleRedisKeyTree(filteredTreeKeys.value, expandedGroupIds.value);
|
||||
});
|
||||
// Fetch All keeps this raw array identity bound to RecycleScroller. Replacing
|
||||
// the prop array makes vue-virtual-scroller slice and inspect every row key;
|
||||
// bounded in-place publication plus updateVisibleItems avoids that O(N) turn.
|
||||
const fetchAllVisibleRows = shallowRef<RedisKeyTreeRow[]>([]);
|
||||
let fetchAllVisibleRowsSource: readonly RedisKeyTreeRow[] = [];
|
||||
|
||||
function facadeArrayIndex(property: PropertyKey): number {
|
||||
if (typeof property !== "string" || property === "") return -1;
|
||||
const index = Number(property);
|
||||
return Number.isInteger(index) && index >= 0 && String(index) === property ? index : -1;
|
||||
}
|
||||
|
||||
// Keep one raw Array-shaped identity bound throughout Fetch All. Array methods
|
||||
// such as slice use both indexed reads and `has`, so proxy both operations;
|
||||
// switching the complete backing snapshot is then O(1) and cannot expose holes.
|
||||
const fetchAllVisibleRowsFacade = markRaw(
|
||||
new Proxy([] as RedisKeyTreeRow[], {
|
||||
get(target, property, receiver) {
|
||||
if (property === "length") return fetchAllVisibleRowsSource.length;
|
||||
const index = facadeArrayIndex(property);
|
||||
return index >= 0 ? fetchAllVisibleRowsSource[index] : Reflect.get(target, property, receiver);
|
||||
},
|
||||
has(target, property) {
|
||||
const index = facadeArrayIndex(property);
|
||||
return index >= 0 ? index < fetchAllVisibleRowsSource.length : Reflect.has(target, property);
|
||||
},
|
||||
}),
|
||||
);
|
||||
const fetchAllVisibleRows = shallowRef<RedisKeyTreeRow[]>(fetchAllVisibleRowsFacade);
|
||||
const fetchAllVisibleRowsActive = ref(false);
|
||||
const visibleRows = computed(() => (fetchAllVisibleRowsActive.value ? fetchAllVisibleRows.value : regularVisibleRows.value));
|
||||
const redisKeyScrollerRows = fetchAllVisibleRowsFacade;
|
||||
|
||||
watch(
|
||||
regularVisibleRows,
|
||||
(rows) => {
|
||||
if (fetchAllVisibleRowsActive.value) return;
|
||||
fetchAllVisibleRowsSource = rows;
|
||||
void nextTick(() => {
|
||||
if (!fetchAllVisibleRowsActive.value && fetchAllVisibleRowsSource === rows) refreshRedisKeyScroller();
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function activateFetchAllVisibleRows() {
|
||||
if (fetchAllVisibleRowsActive.value) return;
|
||||
// Return the exact currently-bound identity when the override is enabled, so
|
||||
// entering Fetch All does not itself trigger the scroller's items watcher.
|
||||
fetchAllVisibleRows.value = regularVisibleRows.value;
|
||||
fetchAllVisibleRowsSource = regularVisibleRows.value;
|
||||
fetchAllVisibleRowsActive.value = true;
|
||||
}
|
||||
|
||||
function deactivateFetchAllVisibleRows() {
|
||||
if (!fetchAllVisibleRowsActive.value) return;
|
||||
fetchAllVisibleRowsActive.value = false;
|
||||
fetchAllVisibleRows.value = [];
|
||||
fetchAllVisibleRowsSource = regularVisibleRows.value;
|
||||
fetchAllFilteredKeyCount.value = null;
|
||||
refreshRedisKeyScroller();
|
||||
}
|
||||
|
||||
function rollbackFetchAllPublication() {
|
||||
@@ -1007,7 +1042,12 @@ async function maybeAutoLoadMoreRedisKeys() {
|
||||
|
||||
function onRedisKeyScroll(event: Event) {
|
||||
const scroller = event.target;
|
||||
if (!(scroller instanceof HTMLElement) || redisInfiniteScrollFrame) return;
|
||||
if (!(scroller instanceof HTMLElement)) return;
|
||||
if (isFetchingAll.value && fetchAllVisibleRowsActive.value) {
|
||||
redisKeyScrollRevision++;
|
||||
latestRedisKeyScrollAnchor = captureRedisKeyViewportAnchor(redisKeyScrollRevision);
|
||||
}
|
||||
if (redisInfiniteScrollFrame) return;
|
||||
redisInfiniteScrollFrame = requestAnimationFrame(() => {
|
||||
redisInfiniteScrollFrame = 0;
|
||||
const shouldLoad = shouldLoadMoreRedisKeys({
|
||||
@@ -1032,36 +1072,114 @@ const FETCH_ALL_SCAN_COUNT = 50000;
|
||||
const FETCH_ALL_BATCH_ITERATIONS = 8;
|
||||
const FETCH_ALL_PUBLISH_CHUNK_SIZE = 25_000;
|
||||
|
||||
type RedisKeyViewportAnchor = {
|
||||
rowId: string;
|
||||
rowIndex: number;
|
||||
scrollRevision: number;
|
||||
};
|
||||
|
||||
type RedisKeyScroller = {
|
||||
getScroll: () => { start: number; end: number };
|
||||
findItemIndex: (offset: number) => number;
|
||||
scrollToItem: (index: number, options?: { align?: "start" | "center" | "end" | "nearest"; smooth?: boolean; offset?: number }) => void;
|
||||
updateVisibleItems?: (itemsChanged: boolean, checkPositionDiff?: boolean) => unknown;
|
||||
$forceUpdate?: () => void;
|
||||
};
|
||||
|
||||
function yieldForRedisKeyBrowserPaint(): Promise<void> {
|
||||
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
|
||||
function redisKeyScroller(): RedisKeyScroller | null {
|
||||
return redisKeyScrollerRef.value as unknown as RedisKeyScroller | null;
|
||||
}
|
||||
|
||||
function captureRedisKeyViewportAnchor(scrollRevision = redisKeyScrollRevision): RedisKeyViewportAnchor | null {
|
||||
const scroller = redisKeyScroller();
|
||||
if (!scroller) return null;
|
||||
const rowIndex = scroller.findItemIndex(scroller.getScroll().start);
|
||||
if (!Number.isInteger(rowIndex) || rowIndex < 0) return null;
|
||||
const rowId = fetchAllVisibleRows.value[rowIndex]?.id;
|
||||
return rowId ? { rowId, rowIndex, scrollRevision } : null;
|
||||
}
|
||||
|
||||
function refreshRedisKeyScroller() {
|
||||
const scroller = redisKeyScrollerRef.value as unknown as { updateVisibleItems?: (force?: boolean) => unknown } | null;
|
||||
// Entries changed in place while the array identity stayed stable. Tell the
|
||||
const scroller = redisKeyScroller();
|
||||
// The facade source changed while its array identity stayed stable. Tell the
|
||||
// scroller to invalidate its keyed view pool for the currently rendered
|
||||
// range; `false` preserves old key/view mappings and can combine row labels.
|
||||
scroller?.updateVisibleItems?.(true);
|
||||
// The method is public on RecycleScroller, but keep the structural guard for
|
||||
// lightweight renderers (including tests) that do not implement scrolling.
|
||||
if (typeof scroller?.updateVisibleItems === "function") {
|
||||
scroller.updateVisibleItems(true);
|
||||
} else {
|
||||
scroller?.$forceUpdate?.();
|
||||
}
|
||||
}
|
||||
|
||||
function fetchAllPublicationIsCurrent(requestId: number): boolean {
|
||||
return requestId === searchRequestId && redisBrowserIsActive && !fetchAllStopRequested.value;
|
||||
}
|
||||
|
||||
async function resolveFetchAllViewportAnchor(rows: readonly RedisKeyTreeRow[], requestId: number): Promise<{ anchor: RedisKeyViewportAnchor; rowIndex: number } | null | undefined> {
|
||||
let anchor = captureRedisKeyViewportAnchor();
|
||||
if (!anchor) return null;
|
||||
|
||||
while (fetchAllPublicationIsCurrent(requestId)) {
|
||||
let rowIndex = -1;
|
||||
let anchorChanged = false;
|
||||
for (let offset = 0; offset < rows.length; offset += FETCH_ALL_PUBLISH_CHUNK_SIZE) {
|
||||
if (!fetchAllPublicationIsCurrent(requestId)) return undefined;
|
||||
const end = Math.min(offset + FETCH_ALL_PUBLISH_CHUNK_SIZE, rows.length);
|
||||
for (let index = offset; index < end; index++) {
|
||||
if (rows[index]?.id === anchor.rowId) {
|
||||
rowIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (rowIndex >= 0) break;
|
||||
if (end < rows.length) await yieldForRedisKeyBrowserPaint();
|
||||
const latest = latestRedisKeyScrollAnchor;
|
||||
if (latest && latest.scrollRevision > anchor.scrollRevision) {
|
||||
anchor = latest;
|
||||
anchorChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const latest = latestRedisKeyScrollAnchor;
|
||||
if (latest && latest.scrollRevision > anchor.scrollRevision) {
|
||||
anchor = latest;
|
||||
continue;
|
||||
}
|
||||
if (!anchorChanged) return rowIndex >= 0 ? { anchor, rowIndex } : null;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function publishFetchAllVisibleRows(rows: readonly RedisKeyTreeRow[], requestId: number): Promise<boolean> {
|
||||
const target = fetchAllVisibleRows.value;
|
||||
if (rows.length === 0) {
|
||||
target.length = 0;
|
||||
const anchorResolution = await resolveFetchAllViewportAnchor(rows, requestId);
|
||||
if (anchorResolution === undefined) return false;
|
||||
if (!fetchAllPublicationIsCurrent(requestId)) return false;
|
||||
// The facade stays bound while its complete backing source switches. If an
|
||||
// anchor survived, install the source and reposition before refreshing the
|
||||
// pool in this same browser turn, making the visual handoff atomic.
|
||||
fetchAllVisibleRowsSource = rows;
|
||||
if (anchorResolution) redisKeyScroller()?.scrollToItem(anchorResolution.rowIndex, { align: "start" });
|
||||
refreshRedisKeyScroller();
|
||||
if (anchorResolution) {
|
||||
// updateVisibleItems updates the scroller's reactive total size, but Vue
|
||||
// applies the corresponding spacer height on its next DOM flush. A target
|
||||
// beyond the old list's scroll range is clamped by the browser until that
|
||||
// happens, so repeat the bounded viewport update after nextTick. Both
|
||||
// flushes stay in the same event-loop turn (there is no paint-capable
|
||||
// yield), preserving an atomic visual handoff.
|
||||
await nextTick();
|
||||
if (!fetchAllPublicationIsCurrent(requestId)) return false;
|
||||
redisKeyScroller()?.scrollToItem(anchorResolution.rowIndex, { align: "start" });
|
||||
refreshRedisKeyScroller();
|
||||
return requestId === searchRequestId && redisBrowserIsActive && !fetchAllStopRequested.value;
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < rows.length; offset += FETCH_ALL_PUBLISH_CHUNK_SIZE) {
|
||||
if (requestId !== searchRequestId || !redisBrowserIsActive || fetchAllStopRequested.value) return false;
|
||||
const end = Math.min(offset + FETCH_ALL_PUBLISH_CHUNK_SIZE, rows.length);
|
||||
for (let index = offset; index < end; index++) target[index] = rows[index];
|
||||
if (end === rows.length) target.length = rows.length;
|
||||
refreshRedisKeyScroller();
|
||||
// Yield only when another bounded publication unit remains.
|
||||
if (end < rows.length) await yieldForRedisKeyBrowserPaint();
|
||||
}
|
||||
return requestId === searchRequestId && redisBrowserIsActive && !fetchAllStopRequested.value;
|
||||
return fetchAllPublicationIsCurrent(requestId);
|
||||
}
|
||||
|
||||
async function fetchAll(): Promise<boolean> {
|
||||
@@ -2651,7 +2769,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
<div v-else-if="noExpiryOnly && visibleRows.length === 0" class="flex-1 flex items-center justify-center text-muted-foreground text-xs p-4 text-center">
|
||||
{{ t("redis.noExpiryKeysEmpty") }}
|
||||
</div>
|
||||
<RecycleScroller v-else ref="redisKeyScrollerRef" class="redis-key-scroller flex-1" :items="visibleRows" :item-size="30" :buffer="600" :skip-hover="true" key-field="id" @scroll="onRedisKeyScroll" @resize="maybeAutoLoadMoreRedisKeys">
|
||||
<RecycleScroller v-else ref="redisKeyScrollerRef" class="redis-key-scroller flex-1" :items="redisKeyScrollerRows" :item-size="30" :buffer="600" :skip-hover="true" key-field="id" @scroll="onRedisKeyScroll" @resize="maybeAutoLoadMoreRedisKeys">
|
||||
<template #default="{ item: row }">
|
||||
<CustomContextMenu :items="redisKeyContextMenuItems(row.node)" v-slot="{ onContextMenu, isOpen }">
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user