Skip to content

Commit c16eadd

Browse files
committed
Added the visibilityMonitor
1 parent 77bc5ef commit c16eadd

File tree

4 files changed

+218
-7
lines changed

4 files changed

+218
-7
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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+
/**
21+
* Base class to be used if you want to emit events. Call the constructor with
22+
* the set of allowed event names.
23+
*/
24+
export abstract class EventEmitter {
25+
private listeners_: {
26+
[eventType: string]: Array<{
27+
callback(...args: unknown[]): void;
28+
context: unknown;
29+
}>;
30+
} = {};
31+
32+
constructor(private allowedEvents_: string[]) {
33+
assert(
34+
Array.isArray(allowedEvents_) && allowedEvents_.length > 0,
35+
'Requires a non-empty array'
36+
);
37+
}
38+
39+
/**
40+
* To be overridden by derived classes in order to fire an initial event when
41+
* somebody subscribes for data.
42+
*
43+
* @returns {Array.<*>} Array of parameters to trigger initial event with.
44+
*/
45+
abstract getInitialEvent(eventType: string): unknown[];
46+
47+
/**
48+
* To be called by derived classes to trigger events.
49+
*/
50+
protected trigger(eventType: string, ...varArgs: unknown[]) {
51+
if (Array.isArray(this.listeners_[eventType])) {
52+
// Clone the list, since callbacks could add/remove listeners.
53+
const listeners = [...this.listeners_[eventType]];
54+
55+
for (let i = 0; i < listeners.length; i++) {
56+
listeners[i].callback.apply(listeners[i].context, varArgs);
57+
}
58+
}
59+
}
60+
61+
on(eventType: string, callback: (a: unknown) => void, context: unknown) {
62+
this.validateEventType_(eventType);
63+
this.listeners_[eventType] = this.listeners_[eventType] || [];
64+
this.listeners_[eventType].push({ callback, context });
65+
66+
const eventData = this.getInitialEvent(eventType);
67+
if (eventData) {
68+
callback.apply(context, eventData);
69+
}
70+
}
71+
72+
off(eventType: string, callback: (a: unknown) => void, context: unknown) {
73+
this.validateEventType_(eventType);
74+
const listeners = this.listeners_[eventType] || [];
75+
for (let i = 0; i < listeners.length; i++) {
76+
if (
77+
listeners[i].callback === callback &&
78+
(!context || context === listeners[i].context)
79+
) {
80+
listeners.splice(i, 1);
81+
return;
82+
}
83+
}
84+
}
85+
86+
private validateEventType_(eventType: string) {
87+
assert(
88+
this.allowedEvents_.find(et => {
89+
return et === eventType;
90+
}),
91+
'Unknown event: ' + eventType
92+
);
93+
}
94+
}
95+

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

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { calculateBackoffMillis, FirebaseError } from "@firebase/util";
2121
import { ERROR_FACTORY, ErrorCode } from "../errors";
2222
import { Storage } from "../storage/storage";
2323
import { isBefore } from 'date-fns';
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,7 @@ export class RealtimeHandler {
4041
) {
4142
this.httpRetriesRemaining = ORIGINAL_RETRIES;
4243
this.setRetriesRemaining();
44+
VisibilityMonitor.getInstance().on('visible', this.onVisibilityChange, this);
4345
}
4446

