From 7c315470c83a9019e66b6e35c5e59e3f25583014 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Fri, 13 Feb 2026 17:46:50 +0300 Subject: [PATCH 1/6] fix: close WebSocket on pagehide to support bfcache --- packages/javascript/src/modules/socket.ts | 41 +++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/javascript/src/modules/socket.ts b/packages/javascript/src/modules/socket.ts index eb64b59..d30d0a9 100644 --- a/packages/javascript/src/modules/socket.ts +++ b/packages/javascript/src/modules/socket.ts @@ -54,6 +54,11 @@ export default class Socket implements Transport { */ private reconnectionAttempts: number; + /** + * Page hide event handler reference (for removal) + */ + private pageHideHandler: () => void; + /** * Creates new Socket instance. Setup initial socket params. * @@ -77,6 +82,11 @@ export default class Socket implements Transport { this.reconnectionTimeout = reconnectionTimeout; this.reconnectionAttempts = reconnectionAttempts; + this.pageHideHandler = () => { + log('Page entering bfcache, closing WebSocket', 'info'); + this.close(); + }; + this.eventsQueue = []; this.ws = null; @@ -120,12 +130,25 @@ export default class Socket implements Transport { } /** - * Create new WebSocket connection and setup event listeners + * Setup window event listeners + */ + private setupListeners(): void { + window.addEventListener('pagehide', this.pageHideHandler, { capture: true }); + } + + /** + * Remove window event listeners + */ + public destroyListeners(): void { + window.removeEventListener('pagehide', this.pageHideHandler, { capture: true }); + } + + /** + * Create new WebSocket connection and setup socket event listeners */ private init(): Promise { return new Promise((resolve, reject) => { this.ws = new WebSocket(this.url); - /** * New message handler */ @@ -139,6 +162,8 @@ export default class Socket implements Transport { * @param event - websocket event on closing */ this.ws.onclose = (event: CloseEvent): void => { + this.destroyListeners(); + if (typeof this.onClose === 'function') { this.onClose(event); } @@ -154,6 +179,8 @@ export default class Socket implements Transport { }; this.ws.onopen = (event: Event): void => { + this.setupListeners(); + if (typeof this.onOpen === 'function') { this.onOpen(event); } @@ -163,6 +190,16 @@ export default class Socket implements Transport { }); } + /** + * Closes socket, it can be restored with init() later + */ + private close(): void { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + /** * Tries to reconnect to the server for specified number of times with the interval * From 715f01602e4f41996f97176938bd770a1f0a56dd Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Fri, 13 Feb 2026 17:58:21 +0300 Subject: [PATCH 2/6] fix: lint --- packages/javascript/src/modules/socket.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/javascript/src/modules/socket.ts b/packages/javascript/src/modules/socket.ts index d30d0a9..affbcaa 100644 --- a/packages/javascript/src/modules/socket.ts +++ b/packages/javascript/src/modules/socket.ts @@ -130,17 +130,17 @@ export default class Socket implements Transport { } /** - * Setup window event listeners + * Remove window event listeners */ - private setupListeners(): void { - window.addEventListener('pagehide', this.pageHideHandler, { capture: true }); + public destroyListeners(): void { + window.removeEventListener('pagehide', this.pageHideHandler, { capture: true }); } /** - * Remove window event listeners + * Setup window event listeners */ - public destroyListeners(): void { - window.removeEventListener('pagehide', this.pageHideHandler, { capture: true }); + private setupListeners(): void { + window.addEventListener('pagehide', this.pageHideHandler, { capture: true }); } /** @@ -149,6 +149,7 @@ export default class Socket implements Transport { private init(): Promise { return new Promise((resolve, reject) => { this.ws = new WebSocket(this.url); + /** * New message handler */ From 8e22ce4059b9e6cb5b555dcf3809a2db20736366 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Tue, 17 Feb 2026 00:40:09 +0300 Subject: [PATCH 3/6] fix: remove log --- packages/javascript/src/modules/socket.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/javascript/src/modules/socket.ts b/packages/javascript/src/modules/socket.ts index affbcaa..290658c 100644 --- a/packages/javascript/src/modules/socket.ts +++ b/packages/javascript/src/modules/socket.ts @@ -83,7 +83,6 @@ export default class Socket implements Transport { this.reconnectionAttempts = reconnectionAttempts; this.pageHideHandler = () => { - log('Page entering bfcache, closing WebSocket', 'info'); this.close(); }; @@ -130,17 +129,17 @@ export default class Socket implements Transport { } /** - * Remove window event listeners + * Setup window event listeners */ - public destroyListeners(): void { - window.removeEventListener('pagehide', this.pageHideHandler, { capture: true }); + private setupListeners(): void { + window.addEventListener('pagehide', this.pageHideHandler, { capture: true }); } /** - * Setup window event listeners + * Remove window event listeners */ - private setupListeners(): void { - window.addEventListener('pagehide', this.pageHideHandler, { capture: true }); + private destroyListeners(): void { + window.removeEventListener('pagehide', this.pageHideHandler, { capture: true }); } /** @@ -192,7 +191,7 @@ export default class Socket implements Transport { } /** - * Closes socket, it can be restored with init() later + * Closes socket connection */ private close(): void { if (this.ws) { From 403cc47b3855465177a580e93d2bd5d04e4f7ed3 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Tue, 17 Feb 2026 01:30:55 +0300 Subject: [PATCH 4/6] chore: add socket test --- packages/javascript/tests/socket.test.ts | 88 ++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 packages/javascript/tests/socket.test.ts diff --git a/packages/javascript/tests/socket.test.ts b/packages/javascript/tests/socket.test.ts new file mode 100644 index 0000000..f25bc39 --- /dev/null +++ b/packages/javascript/tests/socket.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Socket from '../src/modules/socket'; +import type { CatcherMessage } from '@hawk.so/types'; + +const MOCK_WEBSOCKET_URL = 'ws://localhost:1234'; + +type MockWebSocket = { + url: string; + readyState: number; + send: ReturnType; + close: ReturnType; + onopen?: (event: Event) => void; + onclose?: (event: CloseEvent) => void; + onerror?: (event: Event) => void; + onmessage?: (event: MessageEvent) => void; +}; + +function createMockWebSocket() { + const instances: MockWebSocket[] = []; + + const closeSpy = vi.fn(function (this: MockWebSocket) { + this.readyState = WebSocket.CLOSED; + this.onclose?.({ code: 1000 } as CloseEvent); + }); + + const ctor = vi.fn<(url: string) => MockWebSocket>().mockImplementation(function ( + this: MockWebSocket, + url: string + ) { + this.url = url; + this.readyState = WebSocket.CONNECTING; + this.send = vi.fn(); + this.close = closeSpy; + this.onopen = undefined; + this.onclose = undefined; + this.onerror = undefined; + this.onmessage = undefined; + + instances.push(this); + }); + + return { ctor, closeSpy, instances }; +} + +describe('Socket', () => { + let ctor: ReturnType; + let closeSpy: ReturnType; + let instances: MockWebSocket[]; + + let addSpy: ReturnType; + let removeSpy: ReturnType; + + beforeEach(() => { + ({ ctor, closeSpy, instances } = createMockWebSocket()); + (globalThis as any).WebSocket = ctor; + + addSpy = vi.spyOn(window, 'addEventListener'); + removeSpy = vi.spyOn(window, 'removeEventListener'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should close websocket on pagehide and recreate connection on next send()', async () => { + const socket = new Socket({ collectorEndpoint: MOCK_WEBSOCKET_URL }); + + const first = instances[0]; + first.readyState = WebSocket.OPEN; + first.onopen?.(new Event('open')); + const pagehideHandler = addSpy.mock.calls.find(c => c[0] === 'pagehide')![1] as EventListener; + + window.dispatchEvent(new Event('pagehide')); + + expect(closeSpy).toHaveBeenCalledOnce(); + expect(removeSpy).toHaveBeenCalledWith('pagehide', pagehideHandler, { capture: true }); + + const sendPromise = socket.send({ foo: 'bar' } as CatcherMessage); + + const second = instances[1]; + second.readyState = WebSocket.OPEN; + second.onopen?.(new Event('open')); + await sendPromise; + + expect(ctor).toHaveBeenCalledTimes(2); + expect(second.url).toBe(MOCK_WEBSOCKET_URL); + }); +}); From 187eab493447edabad9a4ef186550b78479705f2 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Tue, 17 Feb 2026 16:12:55 +0300 Subject: [PATCH 5/6] chore: update version --- packages/javascript/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/javascript/package.json b/packages/javascript/package.json index 22e9827..bb4d848 100644 --- a/packages/javascript/package.json +++ b/packages/javascript/package.json @@ -1,6 +1,6 @@ { "name": "@hawk.so/javascript", - "version": "3.2.17", + "version": "3.2.18", "description": "JavaScript errors tracking for Hawk.so", "files": [ "dist" From 1935f2100f737bb73f1645c753f046e0f48dc578 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Wed, 18 Feb 2026 15:44:12 +0300 Subject: [PATCH 6/6] fix: test update for better clarity --- packages/javascript/tests/socket.test.ts | 94 ++++++++++-------------- 1 file changed, 40 insertions(+), 54 deletions(-) diff --git a/packages/javascript/tests/socket.test.ts b/packages/javascript/tests/socket.test.ts index f25bc39..27a37db 100644 --- a/packages/javascript/tests/socket.test.ts +++ b/packages/javascript/tests/socket.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import Socket from '../src/modules/socket'; import type { CatcherMessage } from '@hawk.so/types'; @@ -15,74 +15,60 @@ type MockWebSocket = { onmessage?: (event: MessageEvent) => void; }; -function createMockWebSocket() { - const instances: MockWebSocket[] = []; - - const closeSpy = vi.fn(function (this: MockWebSocket) { - this.readyState = WebSocket.CLOSED; - this.onclose?.({ code: 1000 } as CloseEvent); - }); - - const ctor = vi.fn<(url: string) => MockWebSocket>().mockImplementation(function ( - this: MockWebSocket, - url: string - ) { - this.url = url; - this.readyState = WebSocket.CONNECTING; - this.send = vi.fn(); - this.close = closeSpy; - this.onopen = undefined; - this.onclose = undefined; - this.onerror = undefined; - this.onmessage = undefined; - - instances.push(this); - }); - - return { ctor, closeSpy, instances }; -} - describe('Socket', () => { - let ctor: ReturnType; - let closeSpy: ReturnType; - let instances: MockWebSocket[]; - - let addSpy: ReturnType; - let removeSpy: ReturnType; - - beforeEach(() => { - ({ ctor, closeSpy, instances } = createMockWebSocket()); - (globalThis as any).WebSocket = ctor; - - addSpy = vi.spyOn(window, 'addEventListener'); - removeSpy = vi.spyOn(window, 'removeEventListener'); - }); - afterEach(() => { vi.restoreAllMocks(); }); it('should close websocket on pagehide and recreate connection on next send()', async () => { + const closeSpy = vi.fn(function (this: MockWebSocket) { + this.readyState = WebSocket.CLOSED; + this.onclose?.({ code: 1000 } as CloseEvent); + }); + + let webSocket!: MockWebSocket; + const WebSocketConstructor = vi.fn<(url: string) => void>().mockImplementation(function ( + this: MockWebSocket, + url: string + ) { + this.url = url; + this.readyState = WebSocket.CONNECTING; + this.send = vi.fn(); + this.close = closeSpy; + this.onopen = undefined; + this.onclose = undefined; + this.onerror = undefined; + this.onmessage = undefined; + webSocket = this; + }); + globalThis.WebSocket = WebSocketConstructor; + + const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); + + // initialize socket and open fake websocket connection const socket = new Socket({ collectorEndpoint: MOCK_WEBSOCKET_URL }); + webSocket.readyState = WebSocket.OPEN; + webSocket.onopen?.(new Event('open')); - const first = instances[0]; - first.readyState = WebSocket.OPEN; - first.onopen?.(new Event('open')); - const pagehideHandler = addSpy.mock.calls.find(c => c[0] === 'pagehide')![1] as EventListener; + // capture pagehide handler to verify it's properly removed + const pagehideCall = addEventListenerSpy.mock.calls.find(([event]) => event === 'pagehide'); + expect(pagehideCall).toBeDefined(); + const pagehideHandler = pagehideCall![1] as EventListener; + // trigger pagehide event window.dispatchEvent(new Event('pagehide')); + // websocket connection should be closed expect(closeSpy).toHaveBeenCalledOnce(); - expect(removeSpy).toHaveBeenCalledWith('pagehide', pagehideHandler, { capture: true }); + expect(removeEventListenerSpy).toHaveBeenCalledWith('pagehide', pagehideHandler, { capture: true }); + // send socket method should make websocket reconnect const sendPromise = socket.send({ foo: 'bar' } as CatcherMessage); - - const second = instances[1]; - second.readyState = WebSocket.OPEN; - second.onopen?.(new Event('open')); + webSocket.readyState = WebSocket.OPEN; + webSocket.onopen?.(new Event('open')); await sendPromise; - expect(ctor).toHaveBeenCalledTimes(2); - expect(second.url).toBe(MOCK_WEBSOCKET_URL); + expect(WebSocketConstructor).toHaveBeenCalledTimes(2); }); });