diff --git a/CHANGELOG.md b/CHANGELOG.md index d5b7b74..b75bfc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to Multi-Debrid Downloader are documented in this file. ### Fixed - Disable CSS row transitions as well as JavaScript animations during column sorting, so packages immediately jump to their sorted positions. +- Keep the chosen package order stable while live snapshots and reorder confirmations arrive, preventing a brief jump back to the previous order when reversing a sort. Ignore failures from superseded reorder requests. ## [2.0.87] - 2026-09-04 diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index c50ea1b..a84a443 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -238,6 +238,9 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält - Der Sortierfix in `v2.0.87` unterdrückte nur die JavaScript-Animation. Die CSS-Transition der virtuellen Zeilen bewegte sie weiterhin 1,5 Sekunden lang. `VirtualizedDownloadsBody` verwendet jetzt denselben Sortierguard auch für `is-download-motion-disabled`, wodurch CSS und JavaScript beim Spaltensortieren gemeinsam ausgesetzt werden. - Verifiziert: 160 Tests aus `download-sort.test.ts` und `downloads-view.test.tsx` erfolgreich; TypeScript mit `tsc --noEmit` fehlerfrei. Im lokalen Browser-Test mit der bestehenden visuellen Fixture wurden beide Verfügbarkeits-Sortierrichtungen samt CSS-Bewegungssperre geprüft. Normales Einklappen hebt die sortierbedingte Sperre wieder auf. - Die Dev-App läuft mit Vite auf Port 5180 und Main-Watcher; Vite hat die Änderung automatisch geladen. Paketversion bleibt `2.0.87`, der Fix ist noch nicht veröffentlicht. Nächster Schritt: Saschas Sichtprüfung in der Dev-App und gegebenenfalls ein ausdrücklich beauftragtes Release. +- Anschließender Flackerfix: Der bisherige Effect behandelte die lokal optimistisch gesetzte Reihenfolge als Serverbestätigung und löschte den Pending-Schutz. Ein gepuffertes Live-Snapshot konnte dadurch kurz die alte Reihenfolge anzeigen. `package-order-state.ts` hält die neueste Sortierabsicht bis zur passenden Befehls- und Snapshotbestätigung fest. Die Projektion erfolgt beim Rendern des Live-Snapshots; Main-Zustand und Snapshot-Koordinator bleiben autoritativ. Neue Pakete werden angehängt, entfernte nicht wiederhergestellt. Fehler älterer Sortierbefehle können neuere Klicks nicht zurückrollen; Spaltensortierung und manuelles Verschieben verwenden denselben Ablauf. +- Reproduzierbare Browser-Prüfung: Vite mit `tests/visual/vite.config.mts` auf Port 5174 starten und `/sort-regression.html` öffnen. Die echte App läuft dort ausschließlich mit Testdaten, aktivierten Animationen und 750 ms verzögerten Sortierbestätigungen samt altem Zwischen-Snapshot. Vor dem Fix zeigte jeder der drei tatsächlichen Richtungswechsel die Folge neu → alt → neu; danach zeigten vier Durchläufe keine falschen Zwischenreihenfolgen oder gemessenen Zeilenbewegungen. DOM-Mutationsprüfung ergänzt die Bildmessung, damit Hintergrundtab-Drosselung kurze Zwischenstände nicht verdeckt. +- Verifiziert nach dem Flackerfix: 195 Tests in sieben Dateien erfolgreich, einschließlich sechs neuer Regressionen für verspätete Updates, schnelle Richtungswechsel, hinzugefügte/entfernte Pakete, veraltete Fehler und Bestätigungsreihenfolge. TypeScript und Renderer-Produktionsbuild erfolgreich; bekannte Warnung zum rund 583 KiB großen Chunk unverändert. Dev-App automatisch aktualisiert; kein neues Release erstellt. ```powershell npm ci diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 29b01c6..c317938 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -40,6 +40,7 @@ import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; import { preservePackageOrderForDisplay, sortPackageOrderByAvailability, sortPackageOrderByName } from "./package-order"; +import { createPackageOrderState } from "./package-order-state"; import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, resolveSelectAllSelectionScope, shouldClearDownloadSelection } from "./selection"; import { buildConfiguredProviderOrder, createAccountToggleQueue, enqueueAccountToggleIntent, filterAccountDialogOptions, formatAccountOperationError, getAccountDialogSelectableOptions, getAvailableAccountOptions, mergeAccountToggleSettings, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountToggleIntentEnabled, resolveAccountUsername, resolveVisibleAccountKind, sortAccountServices, updateAccountRowSelection, type AccountToggleTarget } from "./account-ui"; import { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit"; @@ -1616,18 +1617,6 @@ const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", " const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability", "added"]; const COLUMN_DEFS = downloadColumnDefinitions; -function sameStringArray(a: string[], b: string[]): boolean { - if (a.length !== b.length) { - return false; - } - for (let index = 0; index < a.length; index += 1) { - if (a[index] !== b[index]) { - return false; - } - } - return true; -} - function formatMbpsInputFromKbps(kbps: number): string { const mbps = Math.max(0, Number(kbps) || 0) / 1024; return String(Number(mbps.toFixed(2))); @@ -1894,9 +1883,7 @@ export function App(): ReactElement { const importCollectorTextRef = useRef<(rawText: string) => Promise>(() => Promise.resolve()); const activeTabRef = useRef(tab); const packageOrderRef = useRef([]); - const serverPackageOrderRef = useRef([]); - const pendingPackageOrderRef = useRef(null); - const pendingPackageOrderAtRef = useRef(0); + const packageOrderStateRef = useRef(createPackageOrderState()); const [collapsedPackages, setCollapsedPackages] = useState>({}); const [downloadDisclosureRevision, setDownloadDisclosureRevision] = useState(0); const [downloadSearch, setDownloadSearch] = useState(""); @@ -2065,31 +2052,7 @@ export function App(): ReactElement { }, [tab]); useEffect(() => { - const incoming = snapshot.session.packageOrder; - serverPackageOrderRef.current = incoming; - - const pending = pendingPackageOrderRef.current; - if (!pending) { - packageOrderRef.current = incoming; - return; - } - - if (sameStringArray(pending, incoming)) { - pendingPackageOrderRef.current = null; - pendingPackageOrderAtRef.current = 0; - packageOrderRef.current = incoming; - return; - } - - const maxOptimisticHoldMs = 1500; - if (Date.now() - pendingPackageOrderAtRef.current >= maxOptimisticHoldMs) { - pendingPackageOrderRef.current = null; - pendingPackageOrderAtRef.current = 0; - packageOrderRef.current = incoming; - return; - } - - packageOrderRef.current = pending; + packageOrderRef.current = snapshot.session.packageOrder; }, [snapshot.session.packageOrder]); useEffect(() => { @@ -2342,7 +2305,8 @@ export function App(): ReactElement { stateFlushTimerRef.current = null; if (!latestStateRef.current) return; const next = latestStateRef.current; - setSnapshot(next); + const packageOrder = packageOrderStateRef.current.accept(next.session.packageOrder); + setSnapshot(packageOrder === next.session.packageOrder ? next : { ...next, session: { ...next.session, packageOrder } }); if (!settingsDirtyRef.current) { setSettingsDraft((current) => createSettingsDraft(next.settings, current)); } @@ -2357,6 +2321,7 @@ export function App(): ReactElement { return; } authoritativeSnapshotApplied = true; + packageOrderStateRef.current.accept(state.session.packageOrder); masterSnapshotRef.current = state; persistedSettingsRef.current = state.settings; const resolvedTheme = resolveSettingsThemeChoice( @@ -4361,6 +4326,22 @@ export function App(): ReactElement { } }; + const commitPackageOrder = useCallback((order: string[]): void => { + const requestId = packageOrderStateRef.current.begin(order); + packageOrderRef.current = [...order]; + setSnapshot((current) => ({ ...current, session: { ...current.session, packageOrder: [...order] } })); + void window.rd.reorderPackages(order).then(() => { + packageOrderStateRef.current.confirm(requestId); + }).catch((error) => { + const restoredOrder = packageOrderStateRef.current.reject(requestId); + if (!restoredOrder) return; + packageOrderRef.current = restoredOrder; + setDownloadsSortRevision((revision) => revision + 1); + setSnapshot((current) => ({ ...current, session: { ...current.session, packageOrder: restoredOrder } })); + showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); + }); + }, [showToast]); + const movePackage = useCallback((packageId: string, direction: "up" | "down") => { const currentOrder = packageOrderRef.current; const order = [...currentOrder]; @@ -4370,24 +4351,8 @@ export function App(): ReactElement { if (target < 0 || target >= order.length) { return; } [order[idx], order[target]] = [order[target], order[idx]]; setDownloadsSortDescending(false); - pendingPackageOrderRef.current = [...order]; - pendingPackageOrderAtRef.current = Date.now(); - packageOrderRef.current = [...order]; - setSnapshot((prev) => { - if (!prev) return prev; - return { ...prev, session: { ...prev.session, packageOrder: [...order] } }; - }); - void window.rd.reorderPackages(order).catch((error) => { - pendingPackageOrderRef.current = null; - pendingPackageOrderAtRef.current = 0; - packageOrderRef.current = serverPackageOrderRef.current; - setSnapshot((prev) => { - if (!prev) return prev; - return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } }; - }); - showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); - }); - }, [showToast]); + commitPackageOrder(order); + }, [commitPackageOrder]); const openCollectorInput = (): void => { setCollectorError(""); @@ -4541,24 +4506,8 @@ export function App(): ReactElement { const unchanged = order.length === currentOrder.length && order.every((id, idx) => id === currentOrder[idx]); if (unchanged) return; setDownloadsSortDescending(false); - pendingPackageOrderRef.current = [...order]; - pendingPackageOrderAtRef.current = Date.now(); - packageOrderRef.current = [...order]; - setSnapshot((prev) => { - if (!prev) return prev; - return { ...prev, session: { ...prev.session, packageOrder: [...order] } }; - }); - void window.rd.reorderPackages(order).catch((error) => { - pendingPackageOrderRef.current = null; - pendingPackageOrderAtRef.current = 0; - packageOrderRef.current = serverPackageOrderRef.current; - setSnapshot((prev) => { - if (!prev) return prev; - return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } }; - }); - showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); - }); - }, [selectedIds, snapshot.session.packages, showToast]); + commitPackageOrder(order); + }, [commitPackageOrder, selectedIds, snapshot.session.packages]); const onPackageToggle = useCallback((packageId: string): void => { let previousEnabled: boolean | null = null; @@ -5312,18 +5261,8 @@ export function App(): ReactElement { : column === "availability" ? sortPackageOrderByAvailability(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending) : sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending); - pendingPackageOrderRef.current = [...sorted]; - pendingPackageOrderAtRef.current = Date.now(); - packageOrderRef.current = sorted; - setSnapshot((current) => ({ ...current, session: { ...current.session, packageOrder: [...sorted] } })); - void window.rd.reorderPackages(sorted).catch((error) => { - pendingPackageOrderRef.current = null; - pendingPackageOrderAtRef.current = 0; - packageOrderRef.current = serverPackageOrderRef.current; - setSnapshot((current) => ({ ...current, session: { ...current.session, packageOrder: serverPackageOrderRef.current } })); - showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); - }); - }, [downloadsSortColumn, downloadsSortDescending, downloadsViewCore.packageRows, showToast, snapshot.session.items, snapshot.session.packageOrder, snapshot.session.packages]); + commitPackageOrder(sorted); + }, [commitPackageOrder, downloadsSortColumn, downloadsSortDescending, downloadsViewCore.packageRows, snapshot.session.items, snapshot.session.packageOrder, snapshot.session.packages]); const clearDownloadQueue = useCallback((): void => { void performQuickAction(async () => { diff --git a/src/renderer/package-order-state.ts b/src/renderer/package-order-state.ts new file mode 100644 index 0000000..ad120a2 --- /dev/null +++ b/src/renderer/package-order-state.ts @@ -0,0 +1,34 @@ +export function createPackageOrderState() { + let requestId = 0; + let serverOrder: string[] = []; + let pending: { id: number; order: string[]; confirmed: boolean } | null = null; + + const project = (incoming: string[]): string[] => { + if (!pending) return incoming; + const available = new Set(incoming); + const order = pending.order.filter((id) => available.delete(id)); + order.push(...available); + const matches = order.length === incoming.length && order.every((id, index) => id === incoming[index]); + if (matches && pending.confirmed) pending = null; + return matches ? incoming : order; + }; + + return { + begin(order: string[]): number { + pending = { id: ++requestId, order: [...order], confirmed: false }; + return requestId; + }, + accept(incoming: string[]): string[] { + serverOrder = incoming; + return project(incoming); + }, + confirm(id: number): void { + if (pending?.id === id) pending.confirmed = true; + }, + reject(id: number): string[] | null { + if (pending?.id !== id) return null; + pending = null; + return serverOrder; + } + }; +} diff --git a/tests/package-order-state.test.ts b/tests/package-order-state.test.ts new file mode 100644 index 0000000..41c3c17 --- /dev/null +++ b/tests/package-order-state.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { createPackageOrderState } from "../src/renderer/package-order-state"; + +describe("pending package order", () => { + it("keeps the chosen order through stale updates until the command and snapshot agree", () => { + const state = createPackageOrderState(); + state.accept(["a", "b", "c"]); + const request = state.begin(["c", "b", "a"]); + expect(state.accept(["a", "b", "c"])).toEqual(["c", "b", "a"]); + state.confirm(request); + expect(state.accept(["a", "b", "c"])).toEqual(["c", "b", "a"]); + expect(state.accept(["c", "b", "a"])).toEqual(["c", "b", "a"]); + expect(state.accept(["b", "a", "c"])).toEqual(["b", "a", "c"]); + }); + + it("does not mistake the old order for confirmation when the user reverses quickly", () => { + const state = createPackageOrderState(); + state.accept(["a", "b"]); + const first = state.begin(["b", "a"]); + const second = state.begin(["a", "b"]); + expect(state.accept(["a", "b"])).toEqual(["a", "b"]); + state.confirm(first); + expect(state.accept(["b", "a"])).toEqual(["a", "b"]); + state.confirm(second); + expect(state.accept(["b", "a"])).toEqual(["a", "b"]); + state.accept(["a", "b"]); + expect(state.accept(["b", "a"])).toEqual(["b", "a"]); + }); + + it("retains newly added packages and never resurrects removed packages", () => { + const state = createPackageOrderState(); + state.accept(["a", "b", "c"]); + const request = state.begin(["c", "b", "a"]); + expect(state.accept(["a", "c", "new"])).toEqual(["c", "a", "new"]); + state.confirm(request); + state.accept(["c", "a", "new"]); + expect(state.accept(["new", "a", "c"])).toEqual(["new", "a", "c"]); + }); + + it("ignores rejected obsolete requests and rolls back the current request to the latest server order", () => { + const state = createPackageOrderState(); + state.accept(["a", "b"]); + const first = state.begin(["b", "a"]); + const second = state.begin(["a", "b"]); + expect(state.reject(first)).toBeNull(); + state.accept(["b", "a", "new"]); + expect(state.reject(second)).toEqual(["b", "a", "new"]); + expect(state.accept(["new", "b", "a"])).toEqual(["new", "b", "a"]); + }); + + it("can receive the matching snapshot before the command promise resolves", () => { + const state = createPackageOrderState(); + state.accept(["a", "b"]); + const request = state.begin(["b", "a"]); + state.accept(["b", "a"]); + state.confirm(request); + const incoming = ["b", "a"]; + expect(state.accept(incoming)).toBe(incoming); + expect(state.accept(["a", "b"])).toEqual(["a", "b"]); + }); + + it("preserves an empty authoritative queue", () => { + const state = createPackageOrderState(); + const request = state.begin(["b", "a"]); + state.confirm(request); + expect(state.accept([])).toEqual([]); + expect(state.accept(["new"])).toEqual(["new"]); + }); +}); diff --git a/tests/visual/sort-regression.html b/tests/visual/sort-regression.html new file mode 100644 index 0000000..7a6ea8e --- /dev/null +++ b/tests/visual/sort-regression.html @@ -0,0 +1,12 @@ + + + + + + Sortier-Regressionsprüfung + + +
+ + + diff --git a/tests/visual/sort-regression.tsx b/tests/visual/sort-regression.tsx new file mode 100644 index 0000000..0e74a72 --- /dev/null +++ b/tests/visual/sort-regression.tsx @@ -0,0 +1,85 @@ +import { createRoot } from "react-dom/client"; +import { App } from "../../src/renderer/App"; +import type { ElectronApi } from "../../src/shared/preload-api"; +import { sortPackageOrderByAvailability } from "../../src/renderer/package-order"; +import { createVisualFixture } from "./fixtures"; +import { createVisualElectronApi } from "./mock-electron-api"; +import "../../src/renderer/theme.css"; +import "../../src/renderer/styles.css"; + +const fixture = createVisualFixture("dense"); +fixture.snapshot.settings.animatePackageDisclosure = true; +const listeners = new Set[0]>(); +const emit = (): void => { + fixture.snapshot.snapshotRevision = (fixture.snapshot.snapshotRevision ?? 0) + 1; + for (const listener of listeners) listener(structuredClone(fixture.snapshot)); +}; +window.rd = { + ...createVisualElectronApi(fixture), + onStateUpdate: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + reorderPackages: async (order) => { + emit(); + await new Promise((resolve) => setTimeout(resolve, 750)); + fixture.snapshot.session.packageOrder = [...order]; + emit(); + } +}; + +const report = document.createElement("output"); +report.style.cssText = "position:fixed;bottom:0;left:0;z-index:9999;background:#fff;color:#111;font-size:12px;padding:8px"; +report.textContent = "Prüfung wird vorbereitet"; +document.body.append(report); +createRoot(document.getElementById("root")!).render(); + +async function verify(): Promise { + const frame = (): Promise => new Promise((resolve) => requestAnimationFrame(() => resolve())); + const findSortButton = (): HTMLButtonElement | undefined => [...document.querySelectorAll("button")] + .find((button) => /^Verfügbarkeit(?: [↑↓])?$/.test(button.textContent?.trim() ?? "")); + for (let attempt = 0; attempt < 180 && !findSortButton(); attempt++) await frame(); + const sortButton = findSortButton(); + if (!sortButton) throw new Error("Verfügbarkeitsspalte fehlt"); + const results = []; + for (let turn = 0; turn < 4; turn++) { + const expected = sortPackageOrderByAvailability( + fixture.snapshot.session.packageOrder, + fixture.snapshot.session.packages, + fixture.snapshot.session.items, + turn % 2 === 1 + ).map((id) => fixture.snapshot.session.packages[id].name); + let wrongOrderUpdates = 0; + const orderChanges: string[][] = []; + const body = document.querySelector(".downloads-table-body")!; + const observer = new MutationObserver(() => { + const actual = [...body.querySelectorAll("article strong")].map((element) => element.textContent ?? ""); + if (JSON.stringify(actual) !== JSON.stringify(orderChanges.at(-1))) orderChanges.push(actual); + if (JSON.stringify(actual) !== JSON.stringify(expected)) wrongOrderUpdates++; + }); + observer.observe(body, { childList: true, subtree: true }); + sortButton.click(); + const deadline = performance.now() + 1600; + let frames = 0; + let wrongOrderFrames = 0; + let movingFrames = 0; + while (performance.now() < deadline) { + await frame(); + frames++; + const body = document.querySelector(".downloads-table-body")!; + const actual = [...body.querySelectorAll("article strong")].map((element) => element.textContent); + if (JSON.stringify(actual) !== JSON.stringify(expected)) wrongOrderFrames++; + const rows = [...body.querySelectorAll(".downloads-virtual-row")]; + if (rows.some((row) => { + const target = Number.parseFloat(row.style.getPropertyValue("--downloads-virtual-row-top")); + const transform = new DOMMatrixReadOnly(getComputedStyle(row).transform); + return Math.abs(transform.m42 - target) > 0.5; + })) movingFrames++; + } + observer.disconnect(); + results.push({ direction: turn % 2 === 1 ? "offline-first" : "online-first", frames, wrongOrderFrames, wrongOrderUpdates, movingFrames, orderChanges }); + } + report.textContent = JSON.stringify({ passed: results.every((result) => result.wrongOrderFrames === 0 && result.wrongOrderUpdates === 0 && result.movingFrames === 0), results }); +} + +void verify().catch((error) => { report.textContent = String(error); });