Prevent stale live snapshots from flickering sorted packages

This commit is contained in:
Sucukdeluxe
2026-09-04 21:26:57 +02:00
parent befb17741e
commit d84673da1d
7 changed files with 232 additions and 89 deletions
+1
View File
@@ -7,6 +7,7 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
### Fixed ### Fixed
- Disable CSS row transitions as well as JavaScript animations during column sorting, so packages immediately jump to their sorted positions. - 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 ## [2.0.87] - 2026-09-04
+3
View File
@@ -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. - 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. - 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. - 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 ```powershell
npm ci npm ci
+28 -89
View File
@@ -40,6 +40,7 @@ import {
getProviderUsageDayKey getProviderUsageDayKey
} from "../shared/provider-daily-limits"; } from "../shared/provider-daily-limits";
import { preservePackageOrderForDisplay, sortPackageOrderByAvailability, sortPackageOrderByName } from "./package-order"; import { preservePackageOrderForDisplay, sortPackageOrderByAvailability, sortPackageOrderByName } from "./package-order";
import { createPackageOrderState } from "./package-order-state";
import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, resolveSelectAllSelectionScope, shouldClearDownloadSelection } from "./selection"; 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 { 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"; 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 ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability", "added"];
const COLUMN_DEFS = downloadColumnDefinitions; 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 { function formatMbpsInputFromKbps(kbps: number): string {
const mbps = Math.max(0, Number(kbps) || 0) / 1024; const mbps = Math.max(0, Number(kbps) || 0) / 1024;
return String(Number(mbps.toFixed(2))); return String(Number(mbps.toFixed(2)));
@@ -1894,9 +1883,7 @@ export function App(): ReactElement {
const importCollectorTextRef = useRef<(rawText: string) => Promise<void>>(() => Promise.resolve()); const importCollectorTextRef = useRef<(rawText: string) => Promise<void>>(() => Promise.resolve());
const activeTabRef = useRef<Tab>(tab); const activeTabRef = useRef<Tab>(tab);
const packageOrderRef = useRef<string[]>([]); const packageOrderRef = useRef<string[]>([]);
const serverPackageOrderRef = useRef<string[]>([]); const packageOrderStateRef = useRef(createPackageOrderState());
const pendingPackageOrderRef = useRef<string[] | null>(null);
const pendingPackageOrderAtRef = useRef(0);
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({}); const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
const [downloadDisclosureRevision, setDownloadDisclosureRevision] = useState(0); const [downloadDisclosureRevision, setDownloadDisclosureRevision] = useState(0);
const [downloadSearch, setDownloadSearch] = useState(""); const [downloadSearch, setDownloadSearch] = useState("");
@@ -2065,31 +2052,7 @@ export function App(): ReactElement {
}, [tab]); }, [tab]);
useEffect(() => { useEffect(() => {
const incoming = snapshot.session.packageOrder; packageOrderRef.current = 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;
}, [snapshot.session.packageOrder]); }, [snapshot.session.packageOrder]);
useEffect(() => { useEffect(() => {
@@ -2342,7 +2305,8 @@ export function App(): ReactElement {
stateFlushTimerRef.current = null; stateFlushTimerRef.current = null;
if (!latestStateRef.current) return; if (!latestStateRef.current) return;
const next = latestStateRef.current; 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) { if (!settingsDirtyRef.current) {
setSettingsDraft((current) => createSettingsDraft(next.settings, current)); setSettingsDraft((current) => createSettingsDraft(next.settings, current));
} }
@@ -2357,6 +2321,7 @@ export function App(): ReactElement {
return; return;
} }
authoritativeSnapshotApplied = true; authoritativeSnapshotApplied = true;
packageOrderStateRef.current.accept(state.session.packageOrder);
masterSnapshotRef.current = state; masterSnapshotRef.current = state;
persistedSettingsRef.current = state.settings; persistedSettingsRef.current = state.settings;
const resolvedTheme = resolveSettingsThemeChoice( 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 movePackage = useCallback((packageId: string, direction: "up" | "down") => {
const currentOrder = packageOrderRef.current; const currentOrder = packageOrderRef.current;
const order = [...currentOrder]; const order = [...currentOrder];
@@ -4370,24 +4351,8 @@ export function App(): ReactElement {
if (target < 0 || target >= order.length) { return; } if (target < 0 || target >= order.length) { return; }
[order[idx], order[target]] = [order[target], order[idx]]; [order[idx], order[target]] = [order[target], order[idx]];
setDownloadsSortDescending(false); setDownloadsSortDescending(false);
pendingPackageOrderRef.current = [...order]; commitPackageOrder(order);
pendingPackageOrderAtRef.current = Date.now(); }, [commitPackageOrder]);
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]);
const openCollectorInput = (): void => { const openCollectorInput = (): void => {
setCollectorError(""); setCollectorError("");
@@ -4541,24 +4506,8 @@ export function App(): ReactElement {
const unchanged = order.length === currentOrder.length && order.every((id, idx) => id === currentOrder[idx]); const unchanged = order.length === currentOrder.length && order.every((id, idx) => id === currentOrder[idx]);
if (unchanged) return; if (unchanged) return;
setDownloadsSortDescending(false); setDownloadsSortDescending(false);
pendingPackageOrderRef.current = [...order]; commitPackageOrder(order);
pendingPackageOrderAtRef.current = Date.now(); }, [commitPackageOrder, selectedIds, snapshot.session.packages]);
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]);
const onPackageToggle = useCallback((packageId: string): void => { const onPackageToggle = useCallback((packageId: string): void => {
let previousEnabled: boolean | null = null; let previousEnabled: boolean | null = null;
@@ -5312,18 +5261,8 @@ export function App(): ReactElement {
: column === "availability" : column === "availability"
? sortPackageOrderByAvailability(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending) ? sortPackageOrderByAvailability(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending)
: sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending); : sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
pendingPackageOrderRef.current = [...sorted]; commitPackageOrder(sorted);
pendingPackageOrderAtRef.current = Date.now(); }, [commitPackageOrder, downloadsSortColumn, downloadsSortDescending, downloadsViewCore.packageRows, snapshot.session.items, snapshot.session.packageOrder, snapshot.session.packages]);
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]);
const clearDownloadQueue = useCallback((): void => { const clearDownloadQueue = useCallback((): void => {
void performQuickAction(async () => { void performQuickAction(async () => {
+34
View File
@@ -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;
}
};
}
+69
View File
@@ -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"]);
});
});
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sortier-Regressionsprüfung</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/sort-regression.tsx"></script>
</body>
</html>
+85
View File
@@ -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<Parameters<ElectronApi["onStateUpdate"]>[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(<App />);
async function verify(): Promise<void> {
const frame = (): Promise<void> => new Promise((resolve) => requestAnimationFrame(() => resolve()));
const findSortButton = (): HTMLButtonElement | undefined => [...document.querySelectorAll<HTMLButtonElement>("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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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); });