mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
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:
+37
-112
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
Reference in New Issue
Block a user