4547
private observers: Set<ConfigUpdateObserver> = new Set<ConfigUpdateObserver>();
@@ -48,6 +50,7 @@ export class RealtimeHandler {
4850
private controller?: AbortController;
4951
private reader: ReadableStreamDefaultReader | undefined;
5052
private httpRetriesRemaining: number = ORIGINAL_RETRIES;
53+
private isInBackground: boolean = false;
5154

5255
private async setRetriesRemaining() {
5356
// Retrieve number of remaining retries from last session. The minimum retry count being one.
@@ -101,9 +104,9 @@ export class RealtimeHandler {
101104
* and canceling the stream reader if they exist.
102105
*/
103106
private closeRealtimeHttpConnection(): void {
104-
if (this.controller) {
105-
this.controller.abort();
106-
this.controller = undefined;
107+
if (this.controller && !this.isInBackground) {
108+
this.controller.abort();
109+
this.controller = undefined;
107110
}
108111

109112
if (this.reader) {
@@ -244,15 +247,22 @@ export class RealtimeHandler {
244247
//await configAutoFetch.listenForNotifications();
245248
}
246249
} catch (error) {
250+
if (this.isInBackground) {
251+
// It's possible the app was backgrounded while the connection was open, which
252+
// threw an exception trying to read the response. No real error here, so treat
253+
// this as a success, even if we haven't read a 200 response code yet.
254+
this.resetRetryCount();
255+
} else {
247256
//there might have been a transient error so the client will retry the connection.
248257
console.error('Exception connecting to real-time RC backend. Retrying the connection...:', error);
258+
}
249259
} finally {
250260
// Close HTTP connection and associated streams.
251261
this.closeRealtimeHttpConnection();
252262
this.setIsHttpConnectionRunning(false);
253263

254264
// Update backoff metadata if the connection failed in the foreground.
255-
const connectionFailed = responseCode == null || this.isStatusCodeRetryable(responseCode);
265+
const connectionFailed = !this.isInBackground && (responseCode == null || this.isStatusCodeRetryable(responseCode));
256266

257267
if (connectionFailed) {
258268
await this.updateBackoffMetadataWithLastFailedStreamConnectionTime(new Date());
@@ -279,9 +289,10 @@ export class RealtimeHandler {
279289
const hasActiveListeners = this.observers.size > 0;
280290
const isNotDisabled = !this.isRealtimeDisabled;
281291
const isNoConnectionActive = !this.isConnectionActive;
282-
return hasActiveListeners && isNotDisabled && isNoConnectionActive;
292+
const inForeground = !this.isInBackground;
293+
return hasActiveListeners && isNotDisabled && isNoConnectionActive && inForeground;
283294
}
284-
295+
285296
private async makeRealtimeHttpConnection(delayMillis: number): Promise<void> {
286297
if (!this.canEstablishStreamConnection()) {
287298
return;
@@ -291,6 +302,9 @@ export class RealtimeHandler {
291302
setTimeout(async () => {
292303
await this.beginRealtimeHttpStream();
293304
}, delayMillis);
305+
} else if (!this.isInBackground) {
306+
const error = ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_STREAM_ERROR, { originalErrorMessage: 'Unable to connect to the server. Check your connection and try again.' });
307+
this.propagateError(error);
294308
}
295309
}
296310

@@ -308,4 +322,24 @@ export class RealtimeHandler {
308322
this.observers.add(observer);
309323
await this.beginRealtime();
310324
}
325+
326+
private abortRealtimeConnection(): void {
327+
if (this.controller) {
328+
this.controller.abort();
329+
this.controller = undefined;
330+
this.isConnectionActive = false;
331+
}
332+
}
333+
334+
private onVisibilityChange(visible: unknown) {
335+
const wasInBackground = this.isInBackground;
336+
this.isInBackground = !visible;
337+
if (wasInBackground !== this.isInBackground) {
338+
if (this.isInBackground) {
339+
this.abortRealtimeConnection();
340+
} else {
341+
this.beginRealtime();
342+
}
343+
}
344+
}
311345
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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+
export class VisibilityMonitor extends EventEmitter {
25+
private visible_: boolean;
26+
27+
static getInstance() {
28+
return new VisibilityMonitor();
29+
}
30+
31+
constructor() {
32+
super(['visible']);
33+
let hidden: string;
34+
let visibilityChange: string;
35+
if (
36+
typeof document !== 'undefined' &&
37+
typeof document.addEventListener !== 'undefined'
38+
) {
39+
if (typeof document['hidden'] !== 'undefined') {
40+
// Opera 12.10 and Firefox 18 and later support
41+
visibilityChange = 'visibilitychange';
42+
hidden = 'hidden';
43+
} else if (typeof document['mozHidden'] !== 'undefined') {
44+
visibilityChange = 'mozvisibilitychange';
45+
hidden = 'mozHidden';
46+
} else if (typeof document['msHidden'] !== 'undefined') {
47+
visibilityChange = 'msvisibilitychange';
48+
hidden = 'msHidden';
49+
} else if (typeof document['webkitHidden'] !== 'undefined') {
50+
visibilityChange = 'webkitvisibilitychange';
51+
hidden = 'webkitHidden';
52+
}
53+
}
54+
55+
// Initially, we always assume we are visible. This ensures that in browsers
56+
// without page visibility support or in cases where we are never visible
57+
// (e.g. chrome extension), we act as if we are visible, i.e. don't delay
58+
// reconnects
59+
this.visible_ = true;
60+
61+
if (visibilityChange) {
62+
document.addEventListener(
63+
visibilityChange,
64+
() => {
65+
const visible = !document[hidden];
66+
if (visible !== this.visible_) {
67+
this.visible_ = visible;
68+
this.trigger('visible', visible);
69+
}
70+
},
71+
false
72+
);
73+
}
74+
}
75+
76+
getInitialEvent(eventType: string): boolean[] {
77+
assert(eventType === 'visible', 'Unknown event type: ' + eventType);
78+
return [this.visible_];
79+
}
80+
}
81+

packages/remote-config/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"extends": "../../config/tsconfig.base.json",
33
"compilerOptions": {
44
"outDir": "dist",
5-
"resolveJsonModule": true
5+
"resolveJsonModule": true,
6+
"strict": false
67
},
78
"exclude": ["dist/**/*"]
89
}

0 commit comments

Comments
 (0)