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.
This commit is contained in:
+29
-7
@@ -74,8 +74,8 @@ import {
|
|||||||
type CollectorWorkspaceFilter
|
type CollectorWorkspaceFilter
|
||||||
} from "./views/collector/collector-model";
|
} from "./views/collector/collector-model";
|
||||||
import {
|
import {
|
||||||
CollectorContent,
|
|
||||||
CollectorInputDialog,
|
CollectorInputDialog,
|
||||||
|
MemoizedCollectorContent,
|
||||||
CollectorSidebar,
|
CollectorSidebar,
|
||||||
CollectorToolbar,
|
CollectorToolbar,
|
||||||
type CollectorViewActions
|
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(
|
export function appendBandwidthSample(
|
||||||
history: { time: number; speed: number }[],
|
history: { time: number; speed: number }[],
|
||||||
speed: number,
|
speed: number,
|
||||||
@@ -1337,16 +1346,29 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ speedBps,
|
|||||||
const rootStyle = getComputedStyle(document.documentElement);
|
const rootStyle = getComputedStyle(document.documentElement);
|
||||||
const palette = readDownloadSpeedSparklinePalette((property) => rootStyle.getPropertyValue(property));
|
const palette = readDownloadSpeedSparklinePalette((property) => rootStyle.getPropertyValue(property));
|
||||||
|
|
||||||
let maxV = 0;
|
let maxV = 0;
|
||||||
for (const v of hist) if (v > maxV) maxV = v;
|
for (const v of hist) if (v > maxV) maxV = v;
|
||||||
maxV = Math.max(maxV, 1024 * 1024);
|
const idle = maxV <= 0;
|
||||||
|
maxV = Math.max(maxV, 1024 * 1024);
|
||||||
|
|
||||||
const pad = 2;
|
const pad = 2;
|
||||||
const h = cssH - pad * 2;
|
const h = cssH - pad * 2;
|
||||||
const step = cssW / (DOWNLOAD_SPEED_MAX_SAMPLES - 1);
|
const step = cssW / (DOWNLOAD_SPEED_MAX_SAMPLES - 1);
|
||||||
const startIdx = DOWNLOAD_SPEED_MAX_SAMPLES - hist.length;
|
const startIdx = DOWNLOAD_SPEED_MAX_SAMPLES - hist.length;
|
||||||
const px = (i: number): number => (startIdx + i) * step;
|
const px = (i: number): number => (startIdx + i) * step;
|
||||||
const py = (v: number): number => pad + h - (v / maxV) * h;
|
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.beginPath();
|
||||||
ctx.moveTo(px(0), py(hist[0]));
|
ctx.moveTo(px(0), py(hist[0]));
|
||||||
@@ -6245,7 +6267,7 @@ export function App(): ReactElement {
|
|||||||
>
|
>
|
||||||
<main className="md-runtime-view-content">
|
<main className="md-runtime-view-content">
|
||||||
{tab === "collector" && (
|
{tab === "collector" && (
|
||||||
<CollectorContent actions={collectorActions} model={collectorViewModel} />
|
<MemoizedCollectorContent actions={collectorActions} model={collectorViewModel} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "downloads" && <DownloadsContent actions={downloadsActions} model={downloadsViewModel} />}
|
{tab === "downloads" && <DownloadsContent actions={downloadsActions} model={downloadsViewModel} />}
|
||||||
|
|||||||
@@ -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 { formatDateTime, formatHosterLabel, humanSize } from "../../download-format";
|
||||||
import { DataTable, DataTableBody, DataTableEmpty, DataTableHeader } from "../../ui/DataTable";
|
import { DataTable, DataTableBody, DataTableEmpty, DataTableHeader } from "../../ui/DataTable";
|
||||||
import { Dialog } from "../../ui/Dialog";
|
import { Dialog } from "../../ui/Dialog";
|
||||||
@@ -165,7 +165,19 @@ function CollectorPackageGroup({ row, model, actions, selected }: {
|
|||||||
const allSelected = row.selectedCount === row.totalCount;
|
const allSelected = row.selectedCount === row.totalCount;
|
||||||
const partiallySelected = row.selectedCount > 0 && !allSelected;
|
const partiallySelected = row.selectedCount > 0 && !allSelected;
|
||||||
const animateItems = model.animationsEnabled && row.allLinks.length <= 64;
|
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 (
|
return (
|
||||||
<div className={`collector-package-group${row.collapsed ? " is-collapsed" : ""}${model.animationsEnabled ? " is-motion-enabled" : ""}`} role="rowgroup">
|
<div className={`collector-package-group${row.collapsed ? " is-collapsed" : ""}${model.animationsEnabled ? " is-motion-enabled" : ""}`} role="rowgroup">
|
||||||
<div className={`collector-package-row${row.selectedCount > 0 ? " is-selected" : ""}`} role="row">
|
<div className={`collector-package-row${row.selectedCount > 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 {
|
export function CollectorView({ model, actions, region = "all" }: CollectorViewProps): ReactElement {
|
||||||
if (region === "sidebar") return <CollectorSidebar actions={actions} model={model} />;
|
if (region === "sidebar") return <CollectorSidebar actions={actions} model={model} />;
|
||||||
if (region === "toolbar") return <CollectorToolbar actions={actions} model={model} />;
|
if (region === "toolbar") return <CollectorToolbar actions={actions} model={model} />;
|
||||||
if (region === "content") return <CollectorContent actions={actions} model={model} />;
|
if (region === "content") return <MemoizedCollectorContent actions={actions} model={model} />;
|
||||||
return (
|
return (
|
||||||
<div className="collector-view">
|
<div className="collector-view">
|
||||||
<CollectorSidebar actions={actions} model={model} />
|
<CollectorSidebar actions={actions} model={model} />
|
||||||
<div className="collector-view-main">
|
<div className="collector-view-main">
|
||||||
<CollectorToolbar actions={actions} model={model} />
|
<CollectorToolbar actions={actions} model={model} />
|
||||||
<CollectorContent actions={actions} model={model} />
|
<MemoizedCollectorContent actions={actions} model={model} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -251,6 +251,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.collector-package-items-frame.is-animated {
|
.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;
|
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +266,17 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes collector-items-expand {
|
||||||
|
from {
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.collector-column-select {
|
.collector-column-select {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
CollectorContent,
|
CollectorContent,
|
||||||
CollectorInputDialog,
|
CollectorInputDialog,
|
||||||
|
MemoizedCollectorContent,
|
||||||
CollectorSidebar,
|
CollectorSidebar,
|
||||||
CollectorToolbar,
|
CollectorToolbar,
|
||||||
CollectorView,
|
CollectorView,
|
||||||
@@ -323,10 +324,20 @@ describe("CollectorView", () => {
|
|||||||
expect(html).not.toContain("SBS14HD.part01.rar");
|
expect(html).not.toContain("SBS14HD.part01.rar");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the animated disclosure frame mounted for compact packages", () => {
|
it("removes collapsed child rows from the DOM even when animations are enabled", () => {
|
||||||
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, [], ["package-sbs"], "", true)} />);
|
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel([packages[0]], "all", "", false, [], ["package-sbs"], "", true)} />);
|
||||||
expect(html).toContain("collector-package-items-frame is-collapsed is-animated");
|
expect(html).not.toContain("collector-package-items-frame");
|
||||||
expect(html).toContain("SBS14HD.part01.rar");
|
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", () => {
|
it("offers selected and all transfer actions", () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { isValidElement, type ReactElement, type ReactNode } from "react";
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types";
|
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 {
|
import {
|
||||||
buildStatisticsViewModel,
|
buildStatisticsViewModel,
|
||||||
type StatisticsMetric
|
type StatisticsMetric
|
||||||
@@ -593,6 +593,12 @@ describe("bandwidth chart palette", () => {
|
|||||||
expect(palette).toEqual({ accent: "rgb(74, 222, 128)" });
|
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", () => {
|
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 source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||||
const chartBlock = source.slice(source.indexOf("const BandwidthChart"), source.indexOf("interface DownloadSpeedSparklineProps"));
|
const chartBlock = source.slice(source.indexOf("const BandwidthChart"), source.indexOf("interface DownloadSpeedSparklineProps"));
|
||||||
|
|||||||
Reference in New Issue
Block a user