From da4c2fb06967cced843fbe92087305747ad3f32f Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Wed, 26 Aug 2026 14:09:01 +0200 Subject: [PATCH] Prevent collector snapshot rerender stalls Memoize the collector content across unrelated app snapshots and release collapsed child rows from the DOM after their closing animation. Draw the idle speed sparkline as a pixel-aligned one-physical-pixel stroke so its thickness remains uniform across display scales. --- src/renderer/App.tsx | 36 +++++++++++++++---- .../views/collector/CollectorView.tsx | 25 ++++++++++--- src/renderer/views/collector/collector.css | 12 +++++++ tests/collector-view.test.tsx | 19 +++++++--- tests/statistics-view.test.tsx | 8 ++++- 5 files changed, 84 insertions(+), 16 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9a03f9d..25c746d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -74,8 +74,8 @@ import { type CollectorWorkspaceFilter } from "./views/collector/collector-model"; import { - CollectorContent, CollectorInputDialog, + MemoizedCollectorContent, CollectorSidebar, CollectorToolbar, type CollectorViewActions @@ -1120,6 +1120,15 @@ export function readDownloadSpeedSparklinePalette( }; } +export function getIdleSparklineStroke(cssHeight: number, devicePixelRatio: number): { y: number; lineWidth: number } { + const dpr = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1; + const baseline = Math.max(0, cssHeight - 2); + return { + y: (Math.floor(baseline * dpr) + 0.5) / dpr, + lineWidth: 1 / dpr + }; +} + export function appendBandwidthSample( history: { time: number; speed: number }[], speed: number, @@ -1337,16 +1346,29 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ speedBps, const rootStyle = getComputedStyle(document.documentElement); const palette = readDownloadSpeedSparklinePalette((property) => rootStyle.getPropertyValue(property)); - let maxV = 0; - for (const v of hist) if (v > maxV) maxV = v; - maxV = Math.max(maxV, 1024 * 1024); + let maxV = 0; + for (const v of hist) if (v > maxV) maxV = v; + const idle = maxV <= 0; + maxV = Math.max(maxV, 1024 * 1024); const pad = 2; const h = cssH - pad * 2; const step = cssW / (DOWNLOAD_SPEED_MAX_SAMPLES - 1); const startIdx = DOWNLOAD_SPEED_MAX_SAMPLES - hist.length; - const px = (i: number): number => (startIdx + i) * step; - const py = (v: number): number => pad + h - (v / maxV) * h; + const px = (i: number): number => (startIdx + i) * step; + const py = (v: number): number => pad + h - (v / maxV) * h; + + if (idle) { + const stroke = getIdleSparklineStroke(cssH, dpr); + ctx.beginPath(); + ctx.moveTo(px(0), stroke.y); + ctx.lineTo(px(hist.length - 1), stroke.y); + ctx.strokeStyle = palette.accent; + ctx.lineWidth = stroke.lineWidth; + ctx.lineCap = "butt"; + ctx.stroke(); + return; + } ctx.beginPath(); ctx.moveTo(px(0), py(hist[0])); @@ -6245,7 +6267,7 @@ export function App(): ReactElement { >
{tab === "collector" && ( - + )} {tab === "downloads" && } diff --git a/src/renderer/views/collector/CollectorView.tsx b/src/renderer/views/collector/CollectorView.tsx index b134457..470b2c5 100644 --- a/src/renderer/views/collector/CollectorView.tsx +++ b/src/renderer/views/collector/CollectorView.tsx @@ -1,4 +1,4 @@ -import type { ChangeEvent, ReactElement } from "react"; +import { memo, useEffect, useState, type ChangeEvent, type ReactElement } from "react"; import { formatDateTime, formatHosterLabel, humanSize } from "../../download-format"; import { DataTable, DataTableBody, DataTableEmpty, DataTableHeader } from "../../ui/DataTable"; import { Dialog } from "../../ui/Dialog"; @@ -165,7 +165,19 @@ function CollectorPackageGroup({ row, model, actions, selected }: { const allSelected = row.selectedCount === row.totalCount; const partiallySelected = row.selectedCount > 0 && !allSelected; const animateItems = model.animationsEnabled && row.allLinks.length <= 64; - const renderItems = !row.collapsed || animateItems; + const [renderItems, setRenderItems] = useState(!row.collapsed); + useEffect(() => { + if (!row.collapsed) { + setRenderItems(true); + return; + } + if (!animateItems) { + setRenderItems(false); + return; + } + const timer = window.setTimeout(() => setRenderItems(false), 300); + return () => window.clearTimeout(timer); + }, [animateItems, row.collapsed]); return (
0 ? " is-selected" : ""}`} role="row"> @@ -256,16 +268,21 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE ); } +export const MemoizedCollectorContent = memo( + CollectorContent, + (previous, next) => previous.model === next.model +); + export function CollectorView({ model, actions, region = "all" }: CollectorViewProps): ReactElement { if (region === "sidebar") return ; if (region === "toolbar") return ; - if (region === "content") return ; + if (region === "content") return ; return (
- +
); diff --git a/src/renderer/views/collector/collector.css b/src/renderer/views/collector/collector.css index eaac2d8..3517817 100644 --- a/src/renderer/views/collector/collector.css +++ b/src/renderer/views/collector/collector.css @@ -251,6 +251,7 @@ } .collector-package-items-frame.is-animated { + animation: collector-items-expand 300ms cubic-bezier(0.2, 0.8, 0.2, 1); transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms ease; } @@ -265,6 +266,17 @@ overflow: hidden; } +@keyframes collector-items-expand { + from { + grid-template-rows: 0fr; + opacity: 0; + } + to { + grid-template-rows: 1fr; + opacity: 1; + } +} + .collector-column-select { display: grid; place-items: center; diff --git a/tests/collector-view.test.tsx b/tests/collector-view.test.tsx index 2328f76..5ef70c5 100644 --- a/tests/collector-view.test.tsx +++ b/tests/collector-view.test.tsx @@ -14,6 +14,7 @@ import { import { CollectorContent, CollectorInputDialog, + MemoizedCollectorContent, CollectorSidebar, CollectorToolbar, CollectorView, @@ -323,10 +324,20 @@ describe("CollectorView", () => { expect(html).not.toContain("SBS14HD.part01.rar"); }); - it("keeps the animated disclosure frame mounted for compact packages", () => { - const html = renderToStaticMarkup(); - expect(html).toContain("collector-package-items-frame is-collapsed is-animated"); - expect(html).toContain("SBS14HD.part01.rar"); + it("removes collapsed child rows from the DOM even when animations are enabled", () => { + const html = renderToStaticMarkup(); + expect(html).not.toContain("collector-package-items-frame"); + expect(html).not.toContain("SBS14HD.part01.rar"); + }); + + it("reuses the collector content when app snapshots keep the same collector model", () => { + const model = buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "", true); + const compare = (MemoizedCollectorContent as unknown as { + compare: (previous: { model: typeof model; actions: CollectorViewActions }, next: { model: typeof model; actions: CollectorViewActions }) => boolean; + }).compare; + + expect(compare({ model, actions: createActions() }, { model, actions: createActions() })).toBe(true); + expect(compare({ model, actions: createActions() }, { model: { ...model, query: "neu" }, actions: createActions() })).toBe(false); }); it("offers selected and all transfer actions", () => { diff --git a/tests/statistics-view.test.tsx b/tests/statistics-view.test.tsx index 548d9b0..1f7a2d5 100644 --- a/tests/statistics-view.test.tsx +++ b/tests/statistics-view.test.tsx @@ -3,7 +3,7 @@ import { isValidElement, type ReactElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types"; -import { appendBandwidthSample, readBandwidthChartPalette, readDownloadSpeedSparklinePalette } from "../src/renderer/App"; +import { appendBandwidthSample, getIdleSparklineStroke, readBandwidthChartPalette, readDownloadSpeedSparklinePalette } from "../src/renderer/App"; import { buildStatisticsViewModel, type StatisticsMetric @@ -593,6 +593,12 @@ describe("bandwidth chart palette", () => { expect(palette).toEqual({ accent: "rgb(74, 222, 128)" }); }); + it("aligns the idle speed line to one physical pixel at every display scale", () => { + expect(getIdleSparklineStroke(22, 1)).toEqual({ y: 20.5, lineWidth: 1 }); + expect(getIdleSparklineStroke(22, 1.25)).toEqual({ y: 20.4, lineWidth: 0.8 }); + expect(getIdleSparklineStroke(22, 2)).toEqual({ y: 20.25, lineWidth: 0.5 }); + }); + it("labels the live chart and slows redraws when reduced motion is requested", () => { const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); const chartBlock = source.slice(source.indexOf("const BandwidthChart"), source.indexOf("interface DownloadSpeedSparklineProps"));