Skip to content

Commit 8f92245

Browse files
authored
Manage HTTP connections based on tab visibility (#9202)
* Added the visibilityMonitor * minor changes * resolving the spacing problem
1 parent 61ba815 commit 8f92245

File tree

3 files changed

+233
-7
lines changed

3 files changed

+233
-7
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { assert } from '@firebase/util';
19+
20+
// TODO: Consolidate the Visibility monitoring API code into a shared utility function in firebase/util to be used by both packages/database and packages/remote-config.
21+
/**
22+
* Base class to be used if you want to emit events. Call the constructor with
23+
* the set of allowed event names.
24+
*/
25+
export abstract class EventEmitter {
26+
private listeners_: {
27+
[eventType: string]: Array<{
28+
callback(...args: unknown[]): void;
29+
context: unknown;
30+
}>;
31+
} = {};
32+
33+
constructor(private allowedEvents_: string[]) {
34+
assert(
35+
Array.isArray(allowedEvents_) && allowedEvents_.length > 0,
36+
'Requires a non-empty array'
37+
);
38+
}
39+
40+
/**
41+
* To be overridden by derived classes in order to fire an initial event when
42+
* somebody subscribes for data.
43+
*
44+
* @returns {Array.<*>} Array of parameters to trigger initial event with.
45+
*/
46+
abstract getInitialEvent(eventType: string): unknown[];
47+
48+
/**
49+
* To be called by derived classes to trigger events.
50+
*/
51+
protected trigger(eventType: string, ...varArgs: unknown[]): void {
52+
if (Array.isArray(this.listeners_[eventType])) {
53+
// Clone the list, since callbacks could add/remove listeners.
54+
const listeners = [...this.listeners_[eventType]];
55+
56+
for (let i = 0; i < listeners.length; i++) {
57+
listeners[i].callback.apply(listeners[i].context, varArgs);
58+
}
59+
}
60+
}
61+
62+
on(
63+
eventType: string,
64+
callback: (a: unknown) => void,
65+
context: unknown
66+
): void {
67+
this.validateEventType_(eventType);
68+
this.listeners_[eventType] = this.listeners_[eventType] || [];
69+
this.listeners_[eventType].push({ callback, context });
70+
71+
const eventData = this.getInitialEvent(eventType);
72+
if (eventData) {
73+
//@ts-ignore
74+
callback.apply(context, eventData);
75+
}
76+
}
77+
78+
off(
79+
eventType: string,
80+
callback: (a: unknown) => void,
81+
context: unknown
82+
): void {
83+
this.validateEventType_(eventType);
84+
const listeners = this.listeners_[eventType] || [];
85+
for (let i = 0; i < listeners.length; i++) {
86+
if (
87+
listeners[i].callback === callback &&
88+
(!context || context === listeners[i].context)
89+
) {
90+
listeners.splice(i, 1);
91+
return;
92+
}
93+
}
94+
}
95+
96+
private validateEventType_(eventType: string): void {
97+
assert(
98+
this.allowedEvents_.find(et => {
99+
return et === eventType;
100+
}),
101+
'Unknown event: ' + eventType
102+
);
103+
}
104+
}

packages/remote-config/src/client/realtime_handler.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ConfigUpdateObserver } from '../public_types';
2121
import { calculateBackoffMillis, FirebaseError } from '@firebase/util';
2222
import { ERROR_FACTORY, ErrorCode } from '../errors';
2323
import { Storage } from '../storage/storage';
24+
import { VisibilityMonitor } from './visibility_monitor';
2425

2526
const API_KEY_HEADER = 'X-Goog-Api-Key';
2627
const INSTALLATIONS_AUTH_TOKEN_HEADER = 'X-Goog-Firebase-Installations-Auth';
@@ -40,6 +41,11 @@ export class RealtimeHandler {
4041
private readonly logger: Logger
4142
) {
4243
void this.setRetriesRemaining();
44+
void VisibilityMonitor.getInstance().on(
45+
'visible',
46+
this.onVisibilityChange,
47+
this
48+
);
4349
}
4450

