From 82bbe1d1dfc40525de58ffd74eb2b0a819f153a5 Mon Sep 17 00:00:00 2001 From: Adi Aharoni Date: Wed, 11 Mar 2026 16:43:08 +0200 Subject: [PATCH 01/14] fix: prevent duplicate ready events on SPA re-injection 1. Add _injectInProgress concurrency guard 2. Replace polling loops with waitForFunction 3. Deduplicate Backbone listeners via tuple array with cleanup 4. Atomic hasSynced check after listener registration 5. isMainFrame guard and storeAvailable SPA skip in framenavigated co-Authored-By: Claude Opus 4.6 --- src/Client.js | 211 ++++++++++++++++++++++++++++---------------------- 1 file changed, 117 insertions(+), 94 deletions(-) diff --git a/src/Client.js b/src/Client.js index c4df1bfaaa..9e581a929c 100644 --- a/src/Client.js +++ b/src/Client.js @@ -112,27 +112,18 @@ class Client extends EventEmitter { * Private function */ async inject() { - if ( - this.options.authTimeoutMs === undefined || - this.options.authTimeoutMs == 0 - ) { - this.options.authTimeoutMs = 30000; - } - let start = Date.now(); - let timeout = this.options.authTimeoutMs; - let res = false; - while (start > Date.now() - timeout) { - res = await this.pupPage.evaluate( - 'window.Debug?.VERSION != undefined', - ); - if (res) { - break; - } - await new Promise((r) => setTimeout(r, 200)); - } - if (!res) { - throw 'auth timeout'; - } + if (this._injectInProgress) return; + this._injectInProgress = true; + + const authTimeout = this.options.authTimeoutMs || 30000; + await this.pupPage + .waitForFunction('window.Debug?.VERSION != undefined', { + timeout: authTimeout, + }) + .catch(() => { + this._injectInProgress = false; + throw 'auth timeout'; + }); await this.setDeviceName( this.options.deviceName, this.options.browserName, @@ -142,43 +133,28 @@ class Client extends EventEmitter { await this.pupPage.evaluate(ExposeAuthStore); - const needAuthentication = await this.pupPage.evaluate(async () => { - let state = window.require('WAWebSocketModel').Socket.state; - - if ( - state === 'OPENING' || - state === 'UNLAUNCHED' || - state === 'PAIRING' - ) { - // wait till state changes - await new Promise((r) => { - window - .require('WAWebSocketModel') - .Socket.on( - 'change:state', - function waitTillInit(_AppState, state) { - if ( - state !== 'OPENING' && - state !== 'UNLAUNCHED' && - state !== 'PAIRING' - ) { - window - .require('WAWebSocketModel') - .Socket.off( - 'change:state', - waitTillInit, - ); - r(); - } - }, - ); - }); - } - state = window.require('WAWebSocketModel').Socket.state; - return state == 'UNPAIRED' || state == 'UNPAIRED_IDLE'; - }); + const needAuthHandle = await this.pupPage.waitForFunction( + () => { + const state = + window.require?.('WAWebSocketModel')?.Socket?.state; + if ( + !state || + state === 'OPENING' || + state === 'UNLAUNCHED' || + state === 'PAIRING' + ) { + return false; + } + return { + need: state === 'UNPAIRED' || state === 'UNPAIRED_IDLE', + state, + }; + }, + { timeout: authTimeout }, + ); + const needAuthentication = await needAuthHandle.jsonValue(); - if (needAuthentication) { + if (needAuthentication.need) { const { failed, failureEventPayload, restart } = await this.authStrategy.onAuthenticationNeeded(); @@ -326,21 +302,14 @@ class Client extends EventEmitter { //Load util functions (serializers, helper functions) await this.pupPage.evaluate(LoadUtils); - let start = Date.now(); - let res = false; - while (start > Date.now() - 30000) { - // Check window.WWebJS Injection - res = await this.pupPage.evaluate( - 'window.WWebJS != undefined', - ); - if (res) { - break; - } - await new Promise((r) => setTimeout(r, 200)); - } - if (!res) { - throw 'ready timeout'; - } + await this.pupPage + .waitForFunction( + 'typeof window.WWebJS !== "undefined"', + { timeout: 30000 }, + ) + .catch(() => { + throw 'ready timeout'; + }); /** * Current connection information @@ -398,29 +367,71 @@ class Client extends EventEmitter { }, ); await this.pupPage.evaluate(() => { - window - .require('WAWebSocketModel') - .Socket.on('change:state', (_AppState, state) => { - window.onAuthAppStateChangedEvent(state); - }); - window - .require('WAWebSocketModel') - .Socket.on('change:hasSynced', () => { - window.onAppStateHasSyncedEvent(); - }); + const Socket = window.require('WAWebSocketModel').Socket; const Cmd = window.require('WAWebCmd').Cmd; - Cmd.on('offline_progress_update_from_bridge', () => { - window.onOfflineProgressUpdateEvent( - window.AuthStore.OfflineMessageHandler.getOfflineDeliveryProgress(), - ); - }); - Cmd.on('logout', async () => { - await window.onLogoutEvent(); - }); - Cmd.on('logout_from_bridge', async () => { - await window.onLogoutEvent(); - }); + + const listeners = [ + [ + Socket, + 'change:state', + (_AppState, state) => { + window.onAuthAppStateChangedEvent(state); + }, + ], + [ + Socket, + 'change:hasSynced', + () => { + window.onAppStateHasSyncedEvent(); + }, + ], + [ + Cmd, + 'offline_progress_update_from_bridge', + () => { + window.onOfflineProgressUpdateEvent( + window.AuthStore.OfflineMessageHandler.getOfflineDeliveryProgress(), + ); + }, + ], + [ + Cmd, + 'logout', + async () => { + await window.onLogoutEvent(); + }, + ], + [ + Cmd, + 'logout_from_bridge', + async () => { + await window.onLogoutEvent(); + }, + ], + ]; + + if (window._wwjsListeners) { + for (const [obj, event, handler] of window._wwjsListeners) { + obj.off(event, handler); + } + } + + for (const [obj, event, handler] of listeners) { + obj.on(event, handler); + } + window._wwjsListeners = listeners; }); + + const hasSynced = await this.pupPage.evaluate( + () => window.require('WAWebSocketModel').Socket.hasSynced, + ); + if (hasSynced) { + await this.pupPage.evaluate(() => { + window.onAppStateHasSyncedEvent(); + }); + } + + this._injectInProgress = false; } /** @@ -493,6 +504,8 @@ class Client extends EventEmitter { await this.inject(); this.pupPage.on('framenavigated', async (frame) => { + if (frame.parentFrame() !== null) return; + if (frame.url().includes('post_logout=1') || this.lastLoggedOut) { this.emit(Events.DISCONNECTED, 'LOGOUT'); await this.authStrategy.logout(); @@ -500,7 +513,17 @@ class Client extends EventEmitter { await this.authStrategy.afterBrowserInitialized(); this.lastLoggedOut = false; } - await this.inject(); + + const storeAvailable = await this.pupPage + .evaluate('typeof window.WWebJS !== "undefined"') + .catch(() => false); + if (storeAvailable) return; + + try { + await this.inject(); + } catch (err) { + this._injectInProgress = false; + } }); } From 6362b6493ad060bfb5d1b35339534a333482893d Mon Sep 17 00:00:00 2001 From: Adi Aharoni Date: Wed, 11 Mar 2026 17:04:13 +0200 Subject: [PATCH 02/14] fix: add robustness fixes from fork PR #41 - try/finally around inject() to always reset _injectInProgress - Atomic hasSynced check inside listener registration evaluate() - Fix framenavigated: capture isLogout before async, skip re-inject only when not logout AND store available - try/catch around obj.off() in listener cleanup - Null guard in QR ref change handler - Reset qrRetries on LOADING_SCREEN event - Add exposeFunctionIfAbsent in requestPairingCode() co-Authored-By: Claude Opus 4.6 --- src/Client.js | 633 ++++++++++++++++++++++++++------------------------ 1 file changed, 335 insertions(+), 298 deletions(-) diff --git a/src/Client.js b/src/Client.js index 9e581a929c..7ef4217ba1 100644 --- a/src/Client.js +++ b/src/Client.js @@ -115,323 +115,343 @@ class Client extends EventEmitter { if (this._injectInProgress) return; this._injectInProgress = true; - const authTimeout = this.options.authTimeoutMs || 30000; - await this.pupPage - .waitForFunction('window.Debug?.VERSION != undefined', { - timeout: authTimeout, - }) - .catch(() => { - this._injectInProgress = false; - throw 'auth timeout'; - }); - await this.setDeviceName( - this.options.deviceName, - this.options.browserName, - ); - const pairWithPhoneNumber = this.options.pairWithPhoneNumber; - const version = await this.getWWebVersion(); + try { + const authTimeout = this.options.authTimeoutMs || 30000; + await this.pupPage + .waitForFunction('window.Debug?.VERSION != undefined', { + timeout: authTimeout, + }) + .catch(() => { + throw 'auth timeout'; + }); + await this.setDeviceName( + this.options.deviceName, + this.options.browserName, + ); + const pairWithPhoneNumber = this.options.pairWithPhoneNumber; + const version = await this.getWWebVersion(); - await this.pupPage.evaluate(ExposeAuthStore); + await this.pupPage.evaluate(ExposeAuthStore); - const needAuthHandle = await this.pupPage.waitForFunction( - () => { - const state = - window.require?.('WAWebSocketModel')?.Socket?.state; - if ( - !state || - state === 'OPENING' || - state === 'UNLAUNCHED' || - state === 'PAIRING' - ) { - return false; - } - return { - need: state === 'UNPAIRED' || state === 'UNPAIRED_IDLE', - state, - }; - }, - { timeout: authTimeout }, - ); - const needAuthentication = await needAuthHandle.jsonValue(); + const needAuthHandle = await this.pupPage.waitForFunction( + () => { + const state = + window.require?.('WAWebSocketModel')?.Socket?.state; + if ( + !state || + state === 'OPENING' || + state === 'UNLAUNCHED' || + state === 'PAIRING' + ) { + return false; + } + return { + need: state === 'UNPAIRED' || state === 'UNPAIRED_IDLE', + state, + }; + }, + { timeout: authTimeout }, + ); + const needAuthentication = await needAuthHandle.jsonValue(); - if (needAuthentication.need) { - const { failed, failureEventPayload, restart } = - await this.authStrategy.onAuthenticationNeeded(); + if (needAuthentication.need) { + const { failed, failureEventPayload, restart } = + await this.authStrategy.onAuthenticationNeeded(); - if (failed) { - /** - * Emitted when there has been an error while trying to restore an existing session - * @event Client#auth_failure - * @param {string} message - */ - this.emit(Events.AUTHENTICATION_FAILURE, failureEventPayload); - await this.destroy(); - if (restart) { - // session restore failed so try again but without session to force new authentication - return this.initialize(); + if (failed) { + /** + * Emitted when there has been an error while trying to restore an existing session + * @event Client#auth_failure + * @param {string} message + */ + this.emit( + Events.AUTHENTICATION_FAILURE, + failureEventPayload, + ); + await this.destroy(); + if (restart) { + // session restore failed so try again but without session to force new authentication + return this.initialize(); + } + return; } - return; - } - // Register qr/code events - if (pairWithPhoneNumber.phoneNumber) { - await exposeFunctionIfAbsent( - this.pupPage, - 'onCodeReceivedEvent', - async (code) => { - /** - * Emitted when a pairing code is received - * @event Client#code - * @param {string} code Code - * @returns {string} Code that was just received - */ - this.emit(Events.CODE_RECEIVED, code); - return code; - }, - ); - this.requestPairingCode( - pairWithPhoneNumber.phoneNumber, - pairWithPhoneNumber.showNotification, - pairWithPhoneNumber.intervalMs, - ); - } else { - let qrRetries = 0; - await exposeFunctionIfAbsent( - this.pupPage, - 'onQRChangedEvent', - async (qr) => { - /** - * Emitted when a QR code is received - * @event Client#qr - * @param {string} qr QR Code - */ - this.emit(Events.QR_RECEIVED, qr); - if (this.options.qrMaxRetries > 0) { - qrRetries++; - if (qrRetries > this.options.qrMaxRetries) { - this.emit( - Events.DISCONNECTED, - 'Max qrcode retries reached', - ); - await this.destroy(); - } - } - }, - ); - - await this.pupPage.evaluate(async () => { - const registrationInfo = - await window.AuthStore.RegistrationUtils.waSignalStore.getRegistrationInfo(); - const noiseKeyPair = - await window.AuthStore.RegistrationUtils.waNoiseInfo.get(); - const staticKeyB64 = window.AuthStore.Base64Tools.encodeB64( - noiseKeyPair.staticKeyPair.pubKey, + // Register qr/code events + if (pairWithPhoneNumber.phoneNumber) { + await exposeFunctionIfAbsent( + this.pupPage, + 'onCodeReceivedEvent', + async (code) => { + /** + * Emitted when a pairing code is received + * @event Client#code + * @param {string} code Code + * @returns {string} Code that was just received + */ + this.emit(Events.CODE_RECEIVED, code); + return code; + }, ); - const identityKeyB64 = - window.AuthStore.Base64Tools.encodeB64( - registrationInfo.identityKeyPair.pubKey, - ); - const advSecretKey = - await window.AuthStore.RegistrationUtils.getADVSecretKey(); - const platform = - window.AuthStore.RegistrationUtils.DEVICE_PLATFORM; - const getQR = (ref) => - ref + - ',' + - staticKeyB64 + - ',' + - identityKeyB64 + - ',' + - advSecretKey + - ',' + - platform; - - window.onQRChangedEvent(getQR(window.AuthStore.Conn.ref)); // initial qr - window.AuthStore.Conn.on('change:ref', (_, ref) => { - window.onQRChangedEvent(getQR(ref)); - }); // future QR changes - }); - } - } + this.requestPairingCode( + pairWithPhoneNumber.phoneNumber, + pairWithPhoneNumber.showNotification, + pairWithPhoneNumber.intervalMs, + ); + } else { + let qrRetries = 0; - await exposeFunctionIfAbsent( - this.pupPage, - 'onAuthAppStateChangedEvent', - async (state) => { - if ( - state == 'UNPAIRED_IDLE' && - !pairWithPhoneNumber.phoneNumber - ) { - // refresh qr code - window.require('WAWebCmd').Cmd.refreshQR(); - } - }, - ); + this.on(Events.LOADING_SCREEN, () => { + qrRetries = 0; + }); - await exposeFunctionIfAbsent( - this.pupPage, - 'onAppStateHasSyncedEvent', - async () => { - const authEventPayload = - await this.authStrategy.getAuthEventPayload(); - /** - * Emitted when authentication is successful - * @event Client#authenticated - */ - this.emit(Events.AUTHENTICATED, authEventPayload); + await exposeFunctionIfAbsent( + this.pupPage, + 'onQRChangedEvent', + async (qr) => { + /** + * Emitted when a QR code is received + * @event Client#qr + * @param {string} qr QR Code + */ + this.emit(Events.QR_RECEIVED, qr); + if (this.options.qrMaxRetries > 0) { + qrRetries++; + if (qrRetries > this.options.qrMaxRetries) { + this.emit( + Events.DISCONNECTED, + 'Max qrcode retries reached', + ); + await this.destroy(); + } + } + }, + ); - const injected = await this.pupPage.evaluate(async () => { - return typeof window.WWebJS !== 'undefined'; - }); + await this.pupPage.evaluate(async () => { + const registrationInfo = + await window.AuthStore.RegistrationUtils.waSignalStore.getRegistrationInfo(); + const noiseKeyPair = + await window.AuthStore.RegistrationUtils.waNoiseInfo.get(); + const staticKeyB64 = + window.AuthStore.Base64Tools.encodeB64( + noiseKeyPair.staticKeyPair.pubKey, + ); + const identityKeyB64 = + window.AuthStore.Base64Tools.encodeB64( + registrationInfo.identityKeyPair.pubKey, + ); + const advSecretKey = + await window.AuthStore.RegistrationUtils.getADVSecretKey(); + const platform = + window.AuthStore.RegistrationUtils.DEVICE_PLATFORM; + const getQR = (ref) => + ref + + ',' + + staticKeyB64 + + ',' + + identityKeyB64 + + ',' + + advSecretKey + + ',' + + platform; + + window.onQRChangedEvent( + getQR(window.AuthStore.Conn.ref), + ); // initial qr + window.AuthStore.Conn.on('change:ref', (_, ref) => { + if (ref == null) return; + window.onQRChangedEvent(getQR(ref)); + }); // future QR changes + }); + } + } - if (!injected) { + await exposeFunctionIfAbsent( + this.pupPage, + 'onAuthAppStateChangedEvent', + async (state) => { if ( - this.options.webVersionCache.type === 'local' && - this.currentIndexHtml + state == 'UNPAIRED_IDLE' && + !pairWithPhoneNumber.phoneNumber ) { - const { type: webCacheType, ...webCacheOptions } = - this.options.webVersionCache; - const webCache = WebCacheFactory.createWebCache( - webCacheType, - webCacheOptions, - ); - - await webCache.persist(this.currentIndexHtml, version); + // refresh qr code + window.require('WAWebCmd').Cmd.refreshQR(); } + }, + ); - //Load util functions (serializers, helper functions) - await this.pupPage.evaluate(LoadUtils); - - await this.pupPage - .waitForFunction( - 'typeof window.WWebJS !== "undefined"', - { timeout: 30000 }, - ) - .catch(() => { - throw 'ready timeout'; - }); - + await exposeFunctionIfAbsent( + this.pupPage, + 'onAppStateHasSyncedEvent', + async () => { + const authEventPayload = + await this.authStrategy.getAuthEventPayload(); /** - * Current connection information - * @type {ClientInfo} + * Emitted when authentication is successful + * @event Client#authenticated */ - this.info = new ClientInfo( - this, - await this.pupPage.evaluate(() => { - return { - ...window - .require('WAWebConnModel') - .Conn.serialize(), - wid: - window - .require('WAWebUserPrefsMeUser') - .getMaybeMePnUser() || - window - .require('WAWebUserPrefsMeUser') - .getMaybeMeLidUser(), - }; - }), - ); + this.emit(Events.AUTHENTICATED, authEventPayload); - this.interface = new InterfaceController(this); + const injected = await this.pupPage.evaluate(async () => { + return typeof window.WWebJS !== 'undefined'; + }); - await this.attachEventListeners(); - } - /** - * Emitted when the client has initialized and is ready to receive messages. - * @event Client#ready - */ - this.emit(Events.READY); - this.authStrategy.afterAuthReady(); - }, - ); - let lastPercent = null; - await exposeFunctionIfAbsent( - this.pupPage, - 'onOfflineProgressUpdateEvent', - async (percent) => { - if (lastPercent !== percent) { - lastPercent = percent; - this.emit(Events.LOADING_SCREEN, percent, 'WhatsApp'); // Message is hardcoded as "WhatsApp" for now - } - }, - ); - await exposeFunctionIfAbsent( - this.pupPage, - 'onLogoutEvent', - async () => { - this.lastLoggedOut = true; - await this.pupPage - .waitForNavigation({ waitUntil: 'load', timeout: 5000 }) - .catch((_) => _); - }, - ); - await this.pupPage.evaluate(() => { - const Socket = window.require('WAWebSocketModel').Socket; - const Cmd = window.require('WAWebCmd').Cmd; - - const listeners = [ - [ - Socket, - 'change:state', - (_AppState, state) => { - window.onAuthAppStateChangedEvent(state); - }, - ], - [ - Socket, - 'change:hasSynced', - () => { - window.onAppStateHasSyncedEvent(); - }, - ], - [ - Cmd, - 'offline_progress_update_from_bridge', - () => { - window.onOfflineProgressUpdateEvent( - window.AuthStore.OfflineMessageHandler.getOfflineDeliveryProgress(), + if (!injected) { + if ( + this.options.webVersionCache.type === 'local' && + this.currentIndexHtml + ) { + const { type: webCacheType, ...webCacheOptions } = + this.options.webVersionCache; + const webCache = WebCacheFactory.createWebCache( + webCacheType, + webCacheOptions, + ); + + await webCache.persist( + this.currentIndexHtml, + version, + ); + } + + //Load util functions (serializers, helper functions) + await this.pupPage.evaluate(LoadUtils); + + await this.pupPage + .waitForFunction( + 'typeof window.WWebJS !== "undefined"', + { timeout: 30000 }, + ) + .catch(() => { + throw 'ready timeout'; + }); + + /** + * Current connection information + * @type {ClientInfo} + */ + this.info = new ClientInfo( + this, + await this.pupPage.evaluate(() => { + return { + ...window + .require('WAWebConnModel') + .Conn.serialize(), + wid: + window + .require('WAWebUserPrefsMeUser') + .getMaybeMePnUser() || + window + .require('WAWebUserPrefsMeUser') + .getMaybeMeLidUser(), + }; + }), ); - }, - ], - [ - Cmd, - 'logout', - async () => { - await window.onLogoutEvent(); - }, - ], - [ - Cmd, - 'logout_from_bridge', - async () => { - await window.onLogoutEvent(); - }, - ], - ]; - - if (window._wwjsListeners) { - for (const [obj, event, handler] of window._wwjsListeners) { - obj.off(event, handler); - } - } - for (const [obj, event, handler] of listeners) { - obj.on(event, handler); - } - window._wwjsListeners = listeners; - }); + this.interface = new InterfaceController(this); - const hasSynced = await this.pupPage.evaluate( - () => window.require('WAWebSocketModel').Socket.hasSynced, - ); - if (hasSynced) { + await this.attachEventListeners(); + } + /** + * Emitted when the client has initialized and is ready to receive messages. + * @event Client#ready + */ + this.emit(Events.READY); + this.authStrategy.afterAuthReady(); + }, + ); + let lastPercent = null; + await exposeFunctionIfAbsent( + this.pupPage, + 'onOfflineProgressUpdateEvent', + async (percent) => { + if (lastPercent !== percent) { + lastPercent = percent; + this.emit(Events.LOADING_SCREEN, percent, 'WhatsApp'); // Message is hardcoded as "WhatsApp" for now + } + }, + ); + await exposeFunctionIfAbsent( + this.pupPage, + 'onLogoutEvent', + async () => { + this.lastLoggedOut = true; + await this.pupPage + .waitForNavigation({ waitUntil: 'load', timeout: 5000 }) + .catch((_) => _); + }, + ); await this.pupPage.evaluate(() => { - window.onAppStateHasSyncedEvent(); + const Socket = window.require('WAWebSocketModel').Socket; + const Cmd = window.require('WAWebCmd').Cmd; + + const listeners = [ + [ + Socket, + 'change:state', + (_AppState, state) => { + window.onAuthAppStateChangedEvent(state); + }, + ], + [ + Socket, + 'change:hasSynced', + () => { + window.onAppStateHasSyncedEvent(); + }, + ], + [ + Cmd, + 'offline_progress_update_from_bridge', + () => { + window.onOfflineProgressUpdateEvent( + window.AuthStore.OfflineMessageHandler.getOfflineDeliveryProgress(), + ); + }, + ], + [ + Cmd, + 'logout', + async () => { + await window.onLogoutEvent(); + }, + ], + [ + Cmd, + 'logout_from_bridge', + async () => { + await window.onLogoutEvent(); + }, + ], + ]; + + // Clean up old listeners to prevent accumulation on re-inject + if (window._wwjsListeners) { + for (const [obj, event, handler] of window._wwjsListeners) { + try { + obj.off(event, handler); + } catch (e) { + /* listeners may already be gone */ + } + } + } + + for (const [obj, event, handler] of listeners) { + obj.on(event, handler); + } + window._wwjsListeners = listeners; + + // Atomic hasSynced check in the same synchronous block as listener registration. + // If hasSynced is already true, Backbone won't fire change:hasSynced (no transition). + // If hasSynced is false, the listener above will catch the future transition. + const storeInjected = typeof window.WWebJS !== 'undefined'; + if (Socket.hasSynced === true && !storeInjected) { + window.onAppStateHasSyncedEvent(); + } }); + } finally { + this._injectInProgress = false; } - - this._injectInProgress = false; } /** @@ -506,7 +526,10 @@ class Client extends EventEmitter { this.pupPage.on('framenavigated', async (frame) => { if (frame.parentFrame() !== null) return; - if (frame.url().includes('post_logout=1') || this.lastLoggedOut) { + const isLogout = + frame.url().includes('post_logout=1') || this.lastLoggedOut; + + if (isLogout) { this.emit(Events.DISCONNECTED, 'LOGOUT'); await this.authStrategy.logout(); await this.authStrategy.beforeBrowserInitialized(); @@ -514,15 +537,21 @@ class Client extends EventEmitter { this.lastLoggedOut = false; } - const storeAvailable = await this.pupPage - .evaluate('typeof window.WWebJS !== "undefined"') - .catch(() => false); - if (storeAvailable) return; + let storeAvailable = false; + try { + storeAvailable = await this.pupPage.evaluate(() => { + return typeof window.WWebJS !== 'undefined'; + }); + } catch (e) { + /* page may not be ready */ + } + + if (!isLogout && storeAvailable) return; try { await this.inject(); } catch (err) { - this._injectInProgress = false; + // inject() may fail if page is still loading after navigation } }); } @@ -539,6 +568,14 @@ class Client extends EventEmitter { showNotification = true, intervalMs = 180000, ) { + await exposeFunctionIfAbsent( + this.pupPage, + 'onCodeReceivedEvent', + async (code) => { + this.emit(Events.CODE_RECEIVED, code); + return code; + }, + ); return await this.pupPage.evaluate( async (phoneNumber, showNotification, intervalMs) => { const getCode = async () => { From 5b8db75ad0c5c750e77c9a188cd520620067b669 Mon Sep 17 00:00:00 2001 From: Adi Aharoni Date: Wed, 11 Mar 2026 17:13:35 +0200 Subject: [PATCH 03/14] fix: remove QR listener on auth success extract named onRefChange handler so it can be removed via socket.on('change:hasSynced') once authentication succeeds, matching PR #41's cleanup behavior. co-Authored-By: Claude Opus 4.6 --- src/Client.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/Client.js b/src/Client.js index 7ef4217ba1..bf8b5a78f9 100644 --- a/src/Client.js +++ b/src/Client.js @@ -255,13 +255,25 @@ class Client extends EventEmitter { ',' + platform; + const onRefChange = (_, ref) => { + if (ref == null) return; + window.onQRChangedEvent(getQR(ref)); + }; + window.onQRChangedEvent( getQR(window.AuthStore.Conn.ref), ); // initial qr - window.AuthStore.Conn.on('change:ref', (_, ref) => { - if (ref == null) return; - window.onQRChangedEvent(getQR(ref)); - }); // future QR changes + window.AuthStore.Conn.on('change:ref', onRefChange); // future QR changes + + // Remove QR listener once authentication succeeds + window + .require('WAWebSocketModel') + .Socket.on('change:hasSynced', () => { + window.AuthStore.Conn.off( + 'change:ref', + onRefChange, + ); + }); }); } } From db98a6fea767888eadbb06168776045d1c16bda7 Mon Sep 17 00:00:00 2001 From: Adi Aharoni Date: Thu, 12 Mar 2026 08:47:51 +0200 Subject: [PATCH 04/14] refactor: remove exposeFunctionIfAbsent from requestPairingCode --- src/Client.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/Client.js b/src/Client.js index bf8b5a78f9..2f8ddceeec 100644 --- a/src/Client.js +++ b/src/Client.js @@ -580,14 +580,6 @@ class Client extends EventEmitter { showNotification = true, intervalMs = 180000, ) { - await exposeFunctionIfAbsent( - this.pupPage, - 'onCodeReceivedEvent', - async (code) => { - this.emit(Events.CODE_RECEIVED, code); - return code; - }, - ); return await this.pupPage.evaluate( async (phoneNumber, showNotification, intervalMs) => { const getCode = async () => { From 73eb41c932a302e5c2e7d28ef3348e5740b8ebc1 Mon Sep 17 00:00:00 2001 From: Adi Aharoni Date: Mon, 16 Mar 2026 03:42:28 +0200 Subject: [PATCH 05/14] fix: run refreshQR in browser context via pupPage.evaluate the onAuthAppStateChangedEvent callback is registered with exposefunctionifabsent, so it runs in Node.js context, not the browser. calling window.require() there throws ReferenceError: window is not defined, which means the QR code was never refreshed on UNPAIRED_IDLE state. --- src/Client.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Client.js b/src/Client.js index 2f8ddceeec..92b0e4a58c 100644 --- a/src/Client.js +++ b/src/Client.js @@ -287,7 +287,9 @@ class Client extends EventEmitter { !pairWithPhoneNumber.phoneNumber ) { // refresh qr code - window.require('WAWebCmd').Cmd.refreshQR(); + await this.pupPage.evaluate(() => { + window.require('WAWebCmd').Cmd.refreshQR(); + }); } }, ); From 0830daaf2f1d1861a676a535f26aec760ac40b9f Mon Sep 17 00:00:00 2001 From: Adi Aharoni Date: Thu, 19 Mar 2026 21:39:59 +0200 Subject: [PATCH 06/14] fix: remove unnecessary try/catch around Backbone .off() backbone's .off() never throws - verified on a live WhatsApp Web instance with non-existent listeners, fake events, and double-off. the try/catch was defensive but unnecessary. --- src/Client.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Client.js b/src/Client.js index c7e2dee51d..b793dd25ae 100644 --- a/src/Client.js +++ b/src/Client.js @@ -442,11 +442,7 @@ class Client extends EventEmitter { // Clean up old listeners to prevent accumulation on re-inject if (window._wwjsListeners) { for (const [obj, event, handler] of window._wwjsListeners) { - try { - obj.off(event, handler); - } catch (e) { - /* listeners may already be gone */ - } + obj.off(event, handler); } } From ca01957dd28147917357ac6c0726dc1f4298693e Mon Sep 17 00:00:00 2001 From: Adi Aharoni Date: Thu, 19 Mar 2026 22:00:44 +0200 Subject: [PATCH 07/14] fix: remove unnecessary try/catch blocks in framenavigated handler - storeAvailable: evaluate() always succeeds after framenavigated because Puppeteer waits for the execution context automatically (verified via CDP event order and Puppeteer source) - inject(): matches upstream behavior - let errors surface instead of silently swallowing them --- src/Client.js | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/Client.js b/src/Client.js index b793dd25ae..23ca6903ae 100644 --- a/src/Client.js +++ b/src/Client.js @@ -547,22 +547,13 @@ class Client extends EventEmitter { this.lastLoggedOut = false; } - let storeAvailable = false; - try { - storeAvailable = await this.pupPage.evaluate(() => { - return typeof window.WWebJS !== 'undefined'; - }); - } catch (e) { - /* page may not be ready */ - } + const storeAvailable = await this.pupPage.evaluate(() => { + return typeof window.WWebJS !== 'undefined'; + }); if (!isLogout && storeAvailable) return; - try { - await this.inject(); - } catch (err) { - // inject() may fail if page is still loading after navigation - } + await this.inject(); }); } From 96bc8d25ba732dabbe4797731fd49fac8d37d1a6 Mon Sep 17 00:00:00 2001 From: Adi Aharoni Date: Wed, 1 Apr 2026 00:42:41 +0300 Subject: [PATCH 08/14] fix: use AbortController to cancel inject on page navigation when WhatsApp Web does location.reload() during inject (e.g. after viewer context auth), puppeteer's waitForFunction hangs for 30s on a dead execution context. After the timeout, no recovery is possible. fix: - Replace _injectInProgress flag with AbortController pattern: new inject() calls automatically cancel any previous in-progress inject - Pass signal to waitForFunction for immediate cancellation - Move framenavigated handler registration before first inject() in initialize(), so navigation during startup triggers re-inject - Abort inject on destroy() to prevent spurious errors --- src/Client.js | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/Client.js b/src/Client.js index 23ca6903ae..ed39fc1ce3 100644 --- a/src/Client.js +++ b/src/Client.js @@ -112,18 +112,23 @@ class Client extends EventEmitter { * Private function */ async inject() { - if (this._injectInProgress) return; - this._injectInProgress = true; + // Cancel any previous inject still running + if (this._injectAbort) this._injectAbort.abort(); + const abort = new AbortController(); + this._injectAbort = abort; try { const authTimeout = this.options.authTimeoutMs || 30000; await this.pupPage .waitForFunction('window.Debug?.VERSION != undefined', { timeout: authTimeout, + signal: abort.signal, }) - .catch(() => { + .catch((err) => { + if (abort.signal.aborted) throw err; throw 'auth timeout'; }); + if (abort.signal.aborted) return; await this.setDeviceName( this.options.deviceName, this.options.browserName, @@ -459,8 +464,13 @@ class Client extends EventEmitter { window.onAppStateHasSyncedEvent(); } }); + } catch (err) { + if (abort.signal.aborted) return; // superseded by newer inject + throw err; } finally { - this._injectInProgress = false; + if (this._injectAbort === abort) { + this._injectAbort = null; + } } } @@ -531,7 +541,16 @@ class Client extends EventEmitter { referer: 'https://whatsapp.com/', }); + // Register framenavigated BEFORE inject so that if navigation + // interrupts inject, the handler triggers a fresh inject. + this._registerFramenavigatedHandler(); + await this.inject(); + } + + _registerFramenavigatedHandler() { + if (this._framenavigatedRegistered) return; + this._framenavigatedRegistered = true; this.pupPage.on('framenavigated', async (frame) => { if (frame.parentFrame() !== null) return; @@ -1231,6 +1250,8 @@ class Client extends EventEmitter { * Closes the client */ async destroy() { + if (this._injectAbort) this._injectAbort.abort(); + const browser = this.pupBrowser; const isConnected = browser?.isConnected?.(); if (isConnected) { From 1cecbd1ddf656ddf7faaee66540dcd6462f9f71e Mon Sep 17 00:00:00 2001 From: Adi Aharoni Date: Sun, 5 Apr 2026 00:56:39 +0300 Subject: [PATCH 09/14] chore: ignore *.d.ts in eslint upstream added TypeScript declaration changes that ESLint cannot parse. --- .eslintignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.eslintignore b/.eslintignore index ad9338310d..c52e328b65 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,6 +3,7 @@ dist coverage docs *.min.js +*.d.ts .wa-version .wwebjs_auth .wwebjs_cache From e78b40a7e7d2251bb42e5349f3b134473eaf2315 Mon Sep 17 00:00:00 2001 From: Adi Aharoni Date: Mon, 6 Apr 2026 21:48:22 +0300 Subject: [PATCH 10/14] chore: revert index.d.ts and .eslintignore changes --- .eslintignore | 1 - index.d.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.eslintignore b/.eslintignore index c52e328b65..ad9338310d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,7 +3,6 @@ dist coverage docs *.min.js -*.d.ts .wa-version .wwebjs_auth .wwebjs_cache diff --git a/index.d.ts b/index.d.ts index 722d96fc37..9fc11bb5cc 100644 --- a/index.d.ts +++ b/index.d.ts @@ -198,7 +198,7 @@ declare namespace WAWebJS { ): Promise; /** Cancels an active pairing code session and returns to QR code mode */ - cancelPairingCode(): Promise; + cancelPairingCode(): Promise /** Force reset of connection state for the client */ resetState(): Promise; From 58bf9fcadc55a617de7e4f05d7f95e7b13f684c2 Mon Sep 17 00:00:00 2001 From: Adi Aharoni Date: Mon, 6 Apr 2026 21:50:03 +0300 Subject: [PATCH 11/14] fix: reset _framenavigatedRegistered flag on destroy --- src/Client.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Client.js b/src/Client.js index 6aada71e3b..d8dccce882 100644 --- a/src/Client.js +++ b/src/Client.js @@ -1303,12 +1303,13 @@ class Client extends EventEmitter { } }); } else { - this.pupPage.on('response', async (res) => { - if (res.ok() && res.url() === WhatsWebURL) { - const indexHtml = await res.text(); + this.pupPage + .waitForResponse((res) => res.ok() && res.url() === WhatsWebURL) + .then((res) => res.text()) + .then((indexHtml) => { this.currentIndexHtml = indexHtml; - } - }); + }) + .catch(() => {}); } } @@ -1317,6 +1318,7 @@ class Client extends EventEmitter { */ async destroy() { if (this._injectAbort) this._injectAbort.abort(); + this._framenavigatedRegistered = false; const browser = this.pupBrowser; const isConnected = browser?.isConnected?.(); From 76801a14ee8b756e7c45e5f63076fefe244245bf Mon Sep 17 00:00:00 2001 From: Adi Aharoni Date: Mon, 6 Apr 2026 21:55:04 +0300 Subject: [PATCH 12/14] fix: revert unintended setCurrentIndexHtml refactor from lint auto-fix --- src/Client.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Client.js b/src/Client.js index d8dccce882..f75026be3a 100644 --- a/src/Client.js +++ b/src/Client.js @@ -1303,13 +1303,12 @@ class Client extends EventEmitter { } }); } else { - this.pupPage - .waitForResponse((res) => res.ok() && res.url() === WhatsWebURL) - .then((res) => res.text()) - .then((indexHtml) => { + this.pupPage.on('response', async (res) => { + if (res.ok() && res.url() === WhatsWebURL) { + const indexHtml = await res.text(); this.currentIndexHtml = indexHtml; - }) - .catch(() => {}); + } + }); } } From 8cfd915ee5331d8b828c74ca8306dc72517bb0f1 Mon Sep 17 00:00:00 2001 From: Adi Aharoni Date: Mon, 6 Apr 2026 22:05:45 +0300 Subject: [PATCH 13/14] fix: replace async listener with waitForResponse for index cache the response listener used an async handler on EventEmitter. mitt ignores returned Promises, so when res.text() rejected on CDP disconnect the promise was orphaned - causing unhandledRejection. replace with page.waitForResponse() which returns a proper Promise chain. Use .catch((_) => _) following the existing convention in this codebase (see waitForNavigation on line 386). --- src/Client.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Client.js b/src/Client.js index f75026be3a..5bf43f2b36 100644 --- a/src/Client.js +++ b/src/Client.js @@ -1303,12 +1303,13 @@ class Client extends EventEmitter { } }); } else { - this.pupPage.on('response', async (res) => { - if (res.ok() && res.url() === WhatsWebURL) { - const indexHtml = await res.text(); + this.pupPage + .waitForResponse((res) => res.ok() && res.url() === WhatsWebURL) + .then((res) => res.text()) + .then((indexHtml) => { this.currentIndexHtml = indexHtml; - } - }); + }) + .catch((_) => _); } } From f0a145cbbee420dd9a3f171c539a59d64b130c30 Mon Sep 17 00:00:00 2001 From: Adi Aharoni Date: Mon, 6 Apr 2026 22:06:14 +0300 Subject: [PATCH 14/14] Revert "fix: replace async listener with waitForResponse for index cache" This reverts commit 8cfd915ee5331d8b828c74ca8306dc72517bb0f1. --- src/Client.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Client.js b/src/Client.js index 5bf43f2b36..f75026be3a 100644 --- a/src/Client.js +++ b/src/Client.js @@ -1303,13 +1303,12 @@ class Client extends EventEmitter { } }); } else { - this.pupPage - .waitForResponse((res) => res.ok() && res.url() === WhatsWebURL) - .then((res) => res.text()) - .then((indexHtml) => { + this.pupPage.on('response', async (res) => { + if (res.ok() && res.url() === WhatsWebURL) { + const indexHtml = await res.text(); this.currentIndexHtml = indexHtml; - }) - .catch((_) => _); + } + }); } }