From 8cdaf90d8d5b2d50c25724b38e32042df75c256c Mon Sep 17 00:00:00 2001 From: zcb <50308342+ChongBiaoZhang@users.noreply.github.com> Date: Tue, 26 May 2026 23:39:16 +0800 Subject: [PATCH] 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 Co-authored-by: Jason --- src/App.tsx | 149 +++++------------- .../forms/hooks/useTemplateValues.ts | 3 +- .../universal/UniversalProviderFormModal.tsx | 5 +- .../universal/UniversalProviderPanel.tsx | 3 +- src/config/universalProviderPresets.ts | 3 +- src/hooks/useTauriEvent.ts | 39 +++++ src/hooks/useUsageCacheBridge.ts | 49 ++---- src/utils/deepClone.ts | 21 +++ src/utils/providerConfigUtils.ts | 18 +-- tests/integration/App.test.tsx | 5 + tests/msw/state.ts | 54 +++---- tests/utils/deepClone.test.ts | 30 ++++ 12 files changed, 178 insertions(+), 201 deletions(-) create mode 100644 src/hooks/useTauriEvent.ts create mode 100644 src/utils/deepClone.ts create mode 100644 tests/utils/deepClone.test.ts diff --git a/src/App.tsx b/src/App.tsx index 26d64fd6f..d1497ffce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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( + "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, }; diff --git a/src/components/providers/forms/hooks/useTemplateValues.ts b/src/components/providers/forms/hooks/useTemplateValues.ts index 2b677f25e..365eae56f 100644 --- a/src/components/providers/forms/hooks/useTemplateValues.ts +++ b/src/components/providers/forms/hooks/useTemplateValues.ts @@ -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; type TemplateValueMap = Record; @@ -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 = {}; } diff --git a/src/components/universal/UniversalProviderFormModal.tsx b/src/components/universal/UniversalProviderFormModal.tsx index e085797ba..0aa43ddc5 100644 --- a/src/components/universal/UniversalProviderFormModal.tsx +++ b/src/components/universal/UniversalProviderFormModal.tsx @@ -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], diff --git a/src/components/universal/UniversalProviderPanel.tsx b/src/components/universal/UniversalProviderPanel.tsx index 22eddd928..42eb16220 100644 --- a/src/components/universal/UniversalProviderPanel.tsx +++ b/src/components/universal/UniversalProviderPanel.tsx @@ -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(), diff --git a/src/config/universalProviderPresets.ts b/src/config/universalProviderPresets.ts index e99e506f9..55f73fe5f 100644 --- a/src/config/universalProviderPresets.ts +++ b/src/config/universalProviderPresets.ts @@ -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, diff --git a/src/hooks/useTauriEvent.ts b/src/hooks/useTauriEvent.ts new file mode 100644 index 000000000..bf3e51405 --- /dev/null +++ b/src/hooks/useTauriEvent.ts @@ -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

( + eventName: string, + handler: (payload: P) => void | Promise, +): void { + const handlerRef = useRef(handler); + handlerRef.current = handler; + + useEffect(() => { + let disposed = false; + let unlisten: UnlistenFn | undefined; + + void (async () => { + try { + const off = await listen

(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]); +} diff --git a/src/hooks/useUsageCacheBridge.ts b/src/hooks/useUsageCacheBridge.ts index ed0deb13d..6f9a4cae6 100644 --- a/src/hooks/useUsageCacheBridge.ts +++ b/src/hooks/useUsageCacheBridge.ts @@ -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( - "usage-cache-updated", - (event) => { - const payload = event.payload; - if (payload.kind === "script") { - queryClient.setQueryData( - usageKeys.script(payload.providerId, payload.appType), - payload.data, - ); - } else if (payload.kind === "subscription") { - queryClient.setQueryData( - subscriptionKeys.quota(payload.appType), - payload.data, - ); - } - }, + useTauriEvent("usage-cache-updated", (payload) => { + if (payload.kind === "script") { + queryClient.setQueryData( + 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( + subscriptionKeys.quota(payload.appType), + payload.data, + ); + } + }); } diff --git a/src/utils/deepClone.ts b/src/utils/deepClone.ts new file mode 100644 index 000000000..11d7b1b37 --- /dev/null +++ b/src/utils/deepClone.ts @@ -0,0 +1,21 @@ +export function deepClone(value: T): T { + if (typeof globalThis.structuredClone === "function") { + return globalThis.structuredClone(value); + } + + return deepCloneFallback(value); +} + +function deepCloneFallback(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; +} diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index b3df73aeb..337fe2316 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -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 = (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; diff --git a/tests/integration/App.test.tsx b/tests/integration/App.test.tsx index e3a26d1c6..b9dde3cf7 100644 --- a/tests/integration/App.test.tsx +++ b/tests/integration/App.test.tsx @@ -230,6 +230,11 @@ describe("App integration with MSW", () => { ), ); + expect(() => { + emitTauriEvent("webdav-sync-status-updated", null); + }).not.toThrow(); + expect(toastErrorMock).not.toHaveBeenCalled(); + emitTauriEvent("webdav-sync-status-updated", { source: "auto", status: "error", diff --git a/tests/msw/state.ts b/tests/msw/state.ts index ac0d5bdd5..85b9bc20d 100644 --- a/tests/msw/state.ts +++ b/tests/msw/state.ts @@ -6,11 +6,15 @@ import type { SessionMeta, Settings, } from "@/types"; +import { deepClone } from "@/utils/deepClone"; type ProvidersByApp = Record>; type CurrentProviderState = Record; type McpConfigState = Record>; -type LiveProviderIdsByApp = Record<"opencode" | "openclaw" | "hermes", string[]>; +type LiveProviderIdsByApp = Record< + "opencode" | "openclaw" | "hermes", + string[] +>; const createDefaultProviders = (): ProvidersByApp => ({ claude: { @@ -193,7 +197,7 @@ let mcpConfigs: McpConfigState = { }; const cloneProviders = (value: ProvidersByApp) => - JSON.parse(JSON.stringify(value)) as ProvidersByApp; + deepClone(value) as ProvidersByApp; export const resetProviderState = () => { providers = createDefaultProviders(); @@ -266,9 +270,9 @@ export const getProviders = (appType: AppId) => export const getCurrentProviderId = (appType: AppId) => current[appType] ?? ""; -export const getLiveProviderIds = (appType: "opencode" | "openclaw" | "hermes") => [ - ...liveProviderIds[appType], -]; +export const getLiveProviderIds = ( + appType: "opencode" | "openclaw" | "hermes", +) => [...liveProviderIds[appType]]; export const setLiveProviderIds = ( appType: "opencode" | "openclaw" | "hermes", @@ -294,10 +298,7 @@ export const setProviders = ( appType: AppId, data: Record, ) => { - providers[appType] = JSON.parse(JSON.stringify(data)) as Record< - string, - Provider - >; + providers[appType] = deepClone(data) as Record; }; export const addProvider = (appType: AppId, provider: Provider) => { @@ -336,13 +337,9 @@ export const updateSortOrder = ( }; export const listProviders = (appType: AppId) => - JSON.parse(JSON.stringify(providers[appType] ?? {})) as Record< - string, - Provider - >; + deepClone(providers[appType] ?? {}) as Record; -export const getSettings = () => - JSON.parse(JSON.stringify(settingsState)) as Settings; +export const getSettings = () => deepClone(settingsState) as Settings; export const setSettings = (data: Partial) => { settingsState = { ...settingsState, ...data }; @@ -355,9 +352,10 @@ export const setAppConfigDirOverrideState = (value: string | null) => { }; export const getMcpConfig = (appType: AppId) => { - const servers = JSON.parse( - JSON.stringify(mcpConfigs[appType] ?? {}), - ) as Record; + const servers = deepClone(mcpConfigs[appType] ?? {}) as Record< + string, + McpServer + >; return { configPath: `/mock/${appType}.mcp.json`, servers, @@ -368,10 +366,7 @@ export const setMcpConfig = ( appType: AppId, value: Record, ) => { - mcpConfigs[appType] = JSON.parse(JSON.stringify(value)) as Record< - string, - McpServer - >; + mcpConfigs[appType] = deepClone(value) as Record; }; export const setMcpServerEnabled = ( @@ -394,7 +389,7 @@ export const upsertMcpServer = ( if (!mcpConfigs[appType]) { mcpConfigs[appType] = {}; } - mcpConfigs[appType][id] = JSON.parse(JSON.stringify(server)) as McpServer; + mcpConfigs[appType][id] = deepClone(server) as McpServer; }; export const deleteMcpServer = (appType: AppId, id: string) => { @@ -402,14 +397,11 @@ export const deleteMcpServer = (appType: AppId, id: string) => { delete mcpConfigs[appType][id]; }; -export const listSessions = () => - JSON.parse(JSON.stringify(sessionsState)) as SessionMeta[]; +export const listSessions = () => deepClone(sessionsState) as SessionMeta[]; export const getSessionMessages = (providerId: string, sourcePath: string) => - JSON.parse( - JSON.stringify( - sessionMessagesState[sessionMessageKey(providerId, sourcePath)] ?? [], - ), + deepClone( + sessionMessagesState[sessionMessageKey(providerId, sourcePath)] ?? [], ) as SessionMessage[]; export const deleteSession = ( @@ -433,8 +425,8 @@ export const setSessionFixtures = ( sessions: SessionMeta[], messages: Record, ) => { - sessionsState = JSON.parse(JSON.stringify(sessions)) as SessionMeta[]; - sessionMessagesState = JSON.parse(JSON.stringify(messages)) as Record< + sessionsState = deepClone(sessions) as SessionMeta[]; + sessionMessagesState = deepClone(messages) as Record< string, SessionMessage[] >; diff --git a/tests/utils/deepClone.test.ts b/tests/utils/deepClone.test.ts new file mode 100644 index 000000000..a7367c33b --- /dev/null +++ b/tests/utils/deepClone.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { deepClone } from "@/utils/deepClone"; + +describe("deepClone", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("falls back when structuredClone is unavailable", () => { + vi.stubGlobal("structuredClone", undefined); + + const source = { + nested: { value: "original" }, + list: [{ enabled: true }], + createdAt: new Date("2026-01-30T00:00:00.000Z"), + }; + + const cloned = deepClone(source); + cloned.nested.value = "changed"; + cloned.list[0].enabled = false; + + expect(cloned).not.toBe(source); + expect(cloned.nested).not.toBe(source.nested); + expect(cloned.list).not.toBe(source.list); + expect(cloned.createdAt).not.toBe(source.createdAt); + expect(cloned.createdAt.getTime()).toBe(source.createdAt.getTime()); + expect(source.nested.value).toBe("original"); + expect(source.list[0].enabled).toBe(true); + }); +});