refactor: cleanup

This commit is contained in:
zhom
2026-06-24 02:00:44 +04:00
parent fe3ae13928
commit 8588a44fb5
22 changed files with 172 additions and 82 deletions
+75 -35
View File
@@ -712,49 +712,67 @@ export default function Home() {
);
const listenForUrlEvents = useCallback(async () => {
// Collect every listener we register so that — whether setup completes or
// throws partway through — we tear down exactly what was registered.
// Previously the Tauri unlisten handles were discarded (so re-runs stacked
// duplicate handlers and a single URL was handled N times), and a failing
// listen() call would leak the listeners that had already succeeded.
const unlisteners: Array<() => void> = [];
let handleLogoUrlEvent: ((event: CustomEvent) => void) | undefined;
const teardown = () => {
for (const unlisten of unlisteners) unlisten();
if (handleLogoUrlEvent) {
window.removeEventListener(
"url-open-request",
handleLogoUrlEvent as EventListener,
);
}
};
try {
// Listen for URL open events from the deep link handler (when app is already running)
await listen<string>("url-open-request", (event) => {
console.log("Received URL open request:", event.payload);
handleUrlOpen(event.payload);
});
unlisteners.push(
await listen<string>("url-open-request", (event) => {
console.log("Received URL open request:", event.payload);
handleUrlOpen(event.payload);
}),
);
// Listen for show profile selector events
await listen<string>("show-profile-selector", (event) => {
console.log("Received show profile selector request:", event.payload);
handleUrlOpen(event.payload);
});
unlisteners.push(
await listen<string>("show-profile-selector", (event) => {
console.log("Received show profile selector request:", event.payload);
handleUrlOpen(event.payload);
}),
);
// Listen for show create profile dialog events
await listen<string>("show-create-profile-dialog", (event) => {
console.log(
"Received show create profile dialog request:",
event.payload,
);
showErrorToast(t("errors.noProfilesForUrl"));
setCreateProfileDialogOpen(true);
});
unlisteners.push(
await listen<string>("show-create-profile-dialog", (event) => {
console.log(
"Received show create profile dialog request:",
event.payload,
);
showErrorToast(t("errors.noProfilesForUrl"));
setCreateProfileDialogOpen(true);
}),
);
// Listen for custom logo click events
const handleLogoUrlEvent = (event: CustomEvent) => {
handleLogoUrlEvent = (event: CustomEvent) => {
console.log("Received logo URL event:", event.detail);
handleUrlOpen(event.detail);
};
window.addEventListener(
"url-open-request",
handleLogoUrlEvent as EventListener,
);
// Return cleanup function
return () => {
window.removeEventListener(
"url-open-request",
handleLogoUrlEvent as EventListener,
);
};
return teardown;
} catch (error) {
console.error("Failed to setup URL listener:", error);
// Tear down whatever did register before the failure so nothing leaks.
teardown();
}
}, [handleUrlOpen, t]);
@@ -1257,6 +1275,7 @@ export default function Home() {
);
useEffect(() => {
let disposed = false;
let unlistenStatus: (() => void) | undefined;
let unlistenProgress: (() => void) | undefined;
const profilesWithTransfer = new Set<string>();
@@ -1333,25 +1352,35 @@ export default function Home() {
);
}
});
// If the effect was torn down while we were awaiting the listeners,
// unlisten immediately — the cleanup below already ran and would have
// missed these handles. (Tauri unlisten is safe to call more than once.)
if (disposed) {
unlistenStatus?.();
unlistenProgress?.();
}
} catch (error) {
console.error("Failed to listen for sync events:", error);
}
})();
return () => {
disposed = true;
if (unlistenStatus) unlistenStatus();
if (unlistenProgress) unlistenProgress();
};
}, [profiles, t]);
useEffect(() => {
// Listen for URL open events and get cleanup function
const setupListeners = async () => {
const cleanup = await listenForUrlEvents();
return cleanup;
};
// Listen for URL open events. Guard against the effect tearing down (or
// re-running) before the async listener setup resolves: if that happens,
// run the cleanup as soon as it's available so the listeners never leak.
let cleanup: (() => void) | undefined;
void setupListeners().then((cleanupFn) => {
let disposed = false;
void listenForUrlEvents().then((cleanupFn) => {
if (disposed) {
cleanupFn?.();
return;
}
cleanup = cleanupFn;
});
@@ -1379,10 +1408,9 @@ export default function Home() {
}
return () => {
disposed = true;
clearInterval(updateInterval);
if (cleanup) {
cleanup();
}
cleanup?.();
};
}, [
checkForUpdates,
@@ -1396,6 +1424,7 @@ export default function Home() {
// E2E encryption listeners — surface password-required prompts and rollover
// progress so the user isn't left guessing whether sealing finished.
useEffect(() => {
let disposed = false;
let unlistenRequired: (() => void) | undefined;
let unlistenStarted: (() => void) | undefined;
let unlistenProgress: (() => void) | undefined;
@@ -1472,9 +1501,20 @@ export default function Home() {
duration: 15000,
});
});
// If the effect was torn down mid-setup, the cleanup below already ran
// before these handles existed — unlisten them now so nothing leaks.
if (disposed) {
unlistenRequired?.();
unlistenStarted?.();
unlistenProgress?.();
unlistenCompleted?.();
unlistenWayfernBlocked?.();
}
})();
return () => {
disposed = true;
unlistenRequired?.();
unlistenStarted?.();
unlistenProgress?.();
+2 -2
View File
@@ -15,6 +15,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { translateBackendError } from "@/lib/backend-errors";
import type { ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
@@ -50,8 +51,7 @@ export function CreateGroupDialog({
onClose();
} catch (err) {
console.error("Failed to create group:", err);
const errorMessage =
err instanceof Error ? err.message : t("groups.createFailed");
const errorMessage = translateBackendError(t, err);
setError(errorMessage);
toast.error(errorMessage);
} finally {
+2 -2
View File
@@ -16,6 +16,7 @@ import {
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { ScrollArea } from "@/components/ui/scroll-area";
import { translateBackendError } from "@/lib/backend-errors";
import type { BrowserProfile, ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
@@ -97,8 +98,7 @@ export function DeleteGroupDialog({
onClose();
} catch (err) {
console.error("Failed to delete group:", err);
const errorMessage =
err instanceof Error ? err.message : t("groups.deleteFailed");
const errorMessage = translateBackendError(t, err);
setError(errorMessage);
toast.error(errorMessage);
} finally {
+2 -2
View File
@@ -15,6 +15,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { translateBackendError } from "@/lib/backend-errors";
import type { ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
@@ -61,8 +62,7 @@ export function EditGroupDialog({
onClose();
} catch (err) {
console.error("Failed to update group:", err);
const errorMessage =
err instanceof Error ? err.message : t("groups.updateFailed");
const errorMessage = translateBackendError(t, err);
setError(errorMessage);
toast.error(errorMessage);
} finally {
+10 -6
View File
@@ -495,9 +495,15 @@ export function GroupManagementDialog({
const results = await Promise.allSettled(
ids.map((groupId) => invoke("delete_profile_group", { groupId })),
);
const failed = results.filter((r) => r.status === "rejected");
if (failed.length > 0) {
showErrorToast(t("groups.deleteFailed"));
const firstRejection = results.find((r) => r.status === "rejected") as
| PromiseRejectedResult
| undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("groups.deleteFailed"),
);
} else {
showSuccessToast(t("groups.deleteSuccess"));
}
@@ -507,9 +513,7 @@ export function GroupManagementDialog({
onGroupManagementComplete();
} catch (err) {
console.error("Bulk group delete failed:", err);
showErrorToast(
err instanceof Error ? err.message : t("groups.deleteFailed"),
);
showErrorToast(translateBackendError(t, err));
} finally {
setIsBulkDeleting(false);
}
+13 -7
View File
@@ -1250,14 +1250,16 @@ export function ProfilesDataTable({
(id) => newSelection[id],
);
// Only update external state if selection actually changed
const prevIds = Object.keys(prevSelection).filter(
(id) => prevSelection[id],
// Only update external state if selection actually changed.
// A Set gives O(1) membership; Array.includes() inside .every() would
// be O(n*m) over large selections.
const prevIdSet = new Set(
Object.keys(prevSelection).filter((id) => prevSelection[id]),
);
if (
selectedIds.length !== prevIds.length ||
!selectedIds.every((id) => prevIds.includes(id))
selectedIds.length !== prevIdSet.size ||
!selectedIds.every((id) => prevIdSet.has(id))
) {
onSelectedProfilesChange(selectedIds);
}
@@ -1559,10 +1561,13 @@ export function ProfilesDataTable({
"get_all_traffic_snapshots",
);
const newSnapshots: Record<string, TrafficSnapshot> = {};
// O(1) membership; runningProfileIds.includes() in this loop would be
// O(snapshots * runningProfiles).
const runningSet = new Set(runningProfileIds);
for (const snapshot of allSnapshots) {
if (snapshot.profile_id) {
// Only keep snapshots for profiles that are currently running
if (runningProfileIds.includes(snapshot.profile_id)) {
if (runningSet.has(snapshot.profile_id)) {
const existing = newSnapshots[snapshot.profile_id];
if (!existing || snapshot.last_update > existing.last_update) {
newSnapshots[snapshot.profile_id] = snapshot;
@@ -1591,9 +1596,10 @@ export function ProfilesDataTable({
setTrafficSnapshots((prev) => {
const cleaned: Record<string, TrafficSnapshot> = {};
const runningSet = new Set(runningProfileIds);
for (const [profileId, snapshot] of Object.entries(prev)) {
// Only keep snapshots for profiles that are currently running
if (runningProfileIds.includes(profileId)) {
if (runningSet.has(profileId)) {
cleaned[profileId] = snapshot;
}
}
+6 -3
View File
@@ -21,6 +21,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { translateBackendError } from "@/lib/backend-errors";
import type { StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
@@ -127,9 +128,11 @@ export function ProxyFormDialog({
onClose();
} catch (error) {
console.error("Failed to save proxy:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(t("proxies.form.saveFailed", { error: errorMessage }));
toast.error(
t("proxies.form.saveFailed", {
error: translateBackendError(t, error),
}),
);
} finally {
setIsSubmitting(false);
}
+1
View File
@@ -1844,6 +1844,7 @@
"passwordTooShort": "Password must be at least {{min}} characters",
"proxyNotFound": "Proxy not found",
"groupNotFound": "Group not found",
"groupAlreadyExists": "A group with this name already exists",
"vpnNotFound": "VPN not found",
"extensionNotFound": "Extension not found",
"extensionGroupNotFound": "Extension group not found",
+1
View File
@@ -1844,6 +1844,7 @@
"passwordTooShort": "La contraseña debe tener al menos {{min}} caracteres",
"proxyNotFound": "Proxy no encontrado",
"groupNotFound": "Grupo no encontrado",
"groupAlreadyExists": "Ya existe un grupo con este nombre",
"vpnNotFound": "VPN no encontrada",
"extensionNotFound": "Extensión no encontrada",
"extensionGroupNotFound": "Grupo de extensiones no encontrado",
+1
View File
@@ -1844,6 +1844,7 @@
"passwordTooShort": "Le mot de passe doit comporter au moins {{min}} caractères",
"proxyNotFound": "Proxy introuvable",
"groupNotFound": "Groupe introuvable",
"groupAlreadyExists": "Un groupe portant ce nom existe déjà",
"vpnNotFound": "VPN introuvable",
"extensionNotFound": "Extension introuvable",
"extensionGroupNotFound": "Groupe d'extensions introuvable",
+1
View File
@@ -1844,6 +1844,7 @@
"passwordTooShort": "パスワードは {{min}} 文字以上必要です",
"proxyNotFound": "プロキシが見つかりません",
"groupNotFound": "グループが見つかりません",
"groupAlreadyExists": "この名前のグループは既に存在します",
"vpnNotFound": "VPNが見つかりません",
"extensionNotFound": "拡張機能が見つかりません",
"extensionGroupNotFound": "拡張機能グループが見つかりません",
+1
View File
@@ -1844,6 +1844,7 @@
"passwordTooShort": "비밀번호는 {{min}}자 이상이어야 합니다",
"proxyNotFound": "프록시를 찾을 수 없습니다",
"groupNotFound": "그룹을 찾을 수 없습니다",
"groupAlreadyExists": "이 이름의 그룹이 이미 존재합니다",
"vpnNotFound": "VPN을 찾을 수 없습니다",
"extensionNotFound": "확장 프로그램을 찾을 수 없습니다",
"extensionGroupNotFound": "확장 프로그램 그룹을 찾을 수 없습니다",
+1
View File
@@ -1844,6 +1844,7 @@
"passwordTooShort": "A senha deve ter pelo menos {{min}} caracteres",
"proxyNotFound": "Proxy não encontrado",
"groupNotFound": "Grupo não encontrado",
"groupAlreadyExists": "Já existe um grupo com este nome",
"vpnNotFound": "VPN não encontrada",
"extensionNotFound": "Extensão não encontrada",
"extensionGroupNotFound": "Grupo de extensões não encontrado",
+1
View File
@@ -1844,6 +1844,7 @@
"passwordTooShort": "Пароль должен быть не короче {{min}} символов",
"proxyNotFound": "Прокси не найден",
"groupNotFound": "Группа не найдена",
"groupAlreadyExists": "Группа с таким именем уже существует",
"vpnNotFound": "VPN не найден",
"extensionNotFound": "Расширение не найдено",
"extensionGroupNotFound": "Группа расширений не найдена",
+1
View File
@@ -1844,6 +1844,7 @@
"passwordTooShort": "Mật khẩu phải có ít nhất {{min}} ký tự",
"proxyNotFound": "Không tìm thấy proxy",
"groupNotFound": "Không tìm thấy nhóm",
"groupAlreadyExists": "Đã tồn tại một nhóm với tên này",
"vpnNotFound": "Không tìm thấy VPN",
"extensionNotFound": "Không tìm thấy tiện ích",
"extensionGroupNotFound": "Không tìm thấy nhóm tiện ích",
+1
View File
@@ -1844,6 +1844,7 @@
"passwordTooShort": "密码至少需要 {{min}} 个字符",
"proxyNotFound": "未找到代理",
"groupNotFound": "未找到分组",
"groupAlreadyExists": "已存在同名分组",
"vpnNotFound": "未找到 VPN",
"extensionNotFound": "未找到扩展",
"extensionGroupNotFound": "未找到扩展分组",
+3
View File
@@ -22,6 +22,7 @@ export type BackendErrorCode =
| "SELF_HOSTED_REQUIRES_LOGOUT"
| "PROXY_NOT_FOUND"
| "GROUP_NOT_FOUND"
| "GROUP_ALREADY_EXISTS"
| "VPN_NOT_FOUND"
| "EXTENSION_NOT_FOUND"
| "EXTENSION_GROUP_NOT_FOUND"
@@ -113,6 +114,8 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.proxyNotFound");
case "GROUP_NOT_FOUND":
return t("backendErrors.groupNotFound");
case "GROUP_ALREADY_EXISTS":
return t("backendErrors.groupAlreadyExists");
case "VPN_NOT_FOUND":
return t("backendErrors.vpnNotFound");
case "EXTENSION_NOT_FOUND":