refactor: replace JSON deep copy with deepClone helper and extract useTauriEvent hook (#3140)

* refactor: replace JSON.parse(JSON.stringify()) with structuredClone and extract useTauriEvent hook

Replace all `JSON.parse(JSON.stringify())` deep copy patterns with native
`structuredClone()` across production source (9 occurrences), tests (11
occurrences), and a hand-rolled `deepClone` utility in providerConfigUtils.ts.
Add "ES2022" to tsconfig lib for type support.

Extract a `useTauriEvent` hook to eliminate the repeated Tauri event listener
boilerplate (`useEffect` + `active/disposed` flag + async `listen`) that was
duplicated across App.tsx (3 listeners) and useUsageCacheBridge.ts. The hook
handles async registration, race-condition guards, and cleanup automatically.

* fix: add compatible deepClone helper

- Add a shared deepClone helper with a structuredClone runtime guard and fallback.
- Route clone call sites through the helper.
- Preserve universal-provider-synced listener ordering and drop the dead-directory diff.

* fix: harden Tauri event handling

- Guard WebDAV sync status events against missing payloads.
- Preserve settings query invalidation ordering before showing auto-sync errors.
- Simplify useTauriEvent subscriptions to avoid dependency-driven re-listens.

---------

Co-authored-by: zcb <zhangchongbiao@qiyuanlab.com>
Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
zcb
2026-05-26 23:39:16 +08:00
committed by GitHub
parent 05ba28016c
commit 8cdaf90d8d
12 changed files with 178 additions and 201 deletions
+37 -112
View File
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import { motion, AnimatePresence } from "framer-motion";
import { toast } from "sonner";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useQueryClient } from "@tanstack/react-query";
import {
Plus,
@@ -46,9 +45,11 @@ import { hermesApi } from "@/lib/api/hermes";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useAutoCompact } from "@/hooks/useAutoCompact";
import { useUsageCacheBridge } from "@/hooks/useUsageCacheBridge";
import { useTauriEvent } from "@/hooks/useTauriEvent";
import { useLastValidValue } from "@/hooks/useLastValidValue";
import { extractErrorMessage } from "@/utils/errorUtils";
import { isTextEditableTarget } from "@/utils/domUtils";
import { deepClone } from "@/utils/deepClone";
import { cn } from "@/lib/utils";
import {
isWindows,
@@ -361,117 +362,43 @@ function App() {
};
}, [activeApp, refetch]);
useEffect(() => {
let unsubscribe: (() => void) | undefined;
let active = true;
useTauriEvent("universal-provider-synced", async () => {
await queryClient.invalidateQueries({ queryKey: ["providers"] });
try {
await providersApi.updateTrayMenu();
} catch (error) {
console.error("[App] Failed to update tray menu", error);
}
});
const setupListener = async () => {
try {
const { listen } = await import("@tauri-apps/api/event");
const off = await listen("universal-provider-synced", async () => {
await queryClient.invalidateQueries({ queryKey: ["providers"] });
try {
await providersApi.updateTrayMenu();
} catch (error) {
console.error("[App] Failed to update tray menu", error);
}
});
if (!active) {
off();
return;
}
unsubscribe = off;
} catch (error) {
console.error(
"[App] Failed to subscribe universal-provider-synced event",
error,
);
}
};
void setupListener();
return () => {
active = false;
unsubscribe?.();
};
}, [queryClient]);
useEffect(() => {
let unsubscribe: (() => void) | undefined;
let active = true;
const setupListener = async () => {
try {
const off = await listen(
"webdav-sync-status-updated",
async (event) => {
const payload = (event.payload ??
{}) as WebDavSyncStatusUpdatedPayload;
await queryClient.invalidateQueries({ queryKey: ["settings"] });
if (payload.source !== "auto" || payload.status !== "error") {
return;
}
toast.error(
t("settings.webdavSync.autoSyncFailedToast", {
error: payload.error || t("common.unknown"),
}),
);
},
);
if (!active) {
off();
return;
}
unsubscribe = off;
} catch (error) {
console.error(
"[App] Failed to subscribe webdav-sync-status-updated event",
error,
);
}
};
void setupListener();
return () => {
active = false;
unsubscribe?.();
};
}, [queryClient, t]);
// Listen for proxy-official-warning: warn when takeover is enabled with an official provider
useEffect(() => {
let unsubscribe: (() => void) | undefined;
let active = true;
const setup = async () => {
const off = await listen("proxy-official-warning", (event) => {
const { providerName } = event.payload as {
appType: string;
providerName: string;
};
toast.warning(
t("notifications.proxyOfficialWarning", {
name: providerName,
defaultValue: `当前供应商 ${providerName} 是官方供应商,建议切换到第三方供应商后再使用代理接管`,
}),
{ duration: 8000 },
);
});
if (!active) {
off();
useTauriEvent<WebDavSyncStatusUpdatedPayload | null | undefined>(
"webdav-sync-status-updated",
async (payload) => {
const statusPayload = payload ?? {};
await queryClient.invalidateQueries({ queryKey: ["settings"] });
if (statusPayload.source !== "auto" || statusPayload.status !== "error") {
return;
}
unsubscribe = off;
};
toast.error(
t("settings.webdavSync.autoSyncFailedToast", {
error: statusPayload.error || t("common.unknown"),
}),
);
},
);
void setup();
return () => {
active = false;
unsubscribe?.();
};
}, [t]);
useTauriEvent<{ appType: string; providerName: string }>(
"proxy-official-warning",
(payload) => {
toast.warning(
t("notifications.proxyOfficialWarning", {
name: payload.providerName,
defaultValue: `当前供应商 ${payload.providerName} 是官方供应商,建议切换到第三方供应商后再使用代理接管`,
}),
{ duration: 8000 },
);
},
);
useEffect(() => {
let active = true;
@@ -744,13 +671,11 @@ function App() {
addToLive?: boolean;
} = {
name: `${provider.name} copy`,
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
settingsConfig: deepClone(provider.settingsConfig),
websiteUrl: provider.websiteUrl,
category: provider.category,
sortIndex: newSortIndex, // 复制原 sortIndex + 1
meta: provider.meta
? JSON.parse(JSON.stringify(provider.meta))
: undefined, // 深拷贝
meta: provider.meta ? deepClone(provider.meta) : undefined,
icon: provider.icon,
iconColor: provider.iconColor,
};
@@ -5,6 +5,7 @@ import type {
} from "@/config/claudeProviderPresets";
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
import { applyTemplateValues } from "@/utils/providerConfigUtils";
import { deepClone } from "@/utils/deepClone";
type TemplatePath = Array<string | number>;
type TemplateValueMap = Record<string, TemplateValueConfig>;
@@ -133,7 +134,7 @@ const applyTemplateValuesToConfigString = (
if (Array.isArray(parsedConfig)) {
targetConfig = [...parsedConfig];
} else if (parsedConfig && typeof parsedConfig === "object") {
targetConfig = JSON.parse(JSON.stringify(parsedConfig));
targetConfig = deepClone(parsedConfig);
} else {
targetConfig = {};
}
@@ -15,6 +15,7 @@ import {
createUniversalProviderFromPreset,
type UniversalProviderPreset,
} from "@/config/universalProviderPresets";
import { deepClone } from "@/utils/deepClone";
interface UniversalProviderFormModalProps {
isOpen: boolean;
@@ -90,7 +91,7 @@ export function UniversalProviderFormModal({
setClaudeEnabled(defaultPreset.defaultApps.claude);
setCodexEnabled(defaultPreset.defaultApps.codex);
setGeminiEnabled(defaultPreset.defaultApps.gemini);
setModels(JSON.parse(JSON.stringify(defaultPreset.defaultModels)));
setModels(deepClone(defaultPreset.defaultModels));
}
}, [editingProvider, initialPreset, isOpen]);
@@ -103,7 +104,7 @@ export function UniversalProviderFormModal({
setClaudeEnabled(preset.defaultApps.claude);
setCodexEnabled(preset.defaultApps.codex);
setGeminiEnabled(preset.defaultApps.gemini);
setModels(JSON.parse(JSON.stringify(preset.defaultModels)));
setModels(deepClone(preset.defaultModels));
}
},
[isEditMode],
@@ -7,6 +7,7 @@ import { UniversalProviderCard } from "./UniversalProviderCard";
import { UniversalProviderFormModal } from "./UniversalProviderFormModal";
import { universalProvidersApi } from "@/lib/api";
import type { UniversalProvider, UniversalProvidersMap } from "@/types";
import { deepClone } from "@/utils/deepClone";
export function UniversalProviderPanel() {
const { t } = useTranslation();
@@ -169,7 +170,7 @@ export function UniversalProviderPanel() {
const handleDuplicate = useCallback(
async (provider: UniversalProvider) => {
const duplicated: UniversalProvider = {
...JSON.parse(JSON.stringify(provider)),
...deepClone(provider),
id: crypto.randomUUID(),
name: `${provider.name} copy`,
createdAt: Date.now(),
+2 -1
View File
@@ -10,6 +10,7 @@ import type {
UniversalProviderApps,
UniversalProviderModels,
} from "@/types";
import { deepClone } from "@/utils/deepClone";
/**
* 统一供应商预设接口
@@ -106,7 +107,7 @@ export function createUniversalProviderFromPreset(
apps: { ...preset.defaultApps },
baseUrl,
apiKey,
models: JSON.parse(JSON.stringify(preset.defaultModels)), // Deep copy
models: deepClone(preset.defaultModels),
websiteUrl: preset.websiteUrl,
icon: preset.icon,
iconColor: preset.iconColor,
+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useRef } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
/**
* 在 useEffect 中监听 Tauri 事件,自动管理异步注册和卸载清理。
* 避免每次使用时重复编写 active flag + async setup 样板代码。
*/
export function useTauriEvent<P>(
eventName: string,
handler: (payload: P) => void | Promise<void>,
): void {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
let disposed = false;
let unlisten: UnlistenFn | undefined;
void (async () => {
try {
const off = await listen<P>(eventName, (event) => {
void handlerRef.current(event.payload);
});
if (disposed) {
off();
} else {
unlisten = off;
}
} catch (error) {
console.error(`Failed to subscribe ${eventName} event`, error);
}
})();
return () => {
disposed = true;
unlisten?.();
};
}, [eventName]);
}
+13 -36
View File
@@ -1,11 +1,10 @@
import { useEffect } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { useQueryClient } from "@tanstack/react-query";
import type { AppId } from "@/lib/api/types";
import type { UsageResult } from "@/types";
import type { SubscriptionQuota } from "@/types/subscription";
import { usageKeys } from "@/lib/query/usage";
import { subscriptionKeys } from "@/lib/query/subscription";
import { useTauriEvent } from "./useTauriEvent";
type UsageCacheUpdatedPayload =
| {
@@ -28,39 +27,17 @@ type UsageCacheUpdatedPayload =
export function useUsageCacheBridge() {
const queryClient = useQueryClient();
useEffect(() => {
let unlisten: UnlistenFn | undefined;
let disposed = false;
(async () => {
const off = await listen<UsageCacheUpdatedPayload>(
"usage-cache-updated",
(event) => {
const payload = event.payload;
if (payload.kind === "script") {
queryClient.setQueryData<UsageResult>(
usageKeys.script(payload.providerId, payload.appType),
payload.data,
);
} else if (payload.kind === "subscription") {
queryClient.setQueryData<SubscriptionQuota>(
subscriptionKeys.quota(payload.appType),
payload.data,
);
}
},
useTauriEvent<UsageCacheUpdatedPayload>("usage-cache-updated", (payload) => {
if (payload.kind === "script") {
queryClient.setQueryData<UsageResult>(
usageKeys.script(payload.providerId, payload.appType),
payload.data,
);
if (disposed) {
off();
} else {
unlisten = off;
}
})();
return () => {
disposed = true;
unlisten?.();
};
}, [queryClient]);
} else if (payload.kind === "subscription") {
queryClient.setQueryData<SubscriptionQuota>(
subscriptionKeys.quota(payload.appType),
payload.data,
);
}
});
}
+21
View File
@@ -0,0 +1,21 @@
export function deepClone<T>(value: T): T {
if (typeof globalThis.structuredClone === "function") {
return globalThis.structuredClone(value);
}
return deepCloneFallback(value);
}
function deepCloneFallback<T>(value: T): T {
if (value === null || typeof value !== "object") return value;
if (value instanceof Date) return new Date(value.getTime()) as T;
if (Array.isArray(value)) {
return value.map((item) => deepCloneFallback(item)) as T;
}
const cloned = {} as T;
Object.keys(value).forEach((key) => {
cloned[key as keyof T] = deepCloneFallback(value[key as keyof T]);
});
return cloned;
}
+1 -17
View File
@@ -1,6 +1,7 @@
// 供应商配置处理工具函数
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
import { deepClone } from "@/utils/deepClone";
import { normalizeTomlText } from "@/utils/textNormalization";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
@@ -62,23 +63,6 @@ const isSubset = (target: any, source: any): boolean => {
return target === source;
};
// 深拷贝函数
const deepClone = <T>(obj: T): T => {
if (obj === null || typeof obj !== "object") return obj;
if (obj instanceof Date) return new Date(obj.getTime()) as T;
if (obj instanceof Array) return obj.map((item) => deepClone(item)) as T;
if (obj instanceof Object) {
const clonedObj = {} as T;
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key]);
}
}
return clonedObj;
}
return obj;
};
export interface UpdateCommonConfigResult {
updatedConfig: string;
error?: string;