4551
private observers: Set<ConfigUpdateObserver> =
@@ -49,6 +55,7 @@ export class RealtimeHandler {
4955
private controller?: AbortController;
5056
private reader: ReadableStreamDefaultReader | undefined;
5157
private httpRetriesRemaining: number = ORIGINAL_RETRIES;
58+
private isInBackground: boolean = false;
5259

5360
private async setRetriesRemaining(): Promise<void> {
5461
// Retrieve number of remaining retries from last session. The minimum retry count being one.
@@ -102,7 +109,7 @@ export class RealtimeHandler {
102109
* and canceling the stream reader if they exist.
103110
*/
104111
private closeRealtimeHttpConnection(): void {
105-
if (this.controller) {
112+
if (this.controller && !this.isInBackground) {
106113
this.controller.abort();
107114
this.controller = undefined;
108115
}
@@ -260,11 +267,18 @@ export class RealtimeHandler {
260267
//await configAutoFetch.listenForNotifications();
261268
}
262269
} catch (error) {
263-
//there might have been a transient error so the client will retry the connection.
264-
this.logger.error(
265-
'Exception connecting to real-time RC backend. Retrying the connection...:',
266-
error
267-
);
270+
if (this.isInBackground) {
271+
// It's possible the app was backgrounded while the connection was open, which
272+
// threw an exception trying to read the response. No real error here, so treat
273+
// this as a success, even if we haven't read a 200 response code yet.
274+
this.resetRetryCount();
275+
} else {
276+
//there might have been a transient error so the client will retry the connection.
277+
this.logger.debug(
278+
'Exception connecting to real-time RC backend. Retrying the connection...:',
279+
error
280+
);
281+
}
268282
} finally {
269283
// Close HTTP connection and associated streams.
270284
this.closeRealtimeHttpConnection();
@@ -304,7 +318,13 @@ export class RealtimeHandler {
304318
const hasActiveListeners = this.observers.size > 0;
305319
const isNotDisabled = !this.isRealtimeDisabled;
306320
const isNoConnectionActive = !this.isConnectionActive;
307-
return hasActiveListeners && isNotDisabled && isNoConnectionActive;
321+
const inForeground = !this.isInBackground;
322+
return (
323+
hasActiveListeners &&
324+
isNotDisabled &&
325+
isNoConnectionActive &&
326+
inForeground
327+
);
308328
}
309329

310330
private async makeRealtimeHttpConnection(delayMillis: number): Promise<void> {
@@ -316,6 +336,12 @@ export class RealtimeHandler {
316336
setTimeout(async () => {
317337
await this.beginRealtimeHttpStream();
318338
}, delayMillis);
339+
} else if (!this.isInBackground) {
340+
const error = ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_STREAM_ERROR, {
341+
originalErrorMessage:
342+
'Unable to connect to the server. Check your connection and try again.'
343+
});
344+
this.propagateError(error);
319345
}
320346
}
321347

@@ -343,4 +369,14 @@ export class RealtimeHandler {
343369
this.observers.delete(observer);
344370
}
345371
}
372+
373+
private async onVisibilityChange(visible: unknown): Promise<void> {
374+
this.isInBackground = !visible;
375+
if (!visible && this.controller) {
376+
this.controller.abort();
377+
this.controller = undefined;
378+
} else if (visible) {
379+
await this.beginRealtime();
380+
}
381+
}
346382
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { assert } from '@firebase/util';
19+
20+
import { EventEmitter } from './eventEmitter';
21+
22+
declare const document: Document;
23+
24+
// TODO: Consolidate the Visibility monitoring API code into a shared utility function in firebase/util to be used by both packages/database and packages/remote-config.
25+
export class VisibilityMonitor extends EventEmitter {
26+
private visible_: boolean;
27+
28+
static getInstance(): VisibilityMonitor {
29+
return new VisibilityMonitor();
30+
}
31+
32+
constructor() {
33+
super(['visible']);
34+
let hidden: string;
35+
let visibilityChange: string;
36+
if (
37+
typeof document !== 'undefined' &&
38+
typeof document.addEventListener !== 'undefined'
39+
) {
40+
if (typeof document['hidden'] !== 'undefined') {
41+
// Opera 12.10 and Firefox 18 and later support
42+
visibilityChange = 'visibilitychange';
43+
hidden = 'hidden';
44+
} // @ts-ignore
45+
else if (typeof document['mozHidden'] !== 'undefined') {
46+
visibilityChange = 'mozvisibilitychange';
47+
hidden = 'mozHidden';
48+
} // @ts-ignore
49+
else if (typeof document['msHidden'] !== 'undefined') {
50+
visibilityChange = 'msvisibilitychange';
51+
hidden = 'msHidden';
52+
} // @ts-ignore
53+
else if (typeof document['webkitHidden'] !== 'undefined') {
54+
visibilityChange = 'webkitvisibilitychange';
55+
hidden = 'webkitHidden';
56+
}
57+
}
58+
59+
// Initially, we always assume we are visible. This ensures that in browsers
60+
// without page visibility support or in cases where we are never visible
61+
// (e.g. chrome extension), we act as if we are visible, i.e. don't delay
62+
// reconnects
63+
this.visible_ = true;
64+
65+
// @ts-ignore
66+
if (visibilityChange) {
67+
document.addEventListener(
68+
visibilityChange,
69+
() => {
70+
// @ts-ignore
71+
const visible = !document[hidden];
72+
if (visible !== this.visible_) {
73+
this.visible_ = visible;
74+
this.trigger('visible', visible);
75+
}
76+
},
77+
false
78+
);
79+
}
80+
}
81+
82+
getInitialEvent(eventType: string): boolean[] {
83+
assert(eventType === 'visible', 'Unknown event type: ' + eventType);
84+
return [this.visible_];
85+
}
86+
}

0 commit comments

Comments
 (0)