refactor: use Immer for immutable state updates (#527)

* refactor: use Immer for immutable state updates

Replace Set/Map with Record types and use Immer's produce() for
immutable state updates. This fixes mutation bugs where .add()/.set()
were mutating state before copying (e.g., `new Set(prev.add(id))`).

Changes:
- Add immer dependency
- Convert Set<string> to Record<string, true> (sparse hash set pattern)
- Convert Map<K,V> to Record<K,V>
- Use produce() for all state mutations
- Update prop types in child components

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* move deps back to where they started

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: CJ Pais <cj@cjpais.com>
This commit is contained in:
Josh Ribakoff 2026-01-18 16:47:43 -08:00 committed by GitHub
parent c84e863423
commit f9d2aa68c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 97 additions and 88 deletions

View file

@ -18,6 +18,7 @@
"@tauri-apps/plugin-store": "~2.4.1",
"@tauri-apps/plugin-updater": "~2.9.0",
"i18next": "^25.7.2",
"immer": "^11.1.3",
"lucide-react": "^0.542.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@ -493,6 +494,8 @@
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"immer": ["immer@11.1.3", "", {}, "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],

View file

@ -31,6 +31,7 @@
"react-select": "^5.8.0",
"tauri-plugin-macos-permissions-api": "2.3.0",
"i18next": "^25.7.2",
"immer": "^11.1.3",
"lucide-react": "^0.542.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",

View file

@ -16,8 +16,8 @@ interface DownloadStats {
}
interface DownloadProgressDisplayProps {
downloadProgress: Map<string, DownloadProgress>;
downloadStats: Map<string, DownloadStats>;
downloadProgress: Record<string, DownloadProgress>;
downloadStats: Record<string, DownloadStats>;
className?: string;
}
@ -26,14 +26,13 @@ const DownloadProgressDisplay: React.FC<DownloadProgressDisplayProps> = ({
downloadStats,
className = "",
}) => {
if (downloadProgress.size === 0) {
const progressValues = Object.values(downloadProgress);
if (progressValues.length === 0) {
return null;
}
const progressData: ProgressData[] = Array.from(
downloadProgress.values(),
).map((progress) => {
const stats = downloadStats.get(progress.model_id);
const progressData: ProgressData[] = progressValues.map((progress) => {
const stats = downloadStats[progress.model_id];
return {
id: progress.model_id,
percentage: progress.percentage,
@ -45,7 +44,7 @@ const DownloadProgressDisplay: React.FC<DownloadProgressDisplayProps> = ({
<ProgressBar
progress={progressData}
className={className}
showSpeed={downloadProgress.size === 1}
showSpeed={progressValues.length === 1}
size="medium"
/>
);

View file

@ -18,7 +18,7 @@ interface DownloadProgress {
interface ModelDropdownProps {
models: ModelInfo[];
currentModelId: string;
downloadProgress: Map<string, DownloadProgress>;
downloadProgress: Record<string, DownloadProgress>;
onModelSelect: (modelId: string) => void;
onModelDownload: (modelId: string) => void;
onModelDelete: (modelId: string) => Promise<void>;
@ -52,14 +52,14 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
};
const handleModelClick = (modelId: string) => {
if (downloadProgress.has(modelId)) {
if (modelId in downloadProgress) {
return; // Don't allow interaction while downloading
}
onModelSelect(modelId);
};
const handleDownloadClick = (modelId: string) => {
if (downloadProgress.has(modelId)) {
if (modelId in downloadProgress) {
return; // Don't allow interaction while downloading
}
onModelDownload(modelId);
@ -158,8 +158,8 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
: t("modelSelector.downloadModels")}
</div>
{downloadableModels.map((model) => {
const isDownloading = downloadProgress.has(model.id);
const progress = downloadProgress.get(model.id);
const isDownloading = model.id in downloadProgress;
const progress = downloadProgress[model.id];
return (
<div

View file

@ -1,6 +1,7 @@
import React, { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { listen } from "@tauri-apps/api/event";
import { produce } from "immer";
import { commands, type ModelInfo } from "@/bindings";
import { getTranslatedModelName } from "../../lib/utils/modelTranslation";
import ModelStatusButton from "./ModelStatusButton";
@ -48,15 +49,15 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
const [modelStatus, setModelStatus] = useState<ModelStatus>("unloaded");
const [modelError, setModelError] = useState<string | null>(null);
const [modelDownloadProgress, setModelDownloadProgress] = useState<
Map<string, DownloadProgress>
>(new Map());
Record<string, DownloadProgress>
>({});
const [showModelDropdown, setShowModelDropdown] = useState(false);
const [downloadStats, setDownloadStats] = useState<
Map<string, DownloadStats>
>(new Map());
const [extractingModels, setExtractingModels] = useState<Set<string>>(
new Set(),
);
Record<string, DownloadStats>
>({});
const [extractingModels, setExtractingModels] = useState<
Record<string, true>
>({});
const dropdownRef = useRef<HTMLDivElement>(null);
@ -97,53 +98,52 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
"model-download-progress",
(event) => {
const progress = event.payload;
setModelDownloadProgress((prev) => {
const newMap = new Map(prev);
newMap.set(progress.model_id, progress);
return newMap;
});
setModelDownloadProgress(
produce((downloadProgress) => {
downloadProgress[progress.model_id] = progress;
}),
);
setModelStatus("downloading");
// Update download stats for speed calculation
const now = Date.now();
setDownloadStats((prev) => {
const current = prev.get(progress.model_id);
const newStats = new Map(prev);
setDownloadStats(
produce((stats) => {
const current = stats[progress.model_id];
if (!current) {
// First progress update - initialize
newStats.set(progress.model_id, {
startTime: now,
lastUpdate: now,
totalDownloaded: progress.downloaded,
speed: 0,
});
} else {
// Calculate speed over last few seconds
const timeDiff = (now - current.lastUpdate) / 1000; // seconds
const bytesDiff = progress.downloaded - current.totalDownloaded;
if (timeDiff > 0.5) {
// Update speed every 500ms
const currentSpeed = bytesDiff / (1024 * 1024) / timeDiff; // MB/s
// Smooth the speed with exponential moving average, but ensure positive values
const validCurrentSpeed = Math.max(0, currentSpeed);
const smoothedSpeed =
current.speed > 0
? current.speed * 0.8 + validCurrentSpeed * 0.2
: validCurrentSpeed;
newStats.set(progress.model_id, {
startTime: current.startTime,
if (!current) {
// First progress update - initialize
stats[progress.model_id] = {
startTime: now,
lastUpdate: now,
totalDownloaded: progress.downloaded,
speed: Math.max(0, smoothedSpeed),
});
}
}
speed: 0,
};
} else {
// Calculate speed over last few seconds
const timeDiff = (now - current.lastUpdate) / 1000; // seconds
const bytesDiff = progress.downloaded - current.totalDownloaded;
return newStats;
});
if (timeDiff > 0.5) {
// Update speed every 500ms
const currentSpeed = bytesDiff / (1024 * 1024) / timeDiff; // MB/s
// Smooth the speed with exponential moving average, but ensure positive values
const validCurrentSpeed = Math.max(0, currentSpeed);
const smoothedSpeed =
current.speed > 0
? current.speed * 0.8 + validCurrentSpeed * 0.2
: validCurrentSpeed;
stats[progress.model_id] = {
startTime: current.startTime,
lastUpdate: now,
totalDownloaded: progress.downloaded,
speed: Math.max(0, smoothedSpeed),
};
}
}
}),
);
},
);
@ -152,16 +152,16 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
"model-download-complete",
(event) => {
const modelId = event.payload;
setModelDownloadProgress((prev) => {
const newMap = new Map(prev);
newMap.delete(modelId);
return newMap;
});
setDownloadStats((prev) => {
const newStats = new Map(prev);
newStats.delete(modelId);
return newStats;
});
setModelDownloadProgress(
produce((progress) => {
delete progress[modelId];
}),
);
setDownloadStats(
produce((stats) => {
delete stats[modelId];
}),
);
loadModels(); // Refresh models list
// Auto-select the newly downloaded model (skip if recording in progress)
@ -181,7 +181,11 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
"model-extraction-started",
(event) => {
const modelId = event.payload;
setExtractingModels((prev) => new Set(prev.add(modelId)));
setExtractingModels(
produce((extracting) => {
extracting[modelId] = true;
}),
);
setModelStatus("extracting");
},
);
@ -190,11 +194,11 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
"model-extraction-completed",
(event) => {
const modelId = event.payload;
setExtractingModels((prev) => {
const next = new Set(prev);
next.delete(modelId);
return next;
});
setExtractingModels(
produce((extracting) => {
delete extracting[modelId];
}),
);
loadModels(); // Refresh models list
// Auto-select the newly extracted model (skip if recording in progress)
@ -214,11 +218,11 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
error: string;
}>("model-extraction-failed", (event) => {
const modelId = event.payload.model_id;
setExtractingModels((prev) => {
const next = new Set(prev);
next.delete(modelId);
return next;
});
setExtractingModels(
produce((extracting) => {
delete extracting[modelId];
}),
);
setModelError(`Failed to extract model: ${event.payload.error}`);
setModelStatus("error");
});
@ -329,9 +333,10 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
};
const getModelDisplayText = (): string => {
if (extractingModels.size > 0) {
if (extractingModels.size === 1) {
const [modelId] = Array.from(extractingModels);
const extractingKeys = Object.keys(extractingModels);
if (extractingKeys.length > 0) {
if (extractingKeys.length === 1) {
const modelId = extractingKeys[0];
const model = models.find((m) => m.id === modelId);
const modelName = model
? getTranslatedModelName(model, t)
@ -339,14 +344,15 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
return t("modelSelector.extracting", { modelName });
} else {
return t("modelSelector.extractingMultiple", {
count: extractingModels.size,
count: extractingKeys.length,
});
}
}
if (modelDownloadProgress.size > 0) {
if (modelDownloadProgress.size === 1) {
const [progress] = Array.from(modelDownloadProgress.values());
const progressValues = Object.values(modelDownloadProgress);
if (progressValues.length > 0) {
if (progressValues.length === 1) {
const progress = progressValues[0];
const percentage = Math.max(
0,
Math.min(100, Math.round(progress.percentage)),
@ -354,7 +360,7 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
return t("modelSelector.downloading", { percentage });
} else {
return t("modelSelector.downloadingMultiple", {
count: modelDownloadProgress.size,
count: progressValues.length,
});
}
}