From 0c38ce02e9184b983355227adbcf9b7b90516335 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 1 Aug 2026 20:03:59 +0800 Subject: [PATCH 01/12] feat(core): add willCleanUp.ts module to manage cleanups before unload --- src/core/utils/__tests__/willCleanUp.test.ts | 51 ++++++++++++++++++++ src/core/utils/willCleanUp.ts | 32 ++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 src/core/utils/__tests__/willCleanUp.test.ts create mode 100644 src/core/utils/willCleanUp.ts diff --git a/src/core/utils/__tests__/willCleanUp.test.ts b/src/core/utils/__tests__/willCleanUp.test.ts new file mode 100644 index 000000000..e44ea8cfc --- /dev/null +++ b/src/core/utils/__tests__/willCleanUp.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { WillCleanUp } from '@/core/utils/willCleanUp'; + +describe('willCleanUp tests module', () => { + let willCleanUp: WillCleanUp; + + beforeEach(() => { + willCleanUp = new WillCleanUp(); + }); + + it('can store registered cleanup functions', () => { + const function1 = () => {}; + const function2 = () => {}; + + willCleanUp.it(function1); + willCleanUp.it(function2); + willCleanUp.it(function2); // won't store duplicate functions + + expect(willCleanUp.list()).toEqual([function1, function2]); + }); + + it('can execute registered cleanup functions at correct time', () => { + const function1 = vi.fn(); + + willCleanUp.it(function1); + + expect(function1).not.toHaveBeenCalled(); + + willCleanUp.execute(); + + expect(function1).toHaveBeenCalled(); + expect(function1).toHaveBeenCalledTimes(1); + + willCleanUp.execute(); // no duplicate call + + expect(function1).toHaveBeenCalledTimes(1); + }); + + it('can release stored cleanup functions at correct time', () => { + const function1 = () => {}; + + willCleanUp.it(function1); + + expect(willCleanUp.list()).not.toEqual([]); + + willCleanUp.execute(); + + expect(willCleanUp.list()).toEqual([]); + }); +}); diff --git a/src/core/utils/willCleanUp.ts b/src/core/utils/willCleanUp.ts new file mode 100644 index 000000000..29d144a92 --- /dev/null +++ b/src/core/utils/willCleanUp.ts @@ -0,0 +1,32 @@ +/** + * A class that manages and executes cleanup functions in code entrypoint. + */ +export class WillCleanUp { + private cleanUps: Array<() => void> = []; + + constructor() {} + + /** + * Register a cleanup function waited to be called. + * @param arg A function that does cleanup operation when called. + */ + it(arg: () => void): void { + if (this.cleanUps.includes(arg)) return; + this.cleanUps.push(arg); + } + + /** + * [debug] return a readonly list containing stored cleanup functions. + */ + list(): Array<() => void> { + return [...this.cleanUps] as const; + } + + /** + * Call all functions registered by `it` functions, and clear their references. + */ + execute(): void { + this.cleanUps.forEach((it) => it()); + this.cleanUps = []; + } +} From e0db3820058a38728f13782c39ec7d9a85acf80a Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 1 Aug 2026 20:20:54 +0800 Subject: [PATCH 02/12] feat: apply WillCleanUp to reduce code repeat in src/pages/content/index.tsx --- src/pages/content/index.tsx | 178 +++++++++++------------------------- 1 file changed, 53 insertions(+), 125 deletions(-) diff --git a/src/pages/content/index.tsx b/src/pages/content/index.tsx index 14b932637..3221788d8 100644 --- a/src/pages/content/index.tsx +++ b/src/pages/content/index.tsx @@ -7,6 +7,7 @@ import { } from '@/core/utils/extensionContext'; import { isGeminiEnterpriseEnvironment } from '@/core/utils/gemini'; import { WATERMARK_STORAGE_KEYS } from '@/core/utils/watermarkSettings'; +import { WillCleanUp } from '@/core/utils/willCleanUp'; import { startFormulaCopy, stopFormulaCopy } from '@/features/formulaCopy'; import { startPluginHost } from '@/features/plugins'; import { @@ -103,27 +104,11 @@ const LIGHT_FEATURE_INIT_DELAY = 50; // For lightweight features const BACKGROUND_TAB_MIN_DELAY = 3000; // Minimum delay for background tabs const BACKGROUND_TAB_MAX_DELAY = 8000; // Maximum delay for background tabs (3000 + 5000) +const willCleanUp = new WillCleanUp(); + let initialized = false; let initializationTimer: number | null = null; -let folderManagerInstance: Awaited> | null = null; - -let promptManagerInstance: Awaited> | null = null; -let slashPromptFeatureInstance: Awaited> | null = null; -let quoteReplyCleanup: (() => void) | null = null; -let inputVimModeCleanup: (() => void) | null = null; -let sendBehaviorCleanup: (() => void) | null = null; -let draftSaveCleanup: (() => void) | null = null; let forkCleanup: (() => void) | null = null; -let gemsSidebarCleanup: (() => void) | null = null; -let responseCompleteNotificationCleanup: (() => void) | null = null; -let edgeFinalVersionNoticeCleanup: (() => void) | null = null; -let pluginHostCleanup: (() => void) | null = null; -let brandThemeCleanup: (() => void) | null = null; -let usageStatusCleanup: (() => void) | null = null; -let remoteAnnouncementsCleanup: (() => void) | null = null; -let storageQuotaWarningCleanup: (() => void) | null = null; -let accountContextBridgeCleanup: (() => void) | null = null; -let codeBlockCollapseCleanup: (() => void) | null = null; let watermarkRemoverStarted = false; async function isForkFeatureEnabled(): Promise { @@ -204,7 +189,8 @@ async function initializeFeatures(): Promise { return; } - slashPromptFeatureInstance = await startSlashPromptFeature(); + const slashPrompt = await startSlashPromptFeature(); + willCleanUp.it(() => slashPrompt.destroy()); // Yield between features instead of sleeping a fixed amount. On an idle main // thread (the common foreground case) requestIdleCallback fires on the next @@ -229,13 +215,14 @@ async function initializeFeatures(): Promise { // Only start prompt manager for custom websites console.log('[Gemini Voyager] Custom website detected, starting Prompt Manager only'); - promptManagerInstance = await startPromptManager(); + const pm = await startPromptManager(); + willCleanUp.it(() => pm.destroy()); return; } console.log('[Gemini Voyager] Not a custom website, checking for Gemini/AI Studio'); - edgeFinalVersionNoticeCleanup = startEdgeFinalVersionNotice(); + willCleanUp.it(startEdgeFinalVersionNotice()); const isEnterprise = isGeminiEnterpriseEnvironment( { @@ -249,7 +236,8 @@ async function initializeFeatures(): Promise { if (isEnterprise) { console.log('[Gemini Voyager] Gemini Enterprise detected, starting Prompt Manager only'); - promptManagerInstance = await startPromptManager(); + const pm = await startPromptManager(); + willCleanUp.it(() => pm.destroy()); return; } @@ -258,8 +246,11 @@ async function initializeFeatures(): Promise { startTimeline(); await delay(HEAVY_FEATURE_INIT_DELAY); - folderManagerInstance = await startFolderManager(); - if (folderManagerInstance) startFolderProject(folderManagerInstance); + const folderManager = await startFolderManager(); + if (folderManager) { + willCleanUp.it(() => folderManager.destroy()); + startFolderProject(folderManager); + } await delay(HEAVY_FEATURE_INIT_DELAY); // Layout preferences are independent and only install lightweight @@ -278,11 +269,11 @@ async function initializeFeatures(): Promise { startInputCollapse(); startInputHaloHider(); - inputVimModeCleanup = await startInputVimMode(); + willCleanUp.it(await startInputVimMode()); await delay(LIGHT_FEATURE_INIT_DELAY); // Send behavior must be ready before prevent-auto-scroll reads its bridge state. - sendBehaviorCleanup = await startSendBehavior('gemini'); + willCleanUp.it(await startSendBehavior('gemini')); startPreventAutoScroll(); startFormulaCopy(); await delay(LIGHT_FEATURE_INIT_DELAY); @@ -306,24 +297,27 @@ async function initializeFeatures(): Promise { // Highlight shares Quote Reply's single selection toolbar/listener. Keep // the toolbar manager alive when Quote Reply is disabled; only its Quote // action is hidden in that case. - quoteReplyCleanup = startQuoteReply({ - quoteEnabled: quoteReplyResult[StorageKeys.QUOTE_REPLY_ENABLED] !== false, - highlightEnabled: quoteReplyResult[StorageKeys.HIGHLIGHT_ENABLED] === true, - highlightDefaultColor: isHighlightColor(storedHighlightColor) - ? storedHighlightColor - : 'yellow', - highlightColorPalette: normalizeHighlightColorPalette( - quoteReplyResult[StorageKeys.HIGHLIGHT_COLOR_PALETTE], - storedHighlightColor, - ), - highlightTimelineMarkersEnabled: - quoteReplyResult[StorageKeys.HIGHLIGHT_TIMELINE_MARKERS_ENABLED] !== false, - }); + willCleanUp.it( + startQuoteReply({ + quoteEnabled: quoteReplyResult[StorageKeys.QUOTE_REPLY_ENABLED] !== false, + highlightEnabled: quoteReplyResult[StorageKeys.HIGHLIGHT_ENABLED] === true, + highlightDefaultColor: isHighlightColor(storedHighlightColor) + ? storedHighlightColor + : 'yellow', + highlightColorPalette: normalizeHighlightColorPalette( + quoteReplyResult[StorageKeys.HIGHLIGHT_COLOR_PALETTE], + storedHighlightColor, + ), + highlightTimelineMarkersEnabled: + quoteReplyResult[StorageKeys.HIGHLIGHT_TIMELINE_MARKERS_ENABLED] !== false, + }), + ); await delay(LIGHT_FEATURE_INIT_DELAY); // Independent content helpers can initialize in the same idle slice. watermarkRemoverStarted = true; void startWatermarkRemover(); + willCleanUp.it(() => stopWatermarkRemover()); startDeepResearchExport(); startContextSync(); startGemsHider(); @@ -338,11 +332,11 @@ async function initializeFeatures(): Promise { startUsageStatus(), ]); if (notificationResult.status === 'fulfilled') { - responseCompleteNotificationCleanup = notificationResult.value; + willCleanUp.it(notificationResult.value); } - if (draftResult.status === 'fulfilled') draftSaveCleanup = draftResult.value; - if (gemsResult.status === 'fulfilled') gemsSidebarCleanup = gemsResult.value; - if (usageResult.status === 'fulfilled') usageStatusCleanup = usageResult.value; + if (draftResult.status === 'fulfilled') willCleanUp.it(draftResult.value); + if (gemsResult.status === 'fulfilled') willCleanUp.it(gemsResult.value); + if (usageResult.status === 'fulfilled') willCleanUp.it(usageResult.value); const failedInitializer = [notificationResult, draftResult, gemsResult, usageResult].find( (result): result is PromiseRejectedResult => result.status === 'rejected', @@ -353,7 +347,7 @@ async function initializeFeatures(): Promise { // DOM enhancements install observers/listeners but do not need separate // idle waits between each initializer. startMarkdownPatcher(); - codeBlockCollapseCleanup = startCodeBlockCollapse(); + willCleanUp.it(startCodeBlockCollapse()); DefaultModelManager.getInstance().init(); startExportButton(); void startCanvasExport(); @@ -375,7 +369,8 @@ async function initializeFeatures(): Promise { location.hostname === 'aistudio.google.com' || location.hostname === 'aistudio.google.cn' ) { - promptManagerInstance = await startPromptManager(); + const pm = await startPromptManager(); + willCleanUp.it(() => pm.destroy()); await delay(HEAVY_FEATURE_INIT_DELAY); } @@ -415,7 +410,7 @@ async function initializeFeatures(): Promise { await delay(LIGHT_FEATURE_INIT_DELAY); // Send behavior (Enter to send) - sendBehaviorCleanup = await startSendBehavior('aistudio'); + willCleanUp.it(await startSendBehavior('aistudio')); await delay(LIGHT_FEATURE_INIT_DELAY); } } catch (e) { @@ -466,6 +461,8 @@ function handleVisibilityChange(): void { // Main initialization logic (function () { + console.log("This is Andy's change in 2026/8/1"); + try { if (!hasValidExtensionContext()) return; @@ -489,7 +486,7 @@ function handleVisibilityChange(): void { // Saved Library and cloud sync need the same account identity as highlights. // This bridge must exist even when optional Folder Manager code never starts. - if (!isPluginSubframe) accountContextBridgeCleanup = startAccountContextBridge(); + if (!isPluginSubframe) willCleanUp.it(startAccountContextBridge()); // Plugin ecosystem host. Started up-front on EVERY page the content script is // injected into (Gemini / AI Studio, and any site a user enabled a plugin for, @@ -513,14 +510,14 @@ function handleVisibilityChange(): void { updateSettings: updateClaudeTimelineSettings, stop: stopClaudeTimeline, }); - pluginHostCleanup = startPluginHost(); + willCleanUp.it(startPluginHost()); // Cosmetic: on Claude / ChatGPT, re-skin Voyager's accent to the host // platform's brand colour (injects --gv-pm-brand + a gv-platform-themed body // class; CSS derives the rest). Applies the adapter's built-in colour at // once, then lets an enabled plugin's declared theme override it live. No-op // on Gemini / AI Studio. - if (!isPluginSubframe) brandThemeCleanup = startBrandTheme(); + if (!isPluginSubframe) willCleanUp.it(startBrandTheme()); const onUnhandledRejection = (event: PromiseRejectionEvent) => { if (isExtensionContextInvalidatedError(event.reason)) { @@ -533,7 +530,9 @@ function handleVisibilityChange(): void { } }; window.addEventListener('unhandledrejection', onUnhandledRejection); + willCleanUp.it(() => window.removeEventListener('unhandledrejection', onUnhandledRejection)); window.addEventListener('error', onWindowError); + willCleanUp.it(() => window.removeEventListener('error', onWindowError)); const onStorageChanged = ( changes: Record, areaName: string, @@ -575,7 +574,7 @@ function handleVisibilityChange(): void { hostname.includes('aistudio.google.com') || hostname.includes('aistudio.google.cn'); if (!isPluginSubframe && (isSupportedSite || pluginPlatformId)) { - remoteAnnouncementsCleanup = startRemoteAnnouncements(); + willCleanUp.it(startRemoteAnnouncements()); } // Initialize KaTeX configuration early to suppress Unicode warnings @@ -584,7 +583,7 @@ function handleVisibilityChange(): void { initKaTeXConfig(); // Initialize i18n early to ensure translations are available initI18n().catch((e) => console.error('[Gemini Voyager] i18n init error:', e)); - storageQuotaWarningCleanup = startStorageQuotaWarningToast(); + willCleanUp.it(startStorageQuotaWarningToast()); } // If not a known site, check if it's a custom website (async) @@ -603,7 +602,7 @@ function handleVisibilityChange(): void { console.log('[Gemini Voyager] Plugin platform: prompt manager'); void startPromptManager() .then((instance) => { - promptManagerInstance = instance; + willCleanUp.it(() => instance.destroy()); }) .catch((error) => { console.error('[Gemini Voyager] Prompt Manager init error on plugin platform:', error); @@ -628,6 +627,7 @@ function handleVisibilityChange(): void { return; } chrome.storage?.onChanged?.addListener(onStorageChanged); + willCleanUp.it(() => chrome.storage?.onChanged?.removeListener(onStorageChanged)); const delay = getInitializationDelay(); @@ -648,83 +648,11 @@ function handleVisibilityChange(): void { // Setup cleanup on page unload to prevent memory leaks window.addEventListener('beforeunload', () => { try { - window.removeEventListener('unhandledrejection', onUnhandledRejection); - window.removeEventListener('error', onWindowError); - // Disconnect watermark-remover observers. - stopWatermarkRemover(); - if (folderManagerInstance) { - folderManagerInstance.destroy(); - folderManagerInstance = null; - } - if (promptManagerInstance) { - promptManagerInstance.destroy(); - promptManagerInstance = null; - } - if (slashPromptFeatureInstance) { - slashPromptFeatureInstance.destroy(); - slashPromptFeatureInstance = null; - } - if (quoteReplyCleanup) { - quoteReplyCleanup(); - quoteReplyCleanup = null; - } - if (inputVimModeCleanup) { - inputVimModeCleanup(); - inputVimModeCleanup = null; - } - if (sendBehaviorCleanup) { - sendBehaviorCleanup(); - sendBehaviorCleanup = null; - } - if (draftSaveCleanup) { - draftSaveCleanup(); - draftSaveCleanup = null; - } + willCleanUp.execute(); if (forkCleanup) { forkCleanup(); forkCleanup = null; } - if (gemsSidebarCleanup) { - gemsSidebarCleanup(); - gemsSidebarCleanup = null; - } - if (responseCompleteNotificationCleanup) { - responseCompleteNotificationCleanup(); - responseCompleteNotificationCleanup = null; - } - if (edgeFinalVersionNoticeCleanup) { - edgeFinalVersionNoticeCleanup(); - edgeFinalVersionNoticeCleanup = null; - } - if (pluginHostCleanup) { - pluginHostCleanup(); - pluginHostCleanup = null; - } - if (brandThemeCleanup) { - brandThemeCleanup(); - brandThemeCleanup = null; - } - if (remoteAnnouncementsCleanup) { - remoteAnnouncementsCleanup(); - remoteAnnouncementsCleanup = null; - } - if (storageQuotaWarningCleanup) { - storageQuotaWarningCleanup(); - storageQuotaWarningCleanup = null; - } - if (accountContextBridgeCleanup) { - accountContextBridgeCleanup(); - accountContextBridgeCleanup = null; - } - if (codeBlockCollapseCleanup) { - codeBlockCollapseCleanup(); - codeBlockCollapseCleanup = null; - } - if (usageStatusCleanup) { - usageStatusCleanup(); - usageStatusCleanup = null; - } - chrome.storage?.onChanged?.removeListener(onStorageChanged); } catch (e) { if (isExtensionContextInvalidatedError(e)) { return; From f0e982c76c1169797197c9f1fc404b63c24d9799 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 1 Aug 2026 20:54:56 +0800 Subject: [PATCH 03/12] chore: remove redundant console.log statement --- src/pages/content/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/content/index.tsx b/src/pages/content/index.tsx index 3221788d8..f17a9ea20 100644 --- a/src/pages/content/index.tsx +++ b/src/pages/content/index.tsx @@ -461,8 +461,6 @@ function handleVisibilityChange(): void { // Main initialization logic (function () { - console.log("This is Andy's change in 2026/8/1"); - try { if (!hasValidExtensionContext()) return; From b3307863051ac28b474fc2e327085f56a5c8882b Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 1 Aug 2026 21:06:32 +0800 Subject: [PATCH 04/12] fix: willCleanUp.ts can safely handle cleanup functions that throws an error --- src/core/utils/__tests__/willCleanUp.test.ts | 16 ++++++++++++++++ src/core/utils/willCleanUp.ts | 8 +++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/core/utils/__tests__/willCleanUp.test.ts b/src/core/utils/__tests__/willCleanUp.test.ts index e44ea8cfc..18b39c339 100644 --- a/src/core/utils/__tests__/willCleanUp.test.ts +++ b/src/core/utils/__tests__/willCleanUp.test.ts @@ -48,4 +48,20 @@ describe('willCleanUp tests module', () => { expect(willCleanUp.list()).toEqual([]); }); + + it('can safely handle cleanup functions that throws an error', () => { + const function1 = () => { + throw Error(); + }; + const function2 = vi.fn(); + + willCleanUp.it(function1); + willCleanUp.it(function2); + + willCleanUp.execute(); + + expect(function2).toHaveBeenCalled(); + expect(function2).toHaveBeenCalledTimes(1); + expect(willCleanUp.list()).toEqual([]); + }); }); diff --git a/src/core/utils/willCleanUp.ts b/src/core/utils/willCleanUp.ts index 29d144a92..fa1568c15 100644 --- a/src/core/utils/willCleanUp.ts +++ b/src/core/utils/willCleanUp.ts @@ -26,7 +26,13 @@ export class WillCleanUp { * Call all functions registered by `it` functions, and clear their references. */ execute(): void { - this.cleanUps.forEach((it) => it()); + this.cleanUps.forEach((it) => { + try { + it(); + } catch (e) { + console.error(`[Gemini Voyager] cleanup error: ${e}`); + } + }); this.cleanUps = []; } } From ab1a9a4ef8ba2d960db3c888e168582cb0ad5ff4 Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 4 Aug 2026 18:33:47 +0800 Subject: [PATCH 05/12] chore: rename WillCleanUp and its related identifiers to make the code more formal --- .../utils/__tests__/cleanupManager.test.ts | 67 +++++++++++++++++++ src/core/utils/__tests__/willCleanUp.test.ts | 67 ------------------- src/core/utils/cleanupManager.ts | 39 +++++++++++ src/core/utils/willCleanUp.ts | 38 ----------- src/pages/content/index.tsx | 65 ++++++++++-------- 5 files changed, 143 insertions(+), 133 deletions(-) create mode 100644 src/core/utils/__tests__/cleanupManager.test.ts delete mode 100644 src/core/utils/__tests__/willCleanUp.test.ts create mode 100644 src/core/utils/cleanupManager.ts delete mode 100644 src/core/utils/willCleanUp.ts diff --git a/src/core/utils/__tests__/cleanupManager.test.ts b/src/core/utils/__tests__/cleanupManager.test.ts new file mode 100644 index 000000000..838b172b8 --- /dev/null +++ b/src/core/utils/__tests__/cleanupManager.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CleanupManager } from '@/core/utils/cleanupManager'; + +describe('willCleanUp tests module', () => { + let cleanupManager: CleanupManager; + + beforeEach(() => { + cleanupManager = new CleanupManager(); + }); + + it('can store registered cleanup functions', () => { + const function1 = () => {}; + const function2 = () => {}; + + cleanupManager.registerCleanupFunction(function1); + cleanupManager.registerCleanupFunction(function2); + cleanupManager.registerCleanupFunction(function2); // won't store duplicate functions + + expect(cleanupManager.list()).toEqual([function1, function2]); + }); + + it('can execute registered cleanup functions at correct time', () => { + const function1 = vi.fn(); + + cleanupManager.registerCleanupFunction(function1); + + expect(function1).not.toHaveBeenCalled(); + + cleanupManager.executeCleanups(); + + expect(function1).toHaveBeenCalled(); + expect(function1).toHaveBeenCalledTimes(1); + + cleanupManager.executeCleanups(); // no duplicate call + + expect(function1).toHaveBeenCalledTimes(1); + }); + + it('can release stored cleanup functions at correct time', () => { + const function1 = () => {}; + + cleanupManager.registerCleanupFunction(function1); + + expect(cleanupManager.list()).not.toEqual([]); + + cleanupManager.executeCleanups(); + + expect(cleanupManager.list()).toEqual([]); + }); + + it('can safely handle cleanup functions that throws an error', () => { + const function1 = () => { + throw Error(); + }; + const function2 = vi.fn(); + + cleanupManager.registerCleanupFunction(function1); + cleanupManager.registerCleanupFunction(function2); + + cleanupManager.executeCleanups(); + + expect(function2).toHaveBeenCalled(); + expect(function2).toHaveBeenCalledTimes(1); + expect(cleanupManager.list()).toEqual([]); + }); +}); diff --git a/src/core/utils/__tests__/willCleanUp.test.ts b/src/core/utils/__tests__/willCleanUp.test.ts deleted file mode 100644 index 18b39c339..000000000 --- a/src/core/utils/__tests__/willCleanUp.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { WillCleanUp } from '@/core/utils/willCleanUp'; - -describe('willCleanUp tests module', () => { - let willCleanUp: WillCleanUp; - - beforeEach(() => { - willCleanUp = new WillCleanUp(); - }); - - it('can store registered cleanup functions', () => { - const function1 = () => {}; - const function2 = () => {}; - - willCleanUp.it(function1); - willCleanUp.it(function2); - willCleanUp.it(function2); // won't store duplicate functions - - expect(willCleanUp.list()).toEqual([function1, function2]); - }); - - it('can execute registered cleanup functions at correct time', () => { - const function1 = vi.fn(); - - willCleanUp.it(function1); - - expect(function1).not.toHaveBeenCalled(); - - willCleanUp.execute(); - - expect(function1).toHaveBeenCalled(); - expect(function1).toHaveBeenCalledTimes(1); - - willCleanUp.execute(); // no duplicate call - - expect(function1).toHaveBeenCalledTimes(1); - }); - - it('can release stored cleanup functions at correct time', () => { - const function1 = () => {}; - - willCleanUp.it(function1); - - expect(willCleanUp.list()).not.toEqual([]); - - willCleanUp.execute(); - - expect(willCleanUp.list()).toEqual([]); - }); - - it('can safely handle cleanup functions that throws an error', () => { - const function1 = () => { - throw Error(); - }; - const function2 = vi.fn(); - - willCleanUp.it(function1); - willCleanUp.it(function2); - - willCleanUp.execute(); - - expect(function2).toHaveBeenCalled(); - expect(function2).toHaveBeenCalledTimes(1); - expect(willCleanUp.list()).toEqual([]); - }); -}); diff --git a/src/core/utils/cleanupManager.ts b/src/core/utils/cleanupManager.ts new file mode 100644 index 000000000..4c802169e --- /dev/null +++ b/src/core/utils/cleanupManager.ts @@ -0,0 +1,39 @@ +/** + * A class that manages and executes cleanup functions in code entrypoint. + */ +export class CleanupManager { + private cleanups: Array<() => void> = []; + + constructor() {} + + /** + * Register a cleanup function waited to be called. + * @param func A function that does cleanup operation when called. + */ + registerCleanupFunction(func: () => void): void { + if (this.cleanups.includes(func)) return; + this.cleanups.push(func); + } + + /** + * [debug] return a readonly list containing stored cleanup functions. + */ + list(): Array<() => void> { + return [...this.cleanups] as const; + } + + /** + * Call all functions registered by `registerCleanupFunction` functions, + * and clear their references. + */ + executeCleanups(): void { + this.cleanups.forEach((it) => { + try { + it(); + } catch (e) { + console.error(`[Gemini Voyager] cleanup error: ${e}`); + } + }); + this.cleanups = []; + } +} diff --git a/src/core/utils/willCleanUp.ts b/src/core/utils/willCleanUp.ts deleted file mode 100644 index fa1568c15..000000000 --- a/src/core/utils/willCleanUp.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * A class that manages and executes cleanup functions in code entrypoint. - */ -export class WillCleanUp { - private cleanUps: Array<() => void> = []; - - constructor() {} - - /** - * Register a cleanup function waited to be called. - * @param arg A function that does cleanup operation when called. - */ - it(arg: () => void): void { - if (this.cleanUps.includes(arg)) return; - this.cleanUps.push(arg); - } - - /** - * [debug] return a readonly list containing stored cleanup functions. - */ - list(): Array<() => void> { - return [...this.cleanUps] as const; - } - - /** - * Call all functions registered by `it` functions, and clear their references. - */ - execute(): void { - this.cleanUps.forEach((it) => { - try { - it(); - } catch (e) { - console.error(`[Gemini Voyager] cleanup error: ${e}`); - } - }); - this.cleanUps = []; - } -} diff --git a/src/pages/content/index.tsx b/src/pages/content/index.tsx index f17a9ea20..02503c998 100644 --- a/src/pages/content/index.tsx +++ b/src/pages/content/index.tsx @@ -1,5 +1,6 @@ import { StorageKeys } from '@/core/types/common'; import { isHighlightColor, normalizeHighlightColorPalette } from '@/core/types/highlight'; +import { CleanupManager } from '@/core/utils/cleanupManager'; import { customWebsitesIncludeHost, sanitizeCustomWebsites } from '@/core/utils/customWebsites'; import { hasValidExtensionContext, @@ -7,7 +8,6 @@ import { } from '@/core/utils/extensionContext'; import { isGeminiEnterpriseEnvironment } from '@/core/utils/gemini'; import { WATERMARK_STORAGE_KEYS } from '@/core/utils/watermarkSettings'; -import { WillCleanUp } from '@/core/utils/willCleanUp'; import { startFormulaCopy, stopFormulaCopy } from '@/features/formulaCopy'; import { startPluginHost } from '@/features/plugins'; import { @@ -104,7 +104,7 @@ const LIGHT_FEATURE_INIT_DELAY = 50; // For lightweight features const BACKGROUND_TAB_MIN_DELAY = 3000; // Minimum delay for background tabs const BACKGROUND_TAB_MAX_DELAY = 8000; // Maximum delay for background tabs (3000 + 5000) -const willCleanUp = new WillCleanUp(); +const cleanupManager = new CleanupManager(); let initialized = false; let initializationTimer: number | null = null; @@ -190,7 +190,7 @@ async function initializeFeatures(): Promise { } const slashPrompt = await startSlashPromptFeature(); - willCleanUp.it(() => slashPrompt.destroy()); + cleanupManager.registerCleanupFunction(() => slashPrompt.destroy()); // Yield between features instead of sleeping a fixed amount. On an idle main // thread (the common foreground case) requestIdleCallback fires on the next @@ -216,13 +216,13 @@ async function initializeFeatures(): Promise { console.log('[Gemini Voyager] Custom website detected, starting Prompt Manager only'); const pm = await startPromptManager(); - willCleanUp.it(() => pm.destroy()); + cleanupManager.registerCleanupFunction(() => pm.destroy()); return; } console.log('[Gemini Voyager] Not a custom website, checking for Gemini/AI Studio'); - willCleanUp.it(startEdgeFinalVersionNotice()); + cleanupManager.registerCleanupFunction(startEdgeFinalVersionNotice()); const isEnterprise = isGeminiEnterpriseEnvironment( { @@ -237,7 +237,7 @@ async function initializeFeatures(): Promise { if (isEnterprise) { console.log('[Gemini Voyager] Gemini Enterprise detected, starting Prompt Manager only'); const pm = await startPromptManager(); - willCleanUp.it(() => pm.destroy()); + cleanupManager.registerCleanupFunction(() => pm.destroy()); return; } @@ -248,7 +248,7 @@ async function initializeFeatures(): Promise { const folderManager = await startFolderManager(); if (folderManager) { - willCleanUp.it(() => folderManager.destroy()); + cleanupManager.registerCleanupFunction(() => folderManager.destroy()); startFolderProject(folderManager); } await delay(HEAVY_FEATURE_INIT_DELAY); @@ -269,11 +269,11 @@ async function initializeFeatures(): Promise { startInputCollapse(); startInputHaloHider(); - willCleanUp.it(await startInputVimMode()); + cleanupManager.registerCleanupFunction(await startInputVimMode()); await delay(LIGHT_FEATURE_INIT_DELAY); // Send behavior must be ready before prevent-auto-scroll reads its bridge state. - willCleanUp.it(await startSendBehavior('gemini')); + cleanupManager.registerCleanupFunction(await startSendBehavior('gemini')); startPreventAutoScroll(); startFormulaCopy(); await delay(LIGHT_FEATURE_INIT_DELAY); @@ -297,7 +297,7 @@ async function initializeFeatures(): Promise { // Highlight shares Quote Reply's single selection toolbar/listener. Keep // the toolbar manager alive when Quote Reply is disabled; only its Quote // action is hidden in that case. - willCleanUp.it( + cleanupManager.registerCleanupFunction( startQuoteReply({ quoteEnabled: quoteReplyResult[StorageKeys.QUOTE_REPLY_ENABLED] !== false, highlightEnabled: quoteReplyResult[StorageKeys.HIGHLIGHT_ENABLED] === true, @@ -317,7 +317,7 @@ async function initializeFeatures(): Promise { // Independent content helpers can initialize in the same idle slice. watermarkRemoverStarted = true; void startWatermarkRemover(); - willCleanUp.it(() => stopWatermarkRemover()); + cleanupManager.registerCleanupFunction(() => stopWatermarkRemover()); startDeepResearchExport(); startContextSync(); startGemsHider(); @@ -332,11 +332,14 @@ async function initializeFeatures(): Promise { startUsageStatus(), ]); if (notificationResult.status === 'fulfilled') { - willCleanUp.it(notificationResult.value); + cleanupManager.registerCleanupFunction(notificationResult.value); } - if (draftResult.status === 'fulfilled') willCleanUp.it(draftResult.value); - if (gemsResult.status === 'fulfilled') willCleanUp.it(gemsResult.value); - if (usageResult.status === 'fulfilled') willCleanUp.it(usageResult.value); + if (draftResult.status === 'fulfilled') + cleanupManager.registerCleanupFunction(draftResult.value); + if (gemsResult.status === 'fulfilled') + cleanupManager.registerCleanupFunction(gemsResult.value); + if (usageResult.status === 'fulfilled') + cleanupManager.registerCleanupFunction(usageResult.value); const failedInitializer = [notificationResult, draftResult, gemsResult, usageResult].find( (result): result is PromiseRejectedResult => result.status === 'rejected', @@ -347,7 +350,7 @@ async function initializeFeatures(): Promise { // DOM enhancements install observers/listeners but do not need separate // idle waits between each initializer. startMarkdownPatcher(); - willCleanUp.it(startCodeBlockCollapse()); + cleanupManager.registerCleanupFunction(startCodeBlockCollapse()); DefaultModelManager.getInstance().init(); startExportButton(); void startCanvasExport(); @@ -370,7 +373,7 @@ async function initializeFeatures(): Promise { location.hostname === 'aistudio.google.cn' ) { const pm = await startPromptManager(); - willCleanUp.it(() => pm.destroy()); + cleanupManager.registerCleanupFunction(() => pm.destroy()); await delay(HEAVY_FEATURE_INIT_DELAY); } @@ -410,7 +413,7 @@ async function initializeFeatures(): Promise { await delay(LIGHT_FEATURE_INIT_DELAY); // Send behavior (Enter to send) - willCleanUp.it(await startSendBehavior('aistudio')); + cleanupManager.registerCleanupFunction(await startSendBehavior('aistudio')); await delay(LIGHT_FEATURE_INIT_DELAY); } } catch (e) { @@ -484,7 +487,7 @@ function handleVisibilityChange(): void { // Saved Library and cloud sync need the same account identity as highlights. // This bridge must exist even when optional Folder Manager code never starts. - if (!isPluginSubframe) willCleanUp.it(startAccountContextBridge()); + if (!isPluginSubframe) cleanupManager.registerCleanupFunction(startAccountContextBridge()); // Plugin ecosystem host. Started up-front on EVERY page the content script is // injected into (Gemini / AI Studio, and any site a user enabled a plugin for, @@ -508,14 +511,14 @@ function handleVisibilityChange(): void { updateSettings: updateClaudeTimelineSettings, stop: stopClaudeTimeline, }); - willCleanUp.it(startPluginHost()); + cleanupManager.registerCleanupFunction(startPluginHost()); // Cosmetic: on Claude / ChatGPT, re-skin Voyager's accent to the host // platform's brand colour (injects --gv-pm-brand + a gv-platform-themed body // class; CSS derives the rest). Applies the adapter's built-in colour at // once, then lets an enabled plugin's declared theme override it live. No-op // on Gemini / AI Studio. - if (!isPluginSubframe) willCleanUp.it(startBrandTheme()); + if (!isPluginSubframe) cleanupManager.registerCleanupFunction(startBrandTheme()); const onUnhandledRejection = (event: PromiseRejectionEvent) => { if (isExtensionContextInvalidatedError(event.reason)) { @@ -528,9 +531,13 @@ function handleVisibilityChange(): void { } }; window.addEventListener('unhandledrejection', onUnhandledRejection); - willCleanUp.it(() => window.removeEventListener('unhandledrejection', onUnhandledRejection)); + cleanupManager.registerCleanupFunction(() => + window.removeEventListener('unhandledrejection', onUnhandledRejection), + ); window.addEventListener('error', onWindowError); - willCleanUp.it(() => window.removeEventListener('error', onWindowError)); + cleanupManager.registerCleanupFunction(() => + window.removeEventListener('error', onWindowError), + ); const onStorageChanged = ( changes: Record, areaName: string, @@ -572,7 +579,7 @@ function handleVisibilityChange(): void { hostname.includes('aistudio.google.com') || hostname.includes('aistudio.google.cn'); if (!isPluginSubframe && (isSupportedSite || pluginPlatformId)) { - willCleanUp.it(startRemoteAnnouncements()); + cleanupManager.registerCleanupFunction(startRemoteAnnouncements()); } // Initialize KaTeX configuration early to suppress Unicode warnings @@ -581,7 +588,7 @@ function handleVisibilityChange(): void { initKaTeXConfig(); // Initialize i18n early to ensure translations are available initI18n().catch((e) => console.error('[Gemini Voyager] i18n init error:', e)); - willCleanUp.it(startStorageQuotaWarningToast()); + cleanupManager.registerCleanupFunction(startStorageQuotaWarningToast()); } // If not a known site, check if it's a custom website (async) @@ -600,7 +607,7 @@ function handleVisibilityChange(): void { console.log('[Gemini Voyager] Plugin platform: prompt manager'); void startPromptManager() .then((instance) => { - willCleanUp.it(() => instance.destroy()); + cleanupManager.registerCleanupFunction(() => instance.destroy()); }) .catch((error) => { console.error('[Gemini Voyager] Prompt Manager init error on plugin platform:', error); @@ -625,7 +632,9 @@ function handleVisibilityChange(): void { return; } chrome.storage?.onChanged?.addListener(onStorageChanged); - willCleanUp.it(() => chrome.storage?.onChanged?.removeListener(onStorageChanged)); + cleanupManager.registerCleanupFunction(() => + chrome.storage?.onChanged?.removeListener(onStorageChanged), + ); const delay = getInitializationDelay(); @@ -646,7 +655,7 @@ function handleVisibilityChange(): void { // Setup cleanup on page unload to prevent memory leaks window.addEventListener('beforeunload', () => { try { - willCleanUp.execute(); + cleanupManager.executeCleanups(); if (forkCleanup) { forkCleanup(); forkCleanup = null; From e7f013df52ef7b451fca6abd6deeb9b92594a2e6 Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 4 Aug 2026 20:16:27 +0800 Subject: [PATCH 06/12] feat: add cleanupManager sequence support and returning cleanup function as-is --- .../utils/__tests__/cleanupManager.test.ts | 40 +++++++++++++- src/core/utils/cleanupManager.ts | 55 +++++++++++++++---- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src/core/utils/__tests__/cleanupManager.test.ts b/src/core/utils/__tests__/cleanupManager.test.ts index 838b172b8..753c62c54 100644 --- a/src/core/utils/__tests__/cleanupManager.test.ts +++ b/src/core/utils/__tests__/cleanupManager.test.ts @@ -2,6 +2,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CleanupManager } from '@/core/utils/cleanupManager'; +enum Sequence { + First, + Second, + Third, +} + describe('willCleanUp tests module', () => { let cleanupManager: CleanupManager; @@ -17,7 +23,22 @@ describe('willCleanUp tests module', () => { cleanupManager.registerCleanupFunction(function2); cleanupManager.registerCleanupFunction(function2); // won't store duplicate functions - expect(cleanupManager.list()).toEqual([function1, function2]); + expect(cleanupManager.list()).toEqual([ + { + pos: -1, + func: function1, + }, + { + pos: -1, + func: function2, + }, + ]); + }); + + it('can return registered functions as-is', () => { + const function1 = () => {}; + + expect(cleanupManager.registerCleanupFunctionAndReturnIt(function1)).toBe(function1); }); it('can execute registered cleanup functions at correct time', () => { @@ -58,10 +79,25 @@ describe('willCleanUp tests module', () => { cleanupManager.registerCleanupFunction(function1); cleanupManager.registerCleanupFunction(function2); - cleanupManager.executeCleanups(); + expect(() => cleanupManager.executeCleanups()).toThrow(); expect(function2).toHaveBeenCalled(); expect(function2).toHaveBeenCalledTimes(1); expect(cleanupManager.list()).toEqual([]); }); + + it('can call functions in correct sequence', () => { + const function1 = vi.fn(); + const function2 = vi.fn(); + const function3 = vi.fn(); + + cleanupManager.registerCleanupFunction(function3, Sequence.Third); + cleanupManager.registerCleanupFunction(function2, Sequence.Second); + cleanupManager.registerCleanupFunction(function1, Sequence.First); + + cleanupManager.executeCleanups(); + + expect(function2).toHaveBeenCalledAfter(function1); + expect(function3).toHaveBeenCalledAfter(function2); + }); }); diff --git a/src/core/utils/cleanupManager.ts b/src/core/utils/cleanupManager.ts index 4c802169e..7b410251e 100644 --- a/src/core/utils/cleanupManager.ts +++ b/src/core/utils/cleanupManager.ts @@ -2,38 +2,69 @@ * A class that manages and executes cleanup functions in code entrypoint. */ export class CleanupManager { - private cleanups: Array<() => void> = []; + private cleanups: Array = []; constructor() {} /** * Register a cleanup function waited to be called. * @param func A function that does cleanup operation when called. + * @param pos A number (preferably defined by Enum) that indicates the position of the + * cleanup function. The lower the number, the earlier the function would be called. */ - registerCleanupFunction(func: () => void): void { - if (this.cleanups.includes(func)) return; - this.cleanups.push(func); + registerCleanupFunction(func: () => void, pos: number = -1): void { + if (this.cleanups.some((cleanup) => cleanup.func === func)) return; + this.cleanups.push({ + pos: pos, + func: func, + }); + } + + /** + * Register a cleanup function, and return the function as-is. + * @param func + * @param pos + */ + registerCleanupFunctionAndReturnIt(func: () => void, pos: number = -1): () => void { + this.registerCleanupFunction(func, pos); + return func; } /** * [debug] return a readonly list containing stored cleanup functions. */ - list(): Array<() => void> { + list(): Array { return [...this.cleanups] as const; } /** * Call all functions registered by `registerCleanupFunction` functions, * and clear their references. + * + * If any cleanup functions throws an error, other functions will execute normally. + * Then, the last recorded error will be re-thrown. */ executeCleanups(): void { - this.cleanups.forEach((it) => { - try { - it(); - } catch (e) { - console.error(`[Gemini Voyager] cleanup error: ${e}`); - } - }); + let error: unknown = null; + + this.cleanups + .sort((a, b) => { + return a.pos - b.pos; + }) + .forEach((it) => { + try { + it.func(); + } catch (e) { + error = e; + } + }); this.cleanups = []; + + if (error) throw error; } } + +interface Cleanup { + pos: number; + func: () => void; +} From f8e9ad9c9cb11bbf709958349bba1f4d93070dbd Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 4 Aug 2026 20:17:16 +0800 Subject: [PATCH 07/12] feat: apply returning function as-is feature to code entrypoint --- src/pages/content/index.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/pages/content/index.tsx b/src/pages/content/index.tsx index 02503c998..f07a594a7 100644 --- a/src/pages/content/index.tsx +++ b/src/pages/content/index.tsx @@ -357,7 +357,7 @@ async function initializeFeatures(): Promise { await delay(LIGHT_FEATURE_INIT_DELAY); if (await isForkFeatureEnabled()) { - forkCleanup = startFork(); + forkCleanup = cleanupManager.registerCleanupFunctionAndReturnIt(startFork()); } // Introduce new feature coachmarks once the changelog is out of the way; @@ -563,7 +563,7 @@ function handleVisibilityChange(): void { const enabled = isForkFeatureEnabledValue(forkSetting.newValue); if (enabled) { if (!forkCleanup) { - forkCleanup = startFork(); + forkCleanup = cleanupManager.registerCleanupFunctionAndReturnIt(startFork()); } } else if (forkCleanup) { forkCleanup(); @@ -656,10 +656,6 @@ function handleVisibilityChange(): void { window.addEventListener('beforeunload', () => { try { cleanupManager.executeCleanups(); - if (forkCleanup) { - forkCleanup(); - forkCleanup = null; - } } catch (e) { if (isExtensionContextInvalidatedError(e)) { return; From 7b621e2f4657febf5ac944d18523c8a9ce85861b Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 4 Aug 2026 20:38:45 +0800 Subject: [PATCH 08/12] feat(cleanup): apply cleanup sequence feature to code entrypoint --- src/core/types/cleanupPositions.ts | 24 +++++ src/pages/content/index.tsx | 138 ++++++++++++++++++++++------- 2 files changed, 130 insertions(+), 32 deletions(-) create mode 100644 src/core/types/cleanupPositions.ts diff --git a/src/core/types/cleanupPositions.ts b/src/core/types/cleanupPositions.ts new file mode 100644 index 000000000..f8159666a --- /dev/null +++ b/src/core/types/cleanupPositions.ts @@ -0,0 +1,24 @@ +export enum CleanupPositions { + RemoveUnhandledRejectionEventListener, + RemoveErrorEventListener, + StopWatermarkRemover, + DestroyFolderManagerInstance, + DestroyPromptManagerInstance, + DestroySlashPromptFeatureInstance, + CleanupQuoteReply, + CleanupInputVimMode, + CleanupSendBehavior, + CleanupDraftSave, + CleanupFork, + CleanupGemsSidebar, + CleanupResponseCompleteNotification, + CleanupEdgeFinalVersionNotice, + CleanupPluginHost, + CleanupBrandTheme, + CleanupRemoteAnnouncements, + CleanupStorageQuotaWarning, + CleanupAccountContextBridge, + CleanupCodeBlockCollapse, + CleanupUsageStatus, + RemoveStorageOnChangedListener, +} diff --git a/src/pages/content/index.tsx b/src/pages/content/index.tsx index 65683dc00..75c0aae71 100644 --- a/src/pages/content/index.tsx +++ b/src/pages/content/index.tsx @@ -1,3 +1,4 @@ +import { CleanupPositions } from '@/core/types/cleanupPositions'; import { StorageKeys } from '@/core/types/common'; import { isHighlightColor, normalizeHighlightColorPalette } from '@/core/types/highlight'; import { CleanupManager } from '@/core/utils/cleanupManager'; @@ -192,7 +193,10 @@ async function initializeFeatures(): Promise { } const slashPrompt = await startSlashPromptFeature(); - cleanupManager.registerCleanupFunction(() => slashPrompt.destroy()); + cleanupManager.registerCleanupFunction( + () => slashPrompt.destroy(), + CleanupPositions.DestroySlashPromptFeatureInstance, + ); // Yield between features instead of sleeping a fixed amount. On an idle main // thread (the common foreground case) requestIdleCallback fires on the next @@ -218,13 +222,19 @@ async function initializeFeatures(): Promise { console.log('[Gemini Voyager] Custom website detected, starting Prompt Manager only'); const pm = await startPromptManager(); - cleanupManager.registerCleanupFunction(() => pm.destroy()); + cleanupManager.registerCleanupFunction( + () => pm.destroy(), + CleanupPositions.DestroyPromptManagerInstance, + ); return; } console.log('[Gemini Voyager] Not a custom website, checking for Gemini/AI Studio'); - cleanupManager.registerCleanupFunction(startEdgeFinalVersionNotice()); + cleanupManager.registerCleanupFunction( + startEdgeFinalVersionNotice(), + CleanupPositions.CleanupEdgeFinalVersionNotice, + ); const isEnterprise = isGeminiEnterpriseEnvironment( { @@ -239,7 +249,10 @@ async function initializeFeatures(): Promise { if (isEnterprise) { console.log('[Gemini Voyager] Gemini Enterprise detected, starting Prompt Manager only'); const pm = await startPromptManager(); - cleanupManager.registerCleanupFunction(() => pm.destroy()); + cleanupManager.registerCleanupFunction( + () => pm.destroy(), + CleanupPositions.DestroyPromptManagerInstance, + ); return; } @@ -250,7 +263,10 @@ async function initializeFeatures(): Promise { const folderManager = await startFolderManager(); if (folderManager) { - cleanupManager.registerCleanupFunction(() => folderManager.destroy()); + cleanupManager.registerCleanupFunction( + () => folderManager.destroy(), + CleanupPositions.DestroyFolderManagerInstance, + ); startFolderProject(folderManager); } await delay(HEAVY_FEATURE_INIT_DELAY); @@ -271,11 +287,17 @@ async function initializeFeatures(): Promise { startInputCollapse(); startInputHaloHider(); - cleanupManager.registerCleanupFunction(await startInputVimMode()); + cleanupManager.registerCleanupFunction( + await startInputVimMode(), + CleanupPositions.CleanupInputVimMode, + ); await delay(LIGHT_FEATURE_INIT_DELAY); // Send behavior must be ready before prevent-auto-scroll reads its bridge state. - cleanupManager.registerCleanupFunction(await startSendBehavior('gemini')); + cleanupManager.registerCleanupFunction( + await startSendBehavior('gemini'), + CleanupPositions.CleanupSendBehavior, + ); startPreventAutoScroll(); startFormulaCopy(); await delay(LIGHT_FEATURE_INIT_DELAY); @@ -313,13 +335,17 @@ async function initializeFeatures(): Promise { highlightTimelineMarkersEnabled: quoteReplyResult[StorageKeys.HIGHLIGHT_TIMELINE_MARKERS_ENABLED] !== false, }), + CleanupPositions.CleanupQuoteReply, ); await delay(LIGHT_FEATURE_INIT_DELAY); // Independent content helpers can initialize in the same idle slice. watermarkRemoverStarted = true; void startWatermarkRemover(); - cleanupManager.registerCleanupFunction(() => stopWatermarkRemover()); + cleanupManager.registerCleanupFunction( + () => stopWatermarkRemover(), + CleanupPositions.StopWatermarkRemover, + ); startDeepResearchExport(); startContextSync(); startGemsHider(); @@ -334,14 +360,29 @@ async function initializeFeatures(): Promise { startUsageStatus(), ]); if (notificationResult.status === 'fulfilled') { - cleanupManager.registerCleanupFunction(notificationResult.value); + cleanupManager.registerCleanupFunction( + notificationResult.value, + CleanupPositions.CleanupResponseCompleteNotification, + ); + } + if (draftResult.status === 'fulfilled') { + cleanupManager.registerCleanupFunction( + draftResult.value, + CleanupPositions.CleanupDraftSave, + ); + } + if (gemsResult.status === 'fulfilled') { + cleanupManager.registerCleanupFunction( + gemsResult.value, + CleanupPositions.CleanupGemsSidebar, + ); + } + if (usageResult.status === 'fulfilled') { + cleanupManager.registerCleanupFunction( + usageResult.value, + CleanupPositions.CleanupUsageStatus, + ); } - if (draftResult.status === 'fulfilled') - cleanupManager.registerCleanupFunction(draftResult.value); - if (gemsResult.status === 'fulfilled') - cleanupManager.registerCleanupFunction(gemsResult.value); - if (usageResult.status === 'fulfilled') - cleanupManager.registerCleanupFunction(usageResult.value); const failedInitializer = [notificationResult, draftResult, gemsResult, usageResult].find( (result): result is PromiseRejectedResult => result.status === 'rejected', @@ -352,14 +393,20 @@ async function initializeFeatures(): Promise { // DOM enhancements install observers/listeners but do not need separate // idle waits between each initializer. startMarkdownPatcher(); - cleanupManager.registerCleanupFunction(startCodeBlockCollapse()); + cleanupManager.registerCleanupFunction( + startCodeBlockCollapse(), + CleanupPositions.CleanupCodeBlockCollapse, + ); DefaultModelManager.getInstance().init(); startExportButton(); void startCanvasExport(); await delay(LIGHT_FEATURE_INIT_DELAY); if (await isForkFeatureEnabled()) { - forkCleanup = cleanupManager.registerCleanupFunctionAndReturnIt(startFork()); + forkCleanup = cleanupManager.registerCleanupFunctionAndReturnIt( + startFork(), + CleanupPositions.CleanupFork, + ); } // Introduce new feature coachmarks once the changelog is out of the way; @@ -375,7 +422,10 @@ async function initializeFeatures(): Promise { location.hostname === 'aistudio.google.cn' ) { const pm = await startPromptManager(); - cleanupManager.registerCleanupFunction(() => pm.destroy()); + cleanupManager.registerCleanupFunction( + () => pm.destroy(), + CleanupPositions.DestroyPromptManagerInstance, + ); await delay(HEAVY_FEATURE_INIT_DELAY); } @@ -415,7 +465,10 @@ async function initializeFeatures(): Promise { await delay(LIGHT_FEATURE_INIT_DELAY); // Send behavior (Enter to send) - cleanupManager.registerCleanupFunction(await startSendBehavior('aistudio')); + cleanupManager.registerCleanupFunction( + await startSendBehavior('aistudio'), + CleanupPositions.CleanupSendBehavior, + ); await delay(LIGHT_FEATURE_INIT_DELAY); } } catch (e) { @@ -489,7 +542,11 @@ function handleVisibilityChange(): void { // Saved Library and cloud sync need the same account identity as highlights. // This bridge must exist even when optional Folder Manager code never starts. - if (!isPluginSubframe) cleanupManager.registerCleanupFunction(startAccountContextBridge()); + if (!isPluginSubframe) + cleanupManager.registerCleanupFunction( + startAccountContextBridge(), + CleanupPositions.CleanupAccountContextBridge, + ); // Plugin ecosystem host. Started up-front on EVERY page the content script is // injected into (Gemini / AI Studio, and any site a user enabled a plugin for, @@ -513,14 +570,15 @@ function handleVisibilityChange(): void { updateSettings: updateClaudeTimelineSettings, stop: stopClaudeTimeline, }); - cleanupManager.registerCleanupFunction(startPluginHost()); + cleanupManager.registerCleanupFunction(startPluginHost(), CleanupPositions.CleanupPluginHost); // Cosmetic: on Claude / ChatGPT, re-skin Voyager's accent to the host // platform's brand colour (injects --gv-pm-brand + a gv-platform-themed body // class; CSS derives the rest). Applies the adapter's built-in colour at // once, then lets an enabled plugin's declared theme override it live. No-op // on Gemini / AI Studio. - if (!isPluginSubframe) cleanupManager.registerCleanupFunction(startBrandTheme()); + if (!isPluginSubframe) + cleanupManager.registerCleanupFunction(startBrandTheme(), CleanupPositions.CleanupBrandTheme); const onUnhandledRejection = (event: PromiseRejectionEvent) => { if (isExtensionContextInvalidatedError(event.reason)) { @@ -533,12 +591,14 @@ function handleVisibilityChange(): void { } }; window.addEventListener('unhandledrejection', onUnhandledRejection); - cleanupManager.registerCleanupFunction(() => - window.removeEventListener('unhandledrejection', onUnhandledRejection), + cleanupManager.registerCleanupFunction( + () => window.removeEventListener('unhandledrejection', onUnhandledRejection), + CleanupPositions.RemoveUnhandledRejectionEventListener, ); window.addEventListener('error', onWindowError); - cleanupManager.registerCleanupFunction(() => - window.removeEventListener('error', onWindowError), + cleanupManager.registerCleanupFunction( + () => window.removeEventListener('error', onWindowError), + CleanupPositions.RemoveErrorEventListener, ); const onStorageChanged = ( changes: Record, @@ -565,9 +625,13 @@ function handleVisibilityChange(): void { const enabled = isForkFeatureEnabledValue(forkSetting.newValue); if (enabled) { if (!forkCleanup) { - forkCleanup = cleanupManager.registerCleanupFunctionAndReturnIt(startFork()); + forkCleanup = cleanupManager.registerCleanupFunctionAndReturnIt( + startFork(), + CleanupPositions.CleanupFork, + ); } } else if (forkCleanup) { + // FIXME: here we need to withdraw cleanups from cleanupManager forkCleanup(); forkCleanup = null; } @@ -581,7 +645,10 @@ function handleVisibilityChange(): void { hostname.includes('aistudio.google.com') || hostname.includes('aistudio.google.cn'); if (!isPluginSubframe && (isSupportedSite || pluginPlatformId)) { - cleanupManager.registerCleanupFunction(startRemoteAnnouncements()); + cleanupManager.registerCleanupFunction( + startRemoteAnnouncements(), + CleanupPositions.CleanupRemoteAnnouncements, + ); } // Initialize KaTeX configuration early to suppress Unicode warnings @@ -590,7 +657,10 @@ function handleVisibilityChange(): void { initKaTeXConfig(); // Initialize i18n early to ensure translations are available initI18n().catch((e) => console.error('[Gemini Voyager] i18n init error:', e)); - cleanupManager.registerCleanupFunction(startStorageQuotaWarningToast()); + cleanupManager.registerCleanupFunction( + startStorageQuotaWarningToast(), + CleanupPositions.CleanupStorageQuotaWarning, + ); } // If not a known site, check if it's a custom website (async) @@ -609,7 +679,10 @@ function handleVisibilityChange(): void { console.log('[Gemini Voyager] Plugin platform: prompt manager'); void startPromptManager() .then((instance) => { - cleanupManager.registerCleanupFunction(() => instance.destroy()); + cleanupManager.registerCleanupFunction( + () => instance.destroy(), + CleanupPositions.DestroyPromptManagerInstance, + ); }) .catch((error) => { console.error('[Gemini Voyager] Prompt Manager init error on plugin platform:', error); @@ -634,8 +707,9 @@ function handleVisibilityChange(): void { return; } chrome.storage?.onChanged?.addListener(onStorageChanged); - cleanupManager.registerCleanupFunction(() => - chrome.storage?.onChanged?.removeListener(onStorageChanged), + cleanupManager.registerCleanupFunction( + () => chrome.storage?.onChanged?.removeListener(onStorageChanged), + CleanupPositions.RemoveStorageOnChangedListener, ); const delay = getInitializationDelay(); From b951e9e2dde2b0e7801650a3b9bee41aca7c8ef1 Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 4 Aug 2026 20:44:24 +0800 Subject: [PATCH 09/12] feat(cleanup): add withdraw cleanup functions feature and its tests --- src/core/utils/__tests__/cleanupManager.test.ts | 16 ++++++++++++++++ src/core/utils/cleanupManager.ts | 8 ++++++++ 2 files changed, 24 insertions(+) diff --git a/src/core/utils/__tests__/cleanupManager.test.ts b/src/core/utils/__tests__/cleanupManager.test.ts index 753c62c54..bd023b5d2 100644 --- a/src/core/utils/__tests__/cleanupManager.test.ts +++ b/src/core/utils/__tests__/cleanupManager.test.ts @@ -100,4 +100,20 @@ describe('willCleanUp tests module', () => { expect(function2).toHaveBeenCalledAfter(function1); expect(function3).toHaveBeenCalledAfter(function2); }); + + it('can withdraw functions by position number', () => { + const function1 = vi.fn(); + const function2 = vi.fn(); + const function3 = vi.fn(); + + cleanupManager.registerCleanupFunction(function3, Sequence.Third); + cleanupManager.registerCleanupFunction(function2, Sequence.Second); + cleanupManager.registerCleanupFunction(function1, Sequence.First); + + cleanupManager.withdrawCleanupFunctionsByPositionNumber(Sequence.Second); + + expect(cleanupManager.list().some((cleanups) => cleanups.func === function1)).toBe(true); + expect(cleanupManager.list().some((cleanups) => cleanups.func === function2)).toBe(false); + expect(cleanupManager.list().some((cleanups) => cleanups.func === function3)).toBe(true); + }); }); diff --git a/src/core/utils/cleanupManager.ts b/src/core/utils/cleanupManager.ts index 7b410251e..f2cdae236 100644 --- a/src/core/utils/cleanupManager.ts +++ b/src/core/utils/cleanupManager.ts @@ -30,6 +30,14 @@ export class CleanupManager { return func; } + /** + * Remove any cleanup functions associated with the given position number. + * @param pos Position number for functions which will be removed. + */ + withdrawCleanupFunctionsByPositionNumber(pos: number): void { + this.cleanups = this.cleanups.filter((cleanup) => cleanup.pos != pos); + } + /** * [debug] return a readonly list containing stored cleanup functions. */ From 82150698f70cfa2709305631f749fa3fd4a8fe25 Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 4 Aug 2026 20:45:10 +0800 Subject: [PATCH 10/12] feat(cleanup): apply the withdrawal cleanup functions feature --- src/pages/content/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/content/index.tsx b/src/pages/content/index.tsx index 75c0aae71..cecd25b68 100644 --- a/src/pages/content/index.tsx +++ b/src/pages/content/index.tsx @@ -631,9 +631,9 @@ function handleVisibilityChange(): void { ); } } else if (forkCleanup) { - // FIXME: here we need to withdraw cleanups from cleanupManager forkCleanup(); forkCleanup = null; + cleanupManager.withdrawCleanupFunctionsByPositionNumber(CleanupPositions.CleanupFork); } }; From 057a71c2b484013cbf505b0c1e5c6ce3ea786625 Mon Sep 17 00:00:00 2001 From: Andy Date: Thu, 6 Aug 2026 19:21:34 +0800 Subject: [PATCH 11/12] fix: resolve cleanupManager incorrectly ignoring cleanup functions that throw falsy errors. --- .../utils/__tests__/cleanupManager.test.ts | 19 +++++++++++++++++++ src/core/utils/cleanupManager.ts | 4 +++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/core/utils/__tests__/cleanupManager.test.ts b/src/core/utils/__tests__/cleanupManager.test.ts index bd023b5d2..e2cad5244 100644 --- a/src/core/utils/__tests__/cleanupManager.test.ts +++ b/src/core/utils/__tests__/cleanupManager.test.ts @@ -86,6 +86,25 @@ describe('willCleanUp tests module', () => { expect(cleanupManager.list()).toEqual([]); }); + it('can identify cleanup functions that throws falsy error', () => { + const function1 = () => { + throw undefined; + }; + + const errorSpy = vi.fn(); + + cleanupManager.registerCleanupFunction(function1); + + try { + cleanupManager.executeCleanups(); + } catch (error) { + errorSpy(error); + } + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenLastCalledWith(undefined); + }); + it('can call functions in correct sequence', () => { const function1 = vi.fn(); const function2 = vi.fn(); diff --git a/src/core/utils/cleanupManager.ts b/src/core/utils/cleanupManager.ts index f2cdae236..c041dc2ef 100644 --- a/src/core/utils/cleanupManager.ts +++ b/src/core/utils/cleanupManager.ts @@ -54,6 +54,7 @@ export class CleanupManager { */ executeCleanups(): void { let error: unknown = null; + let hasError = false; this.cleanups .sort((a, b) => { @@ -64,11 +65,12 @@ export class CleanupManager { it.func(); } catch (e) { error = e; + hasError = true; } }); this.cleanups = []; - if (error) throw error; + if (hasError) throw error; } } From b17c614cc947dd455584e32d1b0b4efe3463476a Mon Sep 17 00:00:00 2001 From: Jesse Zhang Date: Fri, 7 Aug 2026 15:39:10 +0100 Subject: [PATCH 12/12] test(cleanup): lock legacy cleanup order Co-authored-by: Codex --- .../utils/__tests__/cleanupManager.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/core/utils/__tests__/cleanupManager.test.ts b/src/core/utils/__tests__/cleanupManager.test.ts index e2cad5244..71c786d03 100644 --- a/src/core/utils/__tests__/cleanupManager.test.ts +++ b/src/core/utils/__tests__/cleanupManager.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CleanupPositions } from '@/core/types/cleanupPositions'; import { CleanupManager } from '@/core/utils/cleanupManager'; enum Sequence { @@ -120,6 +121,37 @@ describe('willCleanUp tests module', () => { expect(function3).toHaveBeenCalledAfter(function2); }); + it('keeps production cleanup positions in the legacy execution order', () => { + const positionsInExecutionOrder = Object.values(CleanupPositions).filter( + (position): position is string => typeof position === 'string', + ); + + expect(positionsInExecutionOrder).toEqual([ + 'RemoveUnhandledRejectionEventListener', + 'RemoveErrorEventListener', + 'StopWatermarkRemover', + 'DestroyFolderManagerInstance', + 'DestroyPromptManagerInstance', + 'DestroySlashPromptFeatureInstance', + 'CleanupQuoteReply', + 'CleanupInputVimMode', + 'CleanupSendBehavior', + 'CleanupDraftSave', + 'CleanupFork', + 'CleanupGemsSidebar', + 'CleanupResponseCompleteNotification', + 'CleanupEdgeFinalVersionNotice', + 'CleanupPluginHost', + 'CleanupBrandTheme', + 'CleanupRemoteAnnouncements', + 'CleanupStorageQuotaWarning', + 'CleanupAccountContextBridge', + 'CleanupCodeBlockCollapse', + 'CleanupUsageStatus', + 'RemoveStorageOnChangedListener', + ]); + }); + it('can withdraw functions by position number', () => { const function1 = vi.fn(); const function2 = vi.fn();