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.
This commit is contained in:
Sucukdeluxe
2026-08-20 08:40:25 +02:00
parent 1f2f10e803
commit 32fc1e7321
6 changed files with 274 additions and 25 deletions
+30 -25
View File
@@ -97,7 +97,7 @@ import {
import { buildDownloadsViewModel, formatRemainingDownloadBytes, getDownloadQueueTotalBytes, getDownloadSpeedBps, getPendingDownloadItemCount, getRemainingDownloadBytes, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model"; 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 { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable";
import { DeleteConfirmationDialog } from "./views/downloads/DeleteConfirmationDialog"; 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 { import {
DownloadsContent, DownloadsContent,
DownloadsFooter, DownloadsFooter,
@@ -1632,6 +1632,17 @@ export function App(): ReactElement {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [deleteConfirm, setDeleteConfirm] = useState<{ ids: Set<string>; dontAsk: boolean } | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<{ ids: Set<string>; dontAsk: boolean } | null>(null);
const [columnOrder, setColumnOrder] = useState<string[]>(() => DEFAULT_COLUMN_ORDER); const [columnOrder, setColumnOrder] = useState<string[]>(() => DEFAULT_COLUMN_ORDER);
const columnOrderPersistenceRef = useRef<DownloadColumnOrderPersistence | null>(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<DownloadColumnDragSession | null>(null); const columnDragSessionRef = useRef<DownloadColumnDragSession | null>(null);
const columnDragSettleTimerRef = useRef<number | null>(null); const columnDragSettleTimerRef = useRef<number | null>(null);
const suppressColumnSortRef = useRef(false); const suppressColumnSortRef = useRef(false);
@@ -1659,16 +1670,9 @@ export function App(): ReactElement {
const allDebridHostRequestRef = useRef(0); const allDebridHostRequestRef = useRef(0);
const debridLinkHostLimitsRequestRef = useRef(0); const debridLinkHostLimitsRequestRef = useRef(0);
const columnOrderKey = useMemo( const persistColumnOrder = useCallback((order: string[]): void => {
() => (snapshot.settings.columnOrder || []).join("|"), columnOrderPersistenceRef.current?.enqueue(order);
[snapshot.settings.columnOrder] }, []);
);
useEffect(() => {
const order = snapshot.settings.columnOrder;
if (order && order.length > 0) {
setColumnOrder(order);
}
}, [columnOrderKey]);
const collectorViewModel = useMemo(() => buildCollectorViewModel( const collectorViewModel = useMemo(() => buildCollectorViewModel(
collectorTabs, collectorTabs,
@@ -1935,10 +1939,10 @@ export function App(): ReactElement {
if (!mountedRef.current) { if (!mountedRef.current) {
return; return;
} }
masterSnapshotRef.current = state; masterSnapshotRef.current = state;
setSnapshot(state); setSnapshot(state);
if (state.settings.columnOrder?.length > 0) { if (state.settings.columnOrder?.length > 0) {
setColumnOrder(state.settings.columnOrder); columnOrderPersistenceRef.current?.applyAuthoritative(state.settings.columnOrder);
} }
setSettingsDraft((current) => createSettingsDraft(state.settings, current)); setSettingsDraft((current) => createSettingsDraft(state.settings, current));
writeOnlySettingsDirtyRef.current.clear(); writeOnlySettingsDirtyRef.current.clear();
@@ -1991,11 +1995,8 @@ export function App(): ReactElement {
stateFlushTimerRef.current = setTimeout(() => { stateFlushTimerRef.current = setTimeout(() => {
stateFlushTimerRef.current = null; stateFlushTimerRef.current = null;
if (latestStateRef.current) { if (latestStateRef.current) {
const next = latestStateRef.current; const next = latestStateRef.current;
setSnapshot(next); setSnapshot(next);
if (next.settings.columnOrder?.length > 0) {
setColumnOrder(next.settings.columnOrder);
}
if (!settingsDirtyRef.current) { if (!settingsDirtyRef.current) {
setSettingsDraft((current) => createSettingsDraft(next.settings, current)); setSettingsDraft((current) => createSettingsDraft(next.settings, current));
} }
@@ -2659,6 +2660,9 @@ export function App(): ReactElement {
archivePasswordLoadGenerationRef.current += 1; archivePasswordLoadGenerationRef.current += 1;
} }
setSettingsDraft((current) => createSettingsDraft(result, preserveWriteOnlyValues ? current : undefined)); setSettingsDraft((current) => createSettingsDraft(result, preserveWriteOnlyValues ? current : undefined));
if (result.columnOrder?.length) {
columnOrderPersistenceRef.current?.applyAuthoritative(result.columnOrder);
}
writeOnlySettingsDirtyRef.current.clear(); writeOnlySettingsDirtyRef.current.clear();
settingsDirtyRef.current = false; settingsDirtyRef.current = false;
panelDirtyRevisionRef.current = 0; panelDirtyRevisionRef.current = 0;
@@ -4907,10 +4911,11 @@ export function App(): ReactElement {
const changed = next.join("|") !== session.measurements.map((measurement) => measurement.id).join("|"); const changed = next.join("|") !== session.measurements.map((measurement) => measurement.id).join("|");
columnDragSettleTimerRef.current = window.setTimeout(() => { columnDragSettleTimerRef.current = window.setTimeout(() => {
if (changed) { if (changed) {
flushSync(() => setColumnOrder(next)); persistColumnOrder(next);
void window.rd.updateSettings({ columnOrder: next }).catch(() => {}); commitDownloadColumnDrag(session, next, (order) => flushSync(() => setColumnOrder(order)));
} else {
clearDownloadColumnDrag(session);
} }
clearDownloadColumnDrag(session);
if (columnDragSessionRef.current === session) columnDragSessionRef.current = null; if (columnDragSessionRef.current === session) columnDragSessionRef.current = null;
columnDragSettleTimerRef.current = null; columnDragSettleTimerRef.current = null;
}, 220); }, 220);
@@ -6432,8 +6437,8 @@ export function App(): ReactElement {
} }
newOrder.splice(insertAt, 0, col); newOrder.splice(insertAt, 0, col);
} }
setColumnOrder(newOrder); persistColumnOrder(newOrder);
void window.rd.updateSettings({ columnOrder: newOrder }).catch(() => {}); setColumnOrder(newOrder);
}} }}
> >
{isVisible ? "\u2713 " : "\u2003 "}{def.label} {isVisible ? "\u2713 " : "\u2003 "}{def.label}
@@ -515,6 +515,7 @@ export interface DownloadsTableHeaderProps {
} }
function moveColumnWithPointerActions(column: string, direction: -1 | 1, element: HTMLDivElement, actions: DownloadsTableActions): void { function moveColumnWithPointerActions(column: string, direction: -1 | 1, element: HTMLDivElement, actions: DownloadsTableActions): void {
if (element.closest<HTMLElement>(".downloads-table")?.classList.contains("is-column-drag-settling")) return;
const sibling = direction < 0 ? element.previousElementSibling : element.nextElementSibling; const sibling = direction < 0 ? element.previousElementSibling : element.nextElementSibling;
if (!sibling?.matches(".downloads-column-header")) return; if (!sibling?.matches(".downloads-column-header")) return;
const currentRect = element.getBoundingClientRect(); const currentRect = element.getBoundingClientRect();
@@ -548,6 +549,7 @@ export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortC
onPointerCancel={(event) => actions.onColumnPointerCancel(column, event)} onPointerCancel={(event) => actions.onColumnPointerCancel(column, event)}
onPointerDown={(event) => { onPointerDown={(event) => {
if (event.button !== 0 || !event.isPrimary) return; if (event.button !== 0 || !event.isPrimary) return;
if (event.currentTarget.closest<HTMLElement>(".downloads-table")?.classList.contains("is-column-drag-settling")) return;
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
actions.onColumnPointerDown(column, event); actions.onColumnPointerDown(column, event);
}} }}
@@ -127,3 +127,69 @@ export function clearDownloadColumnDrag(session: DownloadColumnDragSession): voi
session.root.querySelectorAll<HTMLElement>("[data-column-dragging]").forEach((element) => delete element.dataset.columnDragging); session.root.querySelectorAll<HTMLElement>("[data-column-dragging]").forEach((element) => delete element.dataset.columnDragging);
delete session.root.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<void>;
}
export function createDownloadColumnOrderPersistence(
initialOrder: string[],
persist: (order: string[]) => Promise<string[]>,
apply: (order: string[]) => void
): DownloadColumnOrderPersistence {
let confirmed = initialOrder;
let queued: string[] | null = null;
let active: Promise<void> | null = null;
let epoch = 0;
const drain = async (): Promise<void> => {
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;
}
};
}
+8
View File
@@ -65,6 +65,14 @@ describe("desktop shell", () => {
expect(sparklineBlock).toContain("window.setInterval(tick, 750)"); 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", () => { it("keeps application menus mounted for animated opening and closing", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); 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"); const css = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
+108
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest"; 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"; import { calculateColumnDragPreview, updateDownloadColumnDrag, type DownloadColumnDragSession } from "../src/renderer/views/downloads/column-drag";
const columns = [ const columns = [
@@ -51,4 +52,111 @@ describe("animated download column drag", () => {
expect(setProperty).toHaveBeenCalledTimes(1); expect(setProperty).toHaveBeenCalledTimes(1);
expect(setProperty).toHaveBeenCalledWith("--downloads-active-drag-x", "25px"); 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<string[]>,
apply: (order: string[]) => void
) => { enqueue: (order: string[]) => void; whenIdle: () => Promise<void> };
}).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<string[]>,
apply: (order: string[]) => void
) => { applyAuthoritative: (order: string[]) => void; enqueue: (order: string[]) => void; whenIdle: () => Promise<void> };
}).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"]);
});
}); });
+60
View File
@@ -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 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 previous = { getBoundingClientRect: () => ({ left: 100, width: 100 }), matches: () => true };
const current = { const current = {
closest: () => null,
getBoundingClientRect: () => ({ left: 200, width: 100 }), getBoundingClientRect: () => ({ left: 200, width: 100 }),
previousElementSibling: previous, previousElementSibling: previous,
nextElementSibling: null 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", () => { it("includes package selection state in memo equality", () => {
const model = withRuntime(createInput()); const model = withRuntime(createInput());
const row = model.packageRows[0]; const row = model.packageRows[0];