From 32fc1e7321e8a3c858231b39624bd9651b29e03f Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Thu, 20 Aug 2026 08:40:25 +0200 Subject: [PATCH] fix(downloads): remove column reorder flicker Clear old FLIP transforms before committing the new download grid order. Serialize and coalesce column-order persistence, keep live download snapshots from overwriting local layout state, reassert imported settings after older requests, and block overlapping pointer or arrow moves during settling. --- src/renderer/App.tsx | 55 +++++---- .../views/downloads/DownloadsTable.tsx | 2 + src/renderer/views/downloads/column-drag.ts | 66 +++++++++++ tests/app-shell.test.tsx | 8 ++ tests/column-drag.test.ts | 108 ++++++++++++++++++ tests/downloads-view.test.tsx | 60 ++++++++++ 6 files changed, 274 insertions(+), 25 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 563f2fb..7a0f453 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -97,7 +97,7 @@ import { import { buildDownloadsViewModel, formatRemainingDownloadBytes, getDownloadQueueTotalBytes, getDownloadSpeedBps, getPendingDownloadItemCount, getRemainingDownloadBytes, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model"; import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable"; import { DeleteConfirmationDialog } from "./views/downloads/DeleteConfirmationDialog"; -import { beginDownloadColumnDrag, clearDownloadColumnDrag, settleDownloadColumnDrag, updateDownloadColumnDrag, type DownloadColumnDragSession } from "./views/downloads/column-drag"; +import { beginDownloadColumnDrag, clearDownloadColumnDrag, commitDownloadColumnDrag, createDownloadColumnOrderPersistence, settleDownloadColumnDrag, updateDownloadColumnDrag, type DownloadColumnDragSession, type DownloadColumnOrderPersistence } from "./views/downloads/column-drag"; import { DownloadsContent, DownloadsFooter, @@ -1632,6 +1632,17 @@ export function App(): ReactElement { const [selectedIds, setSelectedIds] = useState>(new Set()); const [deleteConfirm, setDeleteConfirm] = useState<{ ids: Set; dontAsk: boolean } | null>(null); const [columnOrder, setColumnOrder] = useState(() => DEFAULT_COLUMN_ORDER); + const columnOrderPersistenceRef = useRef(null); + if (!columnOrderPersistenceRef.current) { + columnOrderPersistenceRef.current = createDownloadColumnOrderPersistence( + DEFAULT_COLUMN_ORDER, + async (order) => { + const settings = await window.rd.updateSettings({ columnOrder: order }); + return settings.columnOrder?.length ? settings.columnOrder : order; + }, + setColumnOrder + ); + } const columnDragSessionRef = useRef(null); const columnDragSettleTimerRef = useRef(null); const suppressColumnSortRef = useRef(false); @@ -1659,16 +1670,9 @@ export function App(): ReactElement { const allDebridHostRequestRef = useRef(0); const debridLinkHostLimitsRequestRef = useRef(0); - const columnOrderKey = useMemo( - () => (snapshot.settings.columnOrder || []).join("|"), - [snapshot.settings.columnOrder] - ); - useEffect(() => { - const order = snapshot.settings.columnOrder; - if (order && order.length > 0) { - setColumnOrder(order); - } - }, [columnOrderKey]); + const persistColumnOrder = useCallback((order: string[]): void => { + columnOrderPersistenceRef.current?.enqueue(order); + }, []); const collectorViewModel = useMemo(() => buildCollectorViewModel( collectorTabs, @@ -1935,10 +1939,10 @@ export function App(): ReactElement { if (!mountedRef.current) { return; } - masterSnapshotRef.current = state; - setSnapshot(state); - if (state.settings.columnOrder?.length > 0) { - setColumnOrder(state.settings.columnOrder); + masterSnapshotRef.current = state; + setSnapshot(state); + if (state.settings.columnOrder?.length > 0) { + columnOrderPersistenceRef.current?.applyAuthoritative(state.settings.columnOrder); } setSettingsDraft((current) => createSettingsDraft(state.settings, current)); writeOnlySettingsDirtyRef.current.clear(); @@ -1991,11 +1995,8 @@ export function App(): ReactElement { stateFlushTimerRef.current = setTimeout(() => { stateFlushTimerRef.current = null; if (latestStateRef.current) { - const next = latestStateRef.current; - setSnapshot(next); - if (next.settings.columnOrder?.length > 0) { - setColumnOrder(next.settings.columnOrder); - } + const next = latestStateRef.current; + setSnapshot(next); if (!settingsDirtyRef.current) { setSettingsDraft((current) => createSettingsDraft(next.settings, current)); } @@ -2659,6 +2660,9 @@ export function App(): ReactElement { archivePasswordLoadGenerationRef.current += 1; } setSettingsDraft((current) => createSettingsDraft(result, preserveWriteOnlyValues ? current : undefined)); + if (result.columnOrder?.length) { + columnOrderPersistenceRef.current?.applyAuthoritative(result.columnOrder); + } writeOnlySettingsDirtyRef.current.clear(); settingsDirtyRef.current = false; panelDirtyRevisionRef.current = 0; @@ -4907,10 +4911,11 @@ export function App(): ReactElement { const changed = next.join("|") !== session.measurements.map((measurement) => measurement.id).join("|"); columnDragSettleTimerRef.current = window.setTimeout(() => { if (changed) { - flushSync(() => setColumnOrder(next)); - void window.rd.updateSettings({ columnOrder: next }).catch(() => {}); + persistColumnOrder(next); + commitDownloadColumnDrag(session, next, (order) => flushSync(() => setColumnOrder(order))); + } else { + clearDownloadColumnDrag(session); } - clearDownloadColumnDrag(session); if (columnDragSessionRef.current === session) columnDragSessionRef.current = null; columnDragSettleTimerRef.current = null; }, 220); @@ -6432,8 +6437,8 @@ export function App(): ReactElement { } newOrder.splice(insertAt, 0, col); } - setColumnOrder(newOrder); - void window.rd.updateSettings({ columnOrder: newOrder }).catch(() => {}); + persistColumnOrder(newOrder); + setColumnOrder(newOrder); }} > {isVisible ? "\u2713 " : "\u2003 "}{def.label} diff --git a/src/renderer/views/downloads/DownloadsTable.tsx b/src/renderer/views/downloads/DownloadsTable.tsx index 4918074..c732943 100644 --- a/src/renderer/views/downloads/DownloadsTable.tsx +++ b/src/renderer/views/downloads/DownloadsTable.tsx @@ -515,6 +515,7 @@ export interface DownloadsTableHeaderProps { } function moveColumnWithPointerActions(column: string, direction: -1 | 1, element: HTMLDivElement, actions: DownloadsTableActions): void { + if (element.closest(".downloads-table")?.classList.contains("is-column-drag-settling")) return; const sibling = direction < 0 ? element.previousElementSibling : element.nextElementSibling; if (!sibling?.matches(".downloads-column-header")) return; const currentRect = element.getBoundingClientRect(); @@ -548,6 +549,7 @@ export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortC onPointerCancel={(event) => actions.onColumnPointerCancel(column, event)} onPointerDown={(event) => { if (event.button !== 0 || !event.isPrimary) return; + if (event.currentTarget.closest(".downloads-table")?.classList.contains("is-column-drag-settling")) return; event.currentTarget.setPointerCapture(event.pointerId); actions.onColumnPointerDown(column, event); }} diff --git a/src/renderer/views/downloads/column-drag.ts b/src/renderer/views/downloads/column-drag.ts index a4a2ed0..d203355 100644 --- a/src/renderer/views/downloads/column-drag.ts +++ b/src/renderer/views/downloads/column-drag.ts @@ -127,3 +127,69 @@ export function clearDownloadColumnDrag(session: DownloadColumnDragSession): voi session.root.querySelectorAll("[data-column-dragging]").forEach((element) => delete element.dataset.columnDragging); delete session.root.dataset.columnDragging; } + +export function commitDownloadColumnDrag( + session: DownloadColumnDragSession, + order: string[], + commit: (order: string[]) => void +): void { + clearDownloadColumnDrag(session); + commit(order); +} + +export interface DownloadColumnOrderPersistence { + applyAuthoritative: (order: string[]) => void; + enqueue: (order: string[]) => void; + whenIdle: () => Promise; +} + +export function createDownloadColumnOrderPersistence( + initialOrder: string[], + persist: (order: string[]) => Promise, + apply: (order: string[]) => void +): DownloadColumnOrderPersistence { + let confirmed = initialOrder; + let queued: string[] | null = null; + let active: Promise | null = null; + let epoch = 0; + + const drain = async (): Promise => { + while (queued) { + const order = queued; + queued = null; + const requestEpoch = epoch; + try { + const persisted = await persist(order); + if (requestEpoch !== epoch) continue; + confirmed = persisted.length > 0 ? persisted : order; + if (!queued) apply(confirmed); + } catch { + if (requestEpoch === epoch && !queued) apply(confirmed); + } + } + }; + + const start = (): void => { + if (active) return; + active = drain().finally(() => { + active = null; + if (queued) start(); + }); + }; + + return { + applyAuthoritative: (order) => { + epoch += 1; + confirmed = order; + apply(order); + queued = active ? order : null; + }, + enqueue: (order) => { + queued = order; + start(); + }, + whenIdle: async () => { + while (active) await active; + } + }; +} diff --git a/tests/app-shell.test.tsx b/tests/app-shell.test.tsx index 6719d7a..1d92d4e 100644 --- a/tests/app-shell.test.tsx +++ b/tests/app-shell.test.tsx @@ -65,6 +65,14 @@ describe("desktop shell", () => { expect(sparklineBlock).toContain("window.setInterval(tick, 750)"); }); + it("does not let live download snapshots overwrite the local column order", () => { + const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); + const stateUpdates = source.slice(source.indexOf("unsubscribe = window.rd.onStateUpdate"), source.indexOf("unsubClipboard = window.rd.onClipboardDetected")); + + expect(stateUpdates).not.toContain("setColumnOrder"); + expect(stateUpdates).not.toContain("syncColumnOrderFromSnapshot"); + }); + it("keeps application menus mounted for animated opening and closing", () => { const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); const css = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8"); diff --git a/tests/column-drag.test.ts b/tests/column-drag.test.ts index 94a96ca..ea3cb01 100644 --- a/tests/column-drag.test.ts +++ b/tests/column-drag.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import * as columnDrag from "../src/renderer/views/downloads/column-drag"; import { calculateColumnDragPreview, updateDownloadColumnDrag, type DownloadColumnDragSession } from "../src/renderer/views/downloads/column-drag"; const columns = [ @@ -51,4 +52,111 @@ describe("animated download column drag", () => { expect(setProperty).toHaveBeenCalledTimes(1); expect(setProperty).toHaveBeenCalledWith("--downloads-active-drag-x", "25px"); }); + + it.each([ + { draggedId: "name", measurements: columns, next: ["size", "name", "status"] }, + { + draggedId: "name", + measurements: [ + { id: "size", left: 0, width: 150 }, + { id: "name", left: 150, width: 300 }, + { id: "status", left: 450, width: 100 } + ], + next: ["name", "size", "status"] + } + ])("clears transforms before committing a $draggedId grid move", ({ draggedId, measurements, next }) => { + const commitDownloadColumnDrag = (columnDrag as unknown as { + commitDownloadColumnDrag?: (session: DownloadColumnDragSession, order: string[], commit: (order: string[]) => void) => void; + }).commitDownloadColumnDrag; + expect(commitDownloadColumnDrag).toBeTypeOf("function"); + if (!commitDownloadColumnDrag) return; + + const events: string[] = []; + const root = { + classList: { remove: () => events.push("clear-classes") }, + dataset: { columnDragging: draggedId }, + querySelectorAll: () => [], + style: { + removeProperty: (property: string) => events.push(`clear:${property}`) + } + } as unknown as HTMLElement; + const session = { + active: true, + draggedId, + measurements, + pointerId: -1, + preview: calculateColumnDragPreview(measurements, draggedId, next[0] === draggedId ? -160 : 160), + root, + startX: 0 + } as DownloadColumnDragSession; + + commitDownloadColumnDrag(session, next, (order) => { + events.push(`commit:${order.join("|")}`); + }); + + expect(events.at(-1)).toBe(`commit:${next.join("|")}`); + expect(events.slice(0, -1)).toContain("clear:--downloads-column-drag-name"); + expect(events.slice(0, -1)).toContain("clear:--downloads-column-drag-size"); + }); + + it("serializes and coalesces rapid persistence requests", async () => { + const createDownloadColumnOrderPersistence = (columnDrag as unknown as { + createDownloadColumnOrderPersistence?: ( + initial: string[], + persist: (order: string[]) => Promise, + apply: (order: string[]) => void + ) => { enqueue: (order: string[]) => void; whenIdle: () => Promise }; + }).createDownloadColumnOrderPersistence; + expect(createDownloadColumnOrderPersistence).toBeTypeOf("function"); + if (!createDownloadColumnOrderPersistence) return; + + const pending: Array<(order: string[]) => void> = []; + const persisted: string[][] = []; + const applied: string[][] = []; + const coordinator = createDownloadColumnOrderPersistence(["name", "size"], (order) => { + persisted.push(order); + return new Promise((resolve) => pending.push(resolve)); + }, (order) => applied.push(order)); + + coordinator.enqueue(["size", "name"]); + coordinator.enqueue(["name", "size"]); + coordinator.enqueue(["size", "name"]); + expect(persisted).toEqual([["size", "name"]]); + + pending.shift()?.(["size", "name"]); + await vi.waitFor(() => expect(persisted).toEqual([["size", "name"], ["size", "name"]])); + pending.shift()?.(["size", "name"]); + await coordinator.whenIdle(); + + expect(applied.at(-1)).toEqual(["size", "name"]); + }); + + it("reasserts an authoritative import after an older request finishes", async () => { + const createDownloadColumnOrderPersistence = (columnDrag as unknown as { + createDownloadColumnOrderPersistence?: ( + initial: string[], + persist: (order: string[]) => Promise, + apply: (order: string[]) => void + ) => { applyAuthoritative: (order: string[]) => void; enqueue: (order: string[]) => void; whenIdle: () => Promise }; + }).createDownloadColumnOrderPersistence; + expect(createDownloadColumnOrderPersistence).toBeTypeOf("function"); + if (!createDownloadColumnOrderPersistence) return; + + const pending: Array<(order: string[]) => void> = []; + const persisted: string[][] = []; + const applied: string[][] = []; + const coordinator = createDownloadColumnOrderPersistence(["name", "size"], (order) => { + persisted.push(order); + return new Promise((resolve) => pending.push(resolve)); + }, (order) => applied.push(order)); + + coordinator.enqueue(["size", "name"]); + coordinator.applyAuthoritative(["status", "name", "size"]); + pending.shift()?.(["size", "name"]); + await vi.waitFor(() => expect(persisted.at(-1)).toEqual(["status", "name", "size"])); + pending.shift()?.(["status", "name", "size"]); + await coordinator.whenIdle(); + + expect(applied.at(-1)).toEqual(["status", "name", "size"]); + }); }); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 0eace2c..e69fce3 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -1512,6 +1512,7 @@ describe("download table row contracts", () => { const moveLeft = findElement(header, (element) => element.type === "button" && element.props["aria-label"] === "Geladen / Größe nach links verschieben"); const previous = { getBoundingClientRect: () => ({ left: 100, width: 100 }), matches: () => true }; const current = { + closest: () => null, getBoundingClientRect: () => ({ left: 200, width: 100 }), previousElementSibling: previous, nextElementSibling: null @@ -1530,6 +1531,65 @@ describe("download table row contracts", () => { ]); }); + it("ignores another column move while the previous move is settling", () => { + const calls: string[] = []; + const header = DownloadsTableHeader({ + actions: createActions({ + onColumnPointerDown: () => calls.push("down"), + onColumnPointerMove: () => calls.push("move"), + onColumnPointerUp: () => calls.push("up") + }), + columnOrder: ["name", "size", "account"], + gridTemplate: "200px 100px 100px", + selectedCount: 0, + sortColumn: "name", + sortDirection: "asc", + visibleIds: ["package-a"] + }); + const moveRight = findElement(header, (element) => element.type === "button" && element.props["aria-label"] === "Name nach rechts verschieben"); + const table = { classList: { contains: (className: string) => className === "is-column-drag-settling" } }; + const sibling = { getBoundingClientRect: () => ({ left: 300, width: 100 }), matches: () => true }; + const current = { + closest: (selector: string) => selector === ".downloads-table" ? table : null, + getBoundingClientRect: () => ({ left: 100, width: 200 }), + previousElementSibling: null, + nextElementSibling: sibling + }; + + moveRight.props.onClick({ currentTarget: { closest: () => current }, stopPropagation: () => {} }); + + expect(calls).toEqual([]); + }); + + it("does not start a pointer drag while a column move is settling", () => { + const calls: string[] = []; + const header = DownloadsTableHeader({ + actions: createActions({ onColumnPointerDown: () => calls.push("down") }), + columnOrder: ["name", "size", "account"], + gridTemplate: "200px 100px 100px", + selectedCount: 0, + sortColumn: "name", + sortDirection: "asc", + visibleIds: ["package-a"] + }); + const nameHeader = findElement(header, (element) => element.props["data-download-column"] === "name"); + const pointerCapture = vi.fn(); + + nameHeader.props.onPointerDown({ + button: 0, + clientX: 200, + currentTarget: { + closest: () => ({ classList: { contains: (className: string) => className === "is-column-drag-settling" } }), + setPointerCapture: pointerCapture + }, + isPrimary: true, + pointerId: 4 + }); + + expect(pointerCapture).not.toHaveBeenCalled(); + expect(calls).toEqual([]); + }); + it("includes package selection state in memo equality", () => { const model = withRuntime(createInput()); const row = model.packageRows[0];