Skip to content

Commit 04a8f16

Browse files
authored
chore(PLA-2218): enable socket disconnection when page visibility switches to hidden (#866)
1 parent cb7148b commit 04a8f16

12 files changed

Lines changed: 271 additions & 282 deletions

File tree

.changeset/tasty-zoos-appear.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@knocklabs/client": patch
3+
---
4+
5+
Disconnects socket after an initial delay when page visibility is hidden and removes the `auto_manage_socket_connection` and `auto_manage_socket_connection_delay` flags from `FeedClientOptions`.

packages/client/src/api.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ import axiosRetry from "axios-retry";
33
import { Socket } from "phoenix";
44

55
import { exponentialBackoffFullJitter } from "./helpers";
6+
import { PageVisibilityManager } from "./pageVisibility";
67

78
type ApiClientOptions = {
89
host: string;
910
apiKey: string;
1011
userToken: string | undefined;
1112
branch?: string;
13+
/** Automatically disconnect the socket when the page is hidden and reconnect when visible. Defaults to `true`. */
14+
disconnectOnPageHidden?: boolean;
1215
};
1316

1417
export interface ApiResponse {
@@ -28,6 +31,7 @@ class ApiClient {
2831
private axiosClient: AxiosInstance;
2932

3033
public socket: Socket | undefined;
34+
private pageVisibility: PageVisibilityManager | undefined;
3135

3236
constructor(options: ApiClientOptions) {
3337
this.host = options.host;
@@ -68,6 +72,10 @@ class ApiClient {
6872
});
6973
},
7074
});
75+
76+
if (options.disconnectOnPageHidden !== false) {
77+
this.pageVisibility = new PageVisibilityManager(this.socket);
78+
}
7179
}
7280

