From 5f3372f9f64ed2da9e6c2d295a6093c3218db931 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 28 Sep 2026 06:36:11 +0800 Subject: [PATCH] fix(sidebar): remeasure visible rows after density changes (#1489) * fix(sidebar): remeasure visible rows after density changes * test(credentials): avoid flaky Ed25519 key generation --- src/ui/sidebar/tree/SidebarTree.tsx | 6 + .../sidebar/tree/sidebar-density.test.tsx | 129 ++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 src/ui/tests/sidebar/tree/sidebar-density.test.tsx diff --git a/src/ui/sidebar/tree/SidebarTree.tsx b/src/ui/sidebar/tree/SidebarTree.tsx index 0c75abf1b..04eef4f56 100644 --- a/src/ui/sidebar/tree/SidebarTree.tsx +++ b/src/ui/sidebar/tree/SidebarTree.tsx @@ -861,6 +861,12 @@ export function SidebarTree({ // and made the list visibly jump apart on every pointer move. useLayoutEffect(() => { virtualizer.measure(); + // Rebuild the cleared size cache before measuring the mounted rows again. + virtualizer.getTotalSize(); + // Unchanged rows will not trigger ResizeObserver after their cache is cleared. + parentRef.current + ?.querySelectorAll("[data-index]") + .forEach((element) => virtualizer.measureElement(element)); }, [virtualizer, density, trayTrigger, showTags, showResourceBars]); if (loading) { diff --git a/src/ui/tests/sidebar/tree/sidebar-density.test.tsx b/src/ui/tests/sidebar/tree/sidebar-density.test.tsx new file mode 100644 index 000000000..5bb17811a --- /dev/null +++ b/src/ui/tests/sidebar/tree/sidebar-density.test.tsx @@ -0,0 +1,129 @@ +import { act, render } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { Host, HostFolder } from "@/types/ui-types"; +import { SidebarTree } from "@/sidebar/tree/SidebarTree"; + +vi.mock("@/main-axios", () => ({})); +vi.mock("@/api/plugins-api", () => ({})); +vi.mock("@/settings/HostPluginSections", () => ({ + usePluginHostSections: () => [], +})); +vi.mock("@/contexts/UiPreferencesContext", () => ({ + useAreaPreferences: () => ({ showResourceBars: false, rowActions: "hover" }), +})); +vi.mock("@/sidebar/FolderMetadataDialog", () => ({ + FolderMetadataDialog: () => null, +})); +vi.mock("@/sidebar/HostShareModal", () => ({ HostShareModal: () => null })); +vi.mock("@/sidebar/tree/FolderItem/FolderItem", () => ({ + FolderItem: () =>
Folder
, + folderHostCount: () => 1, +})); +vi.mock("@/sidebar/tree/HostItem/HostItem", () => ({ + HostItem: () =>
Host
, +})); + +const observers: Array<{ + callback: ResizeObserverCallback; + nodes: Set; +}> = []; +let hostHeight = 55; +function rect(element: Element): DOMRect { + const index = element.getAttribute("data-index"); + const height = index === "0" ? 40 : index === "1" ? hostHeight : 600; + return { + x: 0, + y: 0, + width: 300, + height, + top: 0, + left: 0, + right: 300, + bottom: height, + toJSON() {}, + }; +} +function resize(rows: Element[]) { + act(() => { + for (const observer of observers) { + const entries = rows + .filter((row) => observer.nodes.has(row)) + .map( + (target) => + ({ + target, + borderBoxSize: [ + { blockSize: rect(target).height, inlineSize: 300 }, + ], + }) as unknown as ResizeObserverEntry, + ); + if (entries.length) observer.callback(entries, {} as ResizeObserver); + } + }); +} + +beforeEach(() => { + localStorage.setItem("hostOpenFolders", JSON.stringify(["group"])); + hostHeight = 55; + observers.length = 0; + vi.stubGlobal( + "ResizeObserver", + class { + nodes = new Set(); + constructor(callback: ResizeObserverCallback) { + observers.push({ callback, nodes: this.nodes }); + } + observe(node: Element) { + this.nodes.add(node); + } + unobserve(node: Element) { + this.nodes.delete(node); + } + disconnect() { + this.nodes.clear(); + } + }, + ); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function () { + return rect(this); + }, + ); + vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(600); + vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(300); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + localStorage.clear(); +}); + +it("keeps an unchanged folder's measured height after changing density", () => { + const host = { + id: "1", + name: "Host", + ip: "example.com", + username: "test", + } as Host; + const children = [ + { name: "group", path: "group", children: [host] }, + ] as HostFolder[]; + const props = { + children, + onOpenTab: vi.fn(), + onEditHost: vi.fn(), + selectionMode: false, + onToggleSelectionMode: vi.fn(), + }; + const view = render(); + const rows = () => + Array.from(view.container.querySelectorAll("[data-index]")); + expect(rows()).toHaveLength(2); + resize(rows()); + expect((rows()[1] as HTMLElement).style.transform).toBe("translateY(40px)"); + hostHeight = 30; + view.rerender(); + // Folder height is unchanged, so the browser only reports the host resize. + resize([rows()[1]]); + expect((rows()[1] as HTMLElement).style.transform).toBe("translateY(40px)"); +});