From cd13239bc971f6df1a9ea46e4014572cc1ea83fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20Mendon=C3=A7a?= Date: Tue, 9 Jun 2026 00:45:56 -0400 Subject: [PATCH] fix(extension): await init in onMessage to fix lost click after SW sleeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MV3 service worker is terminated when idle (the WebSocket idle timer disconnects after 10s, removing the last keep-alive). The next Option+Click wakes the SW via runtime.sendMessage, but the onMessage handler used elementSender / currentConfig directly while initialize() was still running its async config load. On a cold start the handler fires before init completes, so elementSender is undefined and the click is silently lost — users had to open the SW console (which keeps it alive) to make it work. Make initialization idempotent (ensureInitialized) and await it inside the listener before sending. The listener stays synchronous and returns true to keep the channel open for the async sendResponse, per MV3 messaging guidance. Fixes #19 --- packages/chrome-extension/src/background.ts | 110 ++++++++++++-------- 1 file changed, 68 insertions(+), 42 deletions(-) diff --git a/packages/chrome-extension/src/background.ts b/packages/chrome-extension/src/background.ts index b636452..d79a5bf 100644 --- a/packages/chrome-extension/src/background.ts +++ b/packages/chrome-extension/src/background.ts @@ -6,18 +6,29 @@ import ConfigStorageService from './services/config-storage-service'; let elementSender: ElementSenderService; let currentConfig: ExtensionConfig; +let initPromise: Promise | null = null; -// Initialize when service worker starts -async function initialize() { - currentConfig = await ConfigStorageService.load(); +// Idempotent initialization. The MV3 service worker is terminated when idle and +// woken by the very message we need to handle, so the onMessage listener must +// AWAIT this before touching elementSender/currentConfig — otherwise the first +// click after the SW sleeps races the async config load and is silently lost +// (elementSender is still undefined -> TypeError). +function ensureInitialized(): Promise { + if (!initPromise) { + initPromise = (async () => { + currentConfig = await ConfigStorageService.load(); - // Create the service (no connection on startup) - elementSender = new ElementSenderService(); + // Create the service (no connection on startup) + elementSender = new ElementSenderService(); - logger.info('🚀 MCP Pointer background script loaded', { - enabled: currentConfig.enabled, - port: currentConfig.websocket.port, - }); + logger.info('🚀 MCP Pointer background script loaded', { + enabled: currentConfig.enabled, + port: currentConfig.websocket.port, + }); + })(); + } + + return initPromise; } // Listen for config changes @@ -34,42 +45,57 @@ ConfigStorageService.onChange((newConfig: ExtensionConfig) => { } }); -// Listen for messages from content script +// Listen for messages from content script. +// NOTE: the listener itself is registered synchronously at the top level and is +// NOT async; the async work runs in an inner IIFE and we return true to keep the +// channel open (per MV3 messaging guidance). chrome.runtime.onMessage .addListener((request: any, _sender: any, sendResponse: (response: any) => void) => { if (request.type === 'DOM_ELEMENT_POINTED' && request.data) { - // Send element with current port and status callback - elementSender.sendElement( - request.data, - currentConfig.websocket.port, - (status, error) => { - // Status flow: CONNECTING -> CONNECTED -> SENDING -> SENT - switch (status) { - case ConnectionStatus.CONNECTING: - logger.info('🔄 Connecting to WebSocket...'); - break; - case ConnectionStatus.CONNECTED: - logger.info('✅ Connected'); - break; - case ConnectionStatus.SENDING: - logger.info('📤 Sending element...'); - break; - case ConnectionStatus.SENT: - logger.info('✓ Element sent successfully'); - break; - case ConnectionStatus.ERROR: - logger.error('❌ Failed:', error); - break; - default: - break; - } - }, - ); - - sendResponse({ success: true }); + (async () => { + try { + // Ensure config + sender are ready even on a cold SW start. + await ensureInitialized(); + + // Send element with current port and status callback + await elementSender.sendElement( + request.data, + currentConfig.websocket.port, + (status, error) => { + // Status flow: CONNECTING -> CONNECTED -> SENDING -> SENT + switch (status) { + case ConnectionStatus.CONNECTING: + logger.info('🔄 Connecting to WebSocket...'); + break; + case ConnectionStatus.CONNECTED: + logger.info('✅ Connected'); + break; + case ConnectionStatus.SENDING: + logger.info('📤 Sending element...'); + break; + case ConnectionStatus.SENT: + logger.info('✓ Element sent successfully'); + break; + case ConnectionStatus.ERROR: + logger.error('❌ Failed:', error); + break; + default: + break; + } + }, + ); + + sendResponse({ success: true }); + } catch (error) { + logger.error('❌ Failed to handle pointed element:', error); + sendResponse({ success: false, error: (error as Error).message }); + } + })(); + + return true; // Keep message channel open for async response } - return true; // Keep message channel open for async response + return true; }); // Handle extension install/update @@ -88,5 +114,5 @@ chrome.runtime.onInstalled.addListener((details) => { } }); -// Start initialization -initialize(); +// Best-effort warm start (messages also trigger ensureInitialized) +ensureInitialized();