From 5db05ce3453a43568b9e7ecd2bfa479bfa8b2503 Mon Sep 17 00:00:00 2001 From: Chetan Upare Date: Wed, 28 Jan 2026 02:05:06 +0530 Subject: [PATCH] feat(core): make visibilitychange behavior configurable - Add visibilityChange options to ClientSocketManagerOptions - Add connectOnVisible and disconnectOnHidden boolean options - Update _handleVisibilityChange to respect configuration - Add comprehensive tests for new configurable behavior - Maintain backward compatibility with default behavior Fixes #34 --- packages/core/src/ClientSocketManager.test.ts | 151 ++++++++++++++++++ packages/core/src/ClientSocketManager.ts | 20 ++- packages/core/src/types.ts | 20 +++ 3 files changed, 189 insertions(+), 2 deletions(-) diff --git a/packages/core/src/ClientSocketManager.test.ts b/packages/core/src/ClientSocketManager.test.ts index c6e1fa8..ffeb7d6 100644 --- a/packages/core/src/ClientSocketManager.test.ts +++ b/packages/core/src/ClientSocketManager.test.ts @@ -890,6 +890,157 @@ describe("ClientSocketManager: unit tests", () => { expect(socketManager.connect).not.toHaveBeenCalled(); expect(socketManager.disconnect).not.toHaveBeenCalled(); }); + + describe("with visibility change configuration", () => { + it("should not connect when connectOnVisible is false", () => { + const customSocketManager = new ClientSocketManager("fakeUrl", { + visibilityChange: { + connectOnVisible: false, + }, + eventHandlers: { + onVisiblePage: vi.fn(), + }, + }); + + // Mock connect/disconnect to avoid real socket calls + customSocketManager.connect = vi.fn() as any; + customSocketManager.disconnect = vi.fn() as any; + + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }); + + Object.defineProperty(customSocketManager, "connected", { + configurable: true, + value: false, + }); + + // @ts-expect-error private method access for testing + customSocketManager._handleVisibilityChange(); + + const { onVisiblePage } = customSocketManager["_inputListeners"]; + + expect(onVisiblePage).toHaveBeenCalledTimes(1); + expect(customSocketManager.connect).not.toHaveBeenCalled(); + expect(customSocketManager.disconnect).not.toHaveBeenCalled(); + }); + + it("should not disconnect when disconnectOnHidden is false", () => { + const customSocketManager = new ClientSocketManager("fakeUrl", { + visibilityChange: { + disconnectOnHidden: false, + }, + eventHandlers: { + onHiddenPage: vi.fn(), + }, + }); + + // Mock connect/disconnect to avoid real socket calls + customSocketManager.connect = vi.fn() as any; + customSocketManager.disconnect = vi.fn() as any; + + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "hidden", + }); + + // @ts-expect-error private method access for testing + customSocketManager._handleVisibilityChange(); + + const { onHiddenPage } = customSocketManager["_inputListeners"]; + + expect(onHiddenPage).toHaveBeenCalledTimes(1); + expect(customSocketManager.disconnect).not.toHaveBeenCalled(); + expect(customSocketManager.connect).not.toHaveBeenCalled(); + }); + + it("should use default behavior when visibilityChange is not provided", () => { + const defaultSocketManager = new ClientSocketManager("fakeUrl", { + eventHandlers: { + onVisiblePage: vi.fn(), + onHiddenPage: vi.fn(), + }, + }); + + // Mock connect/disconnect to avoid real socket calls + defaultSocketManager.connect = vi.fn() as any; + defaultSocketManager.disconnect = vi.fn() as any; + + // Test visible page behavior (should connect by default) + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }); + + Object.defineProperty(defaultSocketManager, "connected", { + configurable: true, + value: false, + }); + + // @ts-expect-error private method access for testing + defaultSocketManager._handleVisibilityChange(); + + expect(defaultSocketManager.connect).toHaveBeenCalledTimes(1); + + // Reset and test hidden page behavior (should disconnect by default) + const connectMock = defaultSocketManager.connect as any; + connectMock.mockClear(); + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "hidden", + }); + + // @ts-expect-error private method access for testing + defaultSocketManager._handleVisibilityChange(); + + expect(defaultSocketManager.disconnect).toHaveBeenCalledTimes(1); + }); + + it("should work with both options set to false", () => { + const customSocketManager = new ClientSocketManager("fakeUrl", { + visibilityChange: { + connectOnVisible: false, + disconnectOnHidden: false, + }, + eventHandlers: { + onVisiblePage: vi.fn(), + onHiddenPage: vi.fn(), + }, + }); + + // Mock connect/disconnect to avoid real socket calls + customSocketManager.connect = vi.fn() as any; + customSocketManager.disconnect = vi.fn() as any; + + // Test visible page + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }); + + Object.defineProperty(customSocketManager, "connected", { + configurable: true, + value: false, + }); + + // @ts-expect-error private method access for testing + customSocketManager._handleVisibilityChange(); + + expect(customSocketManager.connect).not.toHaveBeenCalled(); + + // Test hidden page + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "hidden", + }); + + // @ts-expect-error private method access for testing + customSocketManager._handleVisibilityChange(); + + expect(customSocketManager.disconnect).not.toHaveBeenCalled(); + }); + }); }); it("should call warnDisposedClient and return correct recovered state", async () => { diff --git a/packages/core/src/ClientSocketManager.ts b/packages/core/src/ClientSocketManager.ts index a22e562..4d3d55c 100644 --- a/packages/core/src/ClientSocketManager.ts +++ b/packages/core/src/ClientSocketManager.ts @@ -22,6 +22,9 @@ class ClientSocketManager< private _inputListeners: ClientSocketManagerListenerOptions = {}; + private _connectOnVisible: boolean; + private _disconnectOnHidden: boolean; + constructor(uri: string, options?: ClientSocketManagerOptions) { const { path = "/socket.io", @@ -29,12 +32,21 @@ class ClientSocketManager< reconnectionDelayMax = 2000, eventHandlers, devtool: devtoolOpt, + visibilityChange: visibilityChangeOpt, ...restOptions } = options ?? {}; const { enabled: devtoolEnabled = false, zIndex: devtoolZIndex = 999999 } = devtoolOpt ?? {}; + const { + connectOnVisible = true, + disconnectOnHidden = true, + } = visibilityChangeOpt ?? {}; + + this._connectOnVisible = connectOnVisible; + this._disconnectOnHidden = disconnectOnHidden; + try { this._socket = io(uri, { ...restOptions, @@ -206,11 +218,15 @@ class ClientSocketManager< if (isPageVisible) { this._inputListeners.onVisiblePage?.call(this); - if (!this.connected) this.connect(); + if (this._connectOnVisible && !this.connected) { + this.connect(); + } } else { this._inputListeners.onHiddenPage?.call(this); - this.disconnect(); + if (this._disconnectOnHidden) { + this.disconnect(); + } } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b08e0e7..d5bedd1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -166,4 +166,24 @@ export type ClientSocketManagerOptions = OverrideMembers< */ zIndex?: number; }; + /** + * Page visibility change behavior options. + * + * These options control how the socket connection behaves when the page + * visibility changes (e.g., when switching tabs or minimizing the browser). + */ + visibilityChange?: { + /** + * Whether to automatically connect when the page becomes visible. + * + * @default true + */ + connectOnVisible?: boolean; + /** + * Whether to automatically disconnect when the page becomes hidden. + * + * @default true + */ + disconnectOnHidden?: boolean; + }; };