7381
axiosRetry(this.axiosClient, {
@@ -101,6 +109,14 @@ class ApiClient {
101109
}
102110
}
103111

112+
teardown() {
113+
this.pageVisibility?.teardown();
114+
115+
if (this.socket?.isConnected()) {
116+
this.socket.disconnect();
117+
}
118+
}
119+
104120
private canRetryRequest(error: AxiosError) {
105121
// Retry Network Errors.
106122
if (axiosRetry.isNetworkError(error)) {

packages/client/src/clients/feed/feed.ts

Lines changed: 0 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,6 @@ const feedClientDefaults: Pick<FeedClientOptions, "archived" | "mode"> = {
4141
mode: "compact",
4242
};
4343

44-
const DEFAULT_DISCONNECT_DELAY = 2000;
45-
4644
const CLIENT_REF_ID_PREFIX = "client_";
4745

4846
class Feed {
@@ -53,10 +51,7 @@ class Feed {
5351
private userFeedId: string;
5452
private broadcaster: EventEmitter;
5553
private broadcastChannel!: BroadcastChannel | null;
56-
private disconnectTimer: ReturnType<typeof setTimeout> | null = null;
5754
private hasSubscribedToRealTimeUpdates: boolean = false;
58-
private visibilityChangeHandler: () => void = () => {};
59-
private visibilityChangeListenerConnected: boolean = false;
6055

6156
// The raw store instance, used for binding in React and other environments
6257
public store: FeedStore;
@@ -117,13 +112,6 @@ class Feed {
117112

118113
this.socketManager?.leave(this);
119114

120-
this.tearDownVisibilityListeners();
121-
122-
if (this.disconnectTimer) {
123-
clearTimeout(this.disconnectTimer);
124-
this.disconnectTimer = null;
125-
}
126-
127115
if (this.broadcastChannel) {
128116
this.broadcastChannel.close();
129117
}
@@ -553,8 +541,6 @@ class Feed {
553541
__loadingType: undefined,
554542
__fetchSource: undefined,
555543
__experimentalCrossBrowserUpdates: undefined,
556-
auto_manage_socket_connection: undefined,
557-
auto_manage_socket_connection_delay: undefined,
558544
};
559545

560546
const result = await this.knock.client().makeRequest({
@@ -815,10 +801,6 @@ class Feed {
815801
// In server environments we might not have a socket connection
816802
if (!this.socketManager) return;
817803

818-
if (this.defaultOptions.auto_manage_socket_connection) {
819-
this.setUpVisibilityListeners();
820-
}
821-
822804
// If we're initializing but they have previously opted to listen to real-time updates
823805
// then we will automatically reconnect on their behalf
824806
if (this.hasSubscribedToRealTimeUpdates && this.knock.isAuthenticated()) {
@@ -838,33 +820,6 @@ class Feed {
838820
}
839821
}
840822

841-
/**
842-
* Listen for changes to document visibility and automatically disconnect
843-
* or reconnect the socket after a delay
844-
*/
845-
private setUpVisibilityListeners() {
846-
if (
847-
typeof document === "undefined" ||
848-
this.visibilityChangeListenerConnected
849-
) {
850-
return;
851-
}
852-
853-
this.visibilityChangeHandler = this.handleVisibilityChange.bind(this);
854-
this.visibilityChangeListenerConnected = true;
855-
document.addEventListener("visibilitychange", this.visibilityChangeHandler);
856-
}
857-
858-
private tearDownVisibilityListeners() {
859-
if (typeof document === "undefined") return;
860-
861-
document.removeEventListener(
862-
"visibilitychange",
863-
this.visibilityChangeHandler,
864-
);
865-
this.visibilityChangeListenerConnected = false;
866-
}
867-
868823
private emitEvent(
869824
type:
870825
| MessageEngagementStatus
@@ -882,34 +837,6 @@ class Feed {
882837
// Internal events only need `items:`
883838
this.broadcastOverChannel(`items:${type}`, { items });
884839
}
885-
886-
private handleVisibilityChange() {
887-
const disconnectDelay =
888-
this.defaultOptions.auto_manage_socket_connection_delay ??
889-
DEFAULT_DISCONNECT_DELAY;
890-
891-
const client = this.knock.client();
892-
893-
if (document.visibilityState === "hidden") {
894-
// When the tab is hidden, clean up the socket connection after a delay
895-
this.disconnectTimer = setTimeout(() => {
896-
client.socket?.disconnect();
897-
this.disconnectTimer = null;
898-
}, disconnectDelay);
899-
} else if (document.visibilityState === "visible") {
900-
// When the tab is visible, clear the disconnect timer if active to cancel disconnecting
901-
// This handles cases where the tab is only briefly hidden to avoid unnecessary disconnects
902-
if (this.disconnectTimer) {
903-
clearTimeout(this.disconnectTimer);
904-
this.disconnectTimer = null;
905-
}
906-
907-
// If the socket is not connected, try to reconnect
908-
if (!client.socket?.isConnected()) {
909-
client.socket?.connect();
910-
}
911-
}
912-
}
913840
}
914841

915842
export default Feed;

packages/client/src/clients/feed/interfaces.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,6 @@ export interface FeedClientOptions {
3434
trigger_data?: TriggerData;
3535
// Optionally enable cross browser feed updates for this feed
3636
__experimentalCrossBrowserUpdates?: boolean;
37-
// Optionally automatically manage socket connections on changes to tab visibility (defaults to `false`)
38-
auto_manage_socket_connection?: boolean;
39-
// Optionally set the delay amount in milliseconds when automatically disconnecting sockets from inactive tabs (defaults to `2000`)
40-
// Requires `auto_manage_socket_connection` to be `true`
41-
auto_manage_socket_connection_delay?: number;
4237
// Optionally scope notifications to a given date range
4338
inserted_at_date_range?: {
4439
// Optionally set the start date with a string in ISO 8601 format
@@ -88,8 +83,6 @@ export type FetchFeedOptionsForRequest = Omit<
8883
__loadingType: undefined;
8984
__fetchSource: undefined;
9085
__experimentalCrossBrowserUpdates: undefined;
91-
auto_manage_socket_connection: undefined;
92-
auto_manage_socket_connection_delay: undefined;
9386
};
9487

9588
export interface ContentBlockBase {

packages/client/src/interfaces.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export interface KnockOptions {
99
host?: string;
1010
logLevel?: LogLevel;
1111
branch?: string;
12+
/** Automatically disconnect the socket when the page is hidden and reconnect when visible. Defaults to `true`. */
13+
disconnectOnPageHidden?: boolean;
1214
}
1315

1416
export interface KnockObject<T = GenericData> {

packages/client/src/knock.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class Knock {
2626
public userToken?: string;
2727
public logLevel?: LogLevel;
2828
public readonly branch?: string;
29+
private readonly disconnectOnPageHidden?: boolean;
2930
private tokenExpirationTimer: ReturnType<typeof setTimeout> | null = null;
3031
readonly feeds = new FeedClient(this);
3132
readonly objects = new ObjectClient(this);
@@ -42,6 +43,7 @@ class Knock {
4243
this.host = options.host || DEFAULT_HOST;
4344
this.logLevel = options.logLevel;
4445
this.branch = options.branch || undefined;
46+
this.disconnectOnPageHidden = options.disconnectOnPageHidden;
4547

4648
this.log("Initialized Knock instance");
4749

@@ -170,9 +172,7 @@ class Knock {
170172
if (this.tokenExpirationTimer) {
171173
clearTimeout(this.tokenExpirationTimer);
172174
}
173-
if (this.apiClient?.socket && this.apiClient.socket.isConnected()) {
174-
this.apiClient.socket.disconnect();
175-
}
175+
this.apiClient?.teardown();
176176
}
177177

178178
log(message: string, force = false) {
@@ -190,6 +190,7 @@ class Knock {
190190
host: this.host,
191191
userToken: this.userToken,
192192
branch: this.branch,
193+
disconnectOnPageHidden: this.disconnectOnPageHidden,
193194
});
194195
}
195196

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { Socket } from "phoenix";
2+
3+
const DEFAULT_DISCONNECT_DELAY_MS = 30_000;
4+
5+
/**
6+
* Disconnects the socket after a delay when the page becomes hidden,
7+
* and reconnects when it becomes visible again. This avoids holding
8+
* open connections for background tabs that aren't being viewed.
9+
*
10+
* The delay prevents unnecessary disconnects during brief tab switches.
11+
* Phoenix channels automatically rejoin after reconnecting.
12+
*/
13+
export class PageVisibilityManager {
14+
private disconnectTimer: ReturnType<typeof setTimeout> | null = null;
15+
private wasConnected = false;
16+
17+
constructor(
18+
private socket: Socket,
19+
private disconnectDelayMs: number = DEFAULT_DISCONNECT_DELAY_MS,
20+
) {
21+
if (typeof document !== "undefined") {
22+
document.addEventListener("visibilitychange", this.onVisibilityChange);
23+
}
24+
}
25+
26+
private onVisibilityChange = () => {
27+
if (document.hidden) {
28+
this.scheduleDisconnect();
29+
} else {
30+
this.reconnect();
31+
}
32+
};
33+
34+
private scheduleDisconnect() {
35+
this.clearTimer();
36+
37+
this.disconnectTimer = setTimeout(() => {
38+
this.disconnectTimer = null;
39+
40+
if (this.socket.isConnected()) {
41+
this.wasConnected = true;
42+
this.socket.disconnect();
43+
}
44+
}, this.disconnectDelayMs);
45+
}
46+
47+
private reconnect() {
48+
this.clearTimer();
49+
50+
if (this.wasConnected) {
51+
this.wasConnected = false;
52+
this.socket.connect();
53+
}
54+
}
55+
56+
private clearTimer() {
57+
if (this.disconnectTimer) {
58+
clearTimeout(this.disconnectTimer);
59+
this.disconnectTimer = null;
60+
}
61+
}
62+
63+
teardown() {
64+
this.clearTimer();
65+
66+
if (typeof document !== "undefined") {
67+
document.removeEventListener("visibilitychange", this.onVisibilityChange);
68+
}
69+
}
70+
}

packages/client/test/api.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,41 @@ describe("API Client", () => {
131131
// Restore original window value
132132
(global as GlobalWithWindow).window = originalWindow;
133133
});
134+
135+
test("creates PageVisibilityManager by default in browser environment", () => {
136+
const originalWindow = (global as GlobalWithWindow).window;
137+
(global as GlobalWithWindow).window = {} as Window;
138+
139+
const apiClient = new ApiClient({
140+
host: "https://api.knock.app",
141+
apiKey: "pk_test_12345",
142+
userToken: undefined,
143+
});
144+
145+
expect(
146+
(apiClient as unknown as Record<string, unknown>)["pageVisibility"],
147+
).toBeDefined();
148+
149+
(global as GlobalWithWindow).window = originalWindow;
150+
});
151+
152+
test("skips PageVisibilityManager when disconnectOnPageHidden is false", () => {
153+
const originalWindow = (global as GlobalWithWindow).window;
154+
(global as GlobalWithWindow).window = {} as Window;
155+
156+
const apiClient = new ApiClient({
157+
host: "https://api.knock.app",
158+
apiKey: "pk_test_12345",
159+
userToken: undefined,
160+
disconnectOnPageHidden: false,
161+
});
162+
163+
expect(
164+
(apiClient as unknown as Record<string, unknown>)["pageVisibility"],
165+
).toBeUndefined();
166+
167+
(global as GlobalWithWindow).window = originalWindow;
168+
});
134169
});
135170

136171
describe("Request Handling", () => {

0 commit comments

Comments
 (0)