diff --git a/src/pages/batchupdate/App.tsx b/src/pages/batchupdate/App.tsx index 163261fcf..3386cf849 100644 --- a/src/pages/batchupdate/App.tsx +++ b/src/pages/batchupdate/App.tsx @@ -17,8 +17,8 @@ import { } from "@App/app/service/service_worker/types"; import { dayFormat } from "@App/pkg/utils/day_format"; import { IconSync } from "@arco-design/web-react/icon"; -import { useAppContext } from "../store/AppContext"; import { SCRIPT_STATUS_ENABLE } from "@App/app/repo/scripts"; +import { subscribeMessage } from "@App/pages/store/global"; const CollapseItem = Collapse.Item; const { GridItem } = Grid; @@ -29,8 +29,6 @@ const { Text } = Typography; const pageExecute: Record void> = {}; function App() { - const { subscribeMessage } = useAppContext(); - const AUTO_CLOSE_PAGE = 8; // after 8s, auto close const getUrlParam = (key: string): string => { return (location.search?.includes(`${key}=`) ? new URLSearchParams(location.search).get(`${key}`) : "") || ""; diff --git a/src/pages/options/routes/ScriptList/hooks.tsx b/src/pages/options/routes/ScriptList/hooks.tsx index c3a2c006f..425082748 100644 --- a/src/pages/options/routes/ScriptList/hooks.tsx +++ b/src/pages/options/routes/ScriptList/hooks.tsx @@ -13,7 +13,6 @@ import { loadScriptFavicons } from "@App/pages/store/favicons"; import { parseTags } from "@App/app/repo/metadata"; import { getCombinedMeta } from "@App/app/service/service_worker/utils"; import { cacheInstance } from "@App/app/cache"; -import { useAppContext } from "@App/pages/store/AppContext"; // 组件与工具 import { type SearchFilterRequest } from "./SearchFilter"; @@ -39,6 +38,8 @@ import type { TSortedScript, } from "@App/app/service/queue"; import { type useTranslation } from "react-i18next"; +import { subscribeMessage } from "@App/pages/store/global"; +import { HookManager } from "@App/pkg/utils/hookManager"; export type TFilterKey = null | string | number; @@ -64,7 +65,6 @@ export type TSelectFilterKeys = keyof TSelectFilter; export function useScriptDataManagement() { const [scriptList, setScriptList] = useState([]); const [loadingList, setLoadingList] = useState(true); - const { subscribeMessage } = useAppContext(); // 初始化列表与 Favicon 加载 useEffect(() => { @@ -188,18 +188,16 @@ export function useScriptDataManagement() { }, } as const; - const unhooks = [ + const hookMgr = new HookManager(); + hookMgr.append( subscribeMessage("scriptRunStatus", pageApi.scriptRunStatus), subscribeMessage("installScript", pageApi.installScript), subscribeMessage("deleteScripts", pageApi.deleteScripts), subscribeMessage("enableScripts", pageApi.enableScripts), - subscribeMessage("sortedScripts", pageApi.sortedScripts), - ]; - return () => { - for (const unhook of unhooks) unhook(); - unhooks.length = 0; - }; - }, [subscribeMessage]); + subscribeMessage("sortedScripts", pageApi.sortedScripts) + ); + return hookMgr.unhook; + }, []); return { scriptList, setScriptList, loadingList }; } diff --git a/src/pages/options/routes/Setting.tsx b/src/pages/options/routes/Setting.tsx index 22b3a640e..afb779499 100644 --- a/src/pages/options/routes/Setting.tsx +++ b/src/pages/options/routes/Setting.tsx @@ -13,16 +13,15 @@ import { blackListSelfCheck } from "@App/pkg/utils/match"; import { obtainBlackList } from "@App/pkg/utils/utils"; import CustomTrans from "@App/pages/components/CustomTrans"; import { useSystemConfig } from "./utils"; -import { useAppContext } from "@App/pages/store/AppContext"; +import { subscribeMessage } from "@App/pages/store/global"; import { SystemConfigChange, type SystemConfigKey } from "@App/pkg/config/config"; import { type TKeyValue } from "@Packages/message/message_queue"; import { useEffect, useMemo } from "react"; import { systemConfig } from "@App/pages/store/global"; import { initRegularUpdateCheck } from "@App/app/service/service_worker/regular_updatecheck"; +import { HookManager } from "@App/pkg/utils/hookManager"; function Setting() { - const { subscribeMessage } = useAppContext(); - const [editorConfig, setEditorConfig, submitEditorConfig] = useSystemConfig("editor_config"); const [cloudSync, setCloudSync, submitCloudSync] = useSystemConfig("cloud_sync"); const [language, setLanguage, submitLanguage] = useSystemConfig("language"); @@ -84,7 +83,8 @@ function Setting() { script_menu_display_type: setScriptMenuDisplayType, editor_type_definition: setEditorTypeDefinition, } as const; - const unhooks = [ + const hookMgr = new HookManager(); + hookMgr.append( subscribeMessage>( SystemConfigChange, ({ key, value: _value }: TKeyValue) => { @@ -102,12 +102,9 @@ function Setting() { }); } } - ), - ]; - return () => { - for (const unhook of unhooks) unhook(); - unhooks.length = 0; - }; + ) + ); + return hookMgr.unhook; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/pages/popup/App.tsx b/src/pages/popup/App.tsx index 1a5e0fd12..26a42c3c5 100644 --- a/src/pages/popup/App.tsx +++ b/src/pages/popup/App.tsx @@ -22,9 +22,10 @@ import type { ScriptMenu, TPopupScript } from "@App/app/service/service_worker/t import { systemConfig } from "@App/pages/store/global"; import { isChineseUser, localePath } from "@App/locales/locales"; import { getCurrentTab } from "@App/pkg/utils/utils"; -import { useAppContext } from "../store/AppContext"; +import { subscribeMessage } from "@App/pages/store/global"; import type { TDeleteScript, TEnableScript, TScriptRunStatus } from "@App/app/service/queue"; import { SCRIPT_RUN_STATUS_RUNNING } from "@App/app/repo/scripts"; +import { HookManager } from "@App/pkg/utils/hookManager"; const CollapseItem = Collapse.Item; @@ -138,9 +139,8 @@ function App() { return url?.hostname ?? ""; }, [currentUrl]); - const { subscribeMessage } = useAppContext(); useEffect(() => { - let isMounted = true; + const hookMgr = new HookManager(); const updateScriptList = (update: TUpdateEntryFn, options?: TUpdateListOption) => { // 当 启用/禁用/菜单改变 时,如有必要则更新 list 参考 @@ -148,7 +148,7 @@ function App() { setBackScriptList((prev) => updateList(prev, update, options)); }; - const unhooks = [ + hookMgr.append( // 订阅脚本啟用状态变更(enableScripts),即时更新对应项目的 enable。 subscribeMessage("enableScripts", (data) => { updateScriptList((item) => { @@ -196,7 +196,7 @@ function App() { }); if (!url) return; popupClient.getPopupData({ url, tabId }).then((resp) => { - if (!isMounted) return; + if (!hookMgr.isMounted) return; // 响应健全性检查:必须包含 scriptList,否则忽略此次更新 if (!resp || !resp.scriptList) { @@ -225,8 +225,8 @@ function App() { ); }); } - }), - ]; + }) + ); const onCurrentUrlUpdated = (url: string, tabId: number) => { pageTabIdRef.current = tabId; @@ -234,7 +234,7 @@ function App() { popupClient .getPopupData({ url, tabId }) .then((resp) => { - if (!isMounted) return; + if (!hookMgr.isMounted) return; // 确保响应有效 if (!resp || !resp.scriptList) { @@ -255,14 +255,14 @@ function App() { }) .catch((error) => { console.error("Failed to get popup data:", error); - if (!isMounted) return; + if (!hookMgr.isMounted) return; // 设为安全预设,避免 UI 因错误状态而崩溃 setScriptList([]); setBackScriptList([]); setIsBlacklist(false); }) .finally(() => { - if (!isMounted) return; + if (!hookMgr.isMounted) return; setLoading(false); }); }; @@ -272,7 +272,7 @@ function App() { systemConfig.getEnableScript(), systemConfig.getCheckUpdate(), ]); - if (!isMounted) return; + if (!hookMgr.isMounted) return; setIsEnableScript(isEnableScript); setCheckUpdate(checkUpdate); }; @@ -280,7 +280,7 @@ function App() { // 仅在挂载时读取一次页签信息;不绑定 currentUrl 以避免重复查询 try { const tab = await getCurrentTab(); - if (!isMounted || !tab) return; + if (!hookMgr.isMounted || !tab) return; const newUrl = tab.url || ""; setCurrentUrl((prev) => { if (newUrl !== prev) { @@ -296,12 +296,8 @@ function App() { checkScriptEnableAndUpdate(); queryTabInfo(); - return () => { - isMounted = false; - for (const unhook of unhooks) unhook(); - unhooks.length = 0; - }; - }, [subscribeMessage]); + return hookMgr.unhook; + }, []); const { handleEnableScriptChange, handleSettingsClick, handleNotificationClick } = { handleEnableScriptChange: (val: boolean) => { diff --git a/src/pages/store/AppContext.tsx b/src/pages/store/AppContext.tsx index df1cf99c4..7c325df81 100644 --- a/src/pages/store/AppContext.tsx +++ b/src/pages/store/AppContext.tsx @@ -1,5 +1,7 @@ import React, { useState, createContext, type ReactNode, useEffect, useContext } from "react"; import { messageQueue } from "./global"; +import { HookManager } from "@App/pkg/utils/hookManager"; +import { subscribeMessage } from "@App/pages/store/global"; export const fnPlaceHolder = { setEditorTheme: null, @@ -9,7 +11,6 @@ export type ThemeParam = { theme: "auto" | "light" | "dark" }; export interface AppContextType { colorThemeState: "auto" | "light" | "dark"; updateColorTheme: (theme: "auto" | "light" | "dark") => void; - subscribeMessage: (topic: string, handler: (msg: T) => void) => () => void; // 指引模式 setGuideMode: (mode: boolean) => void; guideMode: boolean; @@ -66,15 +67,6 @@ export const AppProvider: React.FC = ({ children }) => { }); const [guideMode, setGuideMode] = useState(false); - const subscribeMessage = (topic: string, handler: (msg: T) => void) => { - return messageQueue.subscribe(topic, (data) => { - const message = data?.myMessage || data; - if (typeof message === "object") { - handler(message as T); - } - }); - }; - useEffect(() => { const pageApi = { onColorThemeUpdated({ theme }: ThemeParam) { @@ -83,11 +75,10 @@ export const AppProvider: React.FC = ({ children }) => { }, } as const; - const unhooks = [subscribeMessage("onColorThemeUpdated", pageApi.onColorThemeUpdated)]; - return () => { - for (const unhook of unhooks) unhook(); - unhooks.length = 0; - }; + const hookMgr = new HookManager(); + hookMgr.append(subscribeMessage("onColorThemeUpdated", pageApi.onColorThemeUpdated)); + + return hookMgr.unhook; }, []); const updateColorTheme = (theme: "auto" | "light" | "dark") => { @@ -100,7 +91,6 @@ export const AppProvider: React.FC = ({ children }) => { value={{ colorThemeState, updateColorTheme, - subscribeMessage, setGuideMode, guideMode, }} diff --git a/src/pages/store/global.ts b/src/pages/store/global.ts index 0a8f4229f..e1a660289 100644 --- a/src/pages/store/global.ts +++ b/src/pages/store/global.ts @@ -10,4 +10,13 @@ export const globalCache = new Map(); export const message = new ExtensionMessage(); export const systemClient = new SystemClient(message); +export const subscribeMessage = (topic: string, handler: (msg: T) => void) => { + return messageQueue.subscribe(topic, (data) => { + const payload = data?.myMessage || data; + if (typeof payload === "object") { + handler(payload as T); + } + }); +}; + initLocales(systemConfig); diff --git a/src/pkg/utils/hookManager.ts b/src/pkg/utils/hookManager.ts new file mode 100644 index 000000000..39d224167 --- /dev/null +++ b/src/pkg/utils/hookManager.ts @@ -0,0 +1,18 @@ +export class HookManager { + public isMounted: boolean = true; + // 存储卸载时调用的钩子函数;unhook 后置为 null + private unhooks: (() => void)[] | null = []; + public append(...fns: ((...args: any) => any)[]) { + // 已经 unhook 的情况下保持幂等,直接忽略追加 + this.unhooks?.push(...fns); + } + public readonly unhook = () => { + // 已经 unhook 过则保持幂等 + this.isMounted = false; + if (this.unhooks !== null) { + for (const unhook of this.unhooks!) unhook(); + this.unhooks!.length = 0; + this.unhooks = null; + } + }; +} diff --git a/tests/pages/options/MainLayout.test.tsx b/tests/pages/options/MainLayout.test.tsx index 1240743ee..72a536dfe 100644 --- a/tests/pages/options/MainLayout.test.tsx +++ b/tests/pages/options/MainLayout.test.tsx @@ -66,6 +66,7 @@ vi.mock("@App/pages/store/global", () => ({ error: vi.fn(), warning: vi.fn(), }, + subscribeMessage: () => vi.fn(), })); vi.mock("@App/pkg/utils/utils", () => ({ diff --git a/tests/pages/popup/App.test.tsx b/tests/pages/popup/App.test.tsx index 9cfe7b175..fa1bde6e7 100644 --- a/tests/pages/popup/App.test.tsx +++ b/tests/pages/popup/App.test.tsx @@ -60,6 +60,7 @@ vi.mock("@App/pages/store/global", () => ({ error: vi.fn(), warning: vi.fn(), }, + subscribeMessage: () => vi.fn(), })); vi.mock("@App/pkg/utils/utils", () => ({