From c16eaddda656a26227b52073430a36d3c3c51521 Mon Sep 17 00:00:00 2001 From: Samruddhi Date: Sat, 9 Aug 2025 19:41:39 +0530 Subject: [PATCH 1/3] Added the visibilityMonitor --- .../remote-config/src/client/eventEmitter.ts | 95 +++++++++++++++++++ .../src/client/realtime_handler.ts | 46 +++++++-- .../src/client/visibility_monitor.ts | 81 ++++++++++++++++ packages/remote-config/tsconfig.json | 3 +- 4 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 packages/remote-config/src/client/eventEmitter.ts create mode 100644 packages/remote-config/src/client/visibility_monitor.ts diff --git a/packages/remote-config/src/client/eventEmitter.ts b/packages/remote-config/src/client/eventEmitter.ts new file mode 100644 index 00000000000..2fb3e6f920a --- /dev/null +++ b/packages/remote-config/src/client/eventEmitter.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assert } from '@firebase/util'; + +/** + * Base class to be used if you want to emit events. Call the constructor with + * the set of allowed event names. + */ +export abstract class EventEmitter { + private listeners_: { + [eventType: string]: Array<{ + callback(...args: unknown[]): void; + context: unknown; + }>; + } = {}; + + constructor(private allowedEvents_: string[]) { + assert( + Array.isArray(allowedEvents_) && allowedEvents_.length > 0, + 'Requires a non-empty array' + ); + } + + /** + * To be overridden by derived classes in order to fire an initial event when + * somebody subscribes for data. + * + * @returns {Array.<*>} Array of parameters to trigger initial event with. + */ + abstract getInitialEvent(eventType: string): unknown[]; + + /** + * To be called by derived classes to trigger events. + */ + protected trigger(eventType: string, ...varArgs: unknown[]) { + if (Array.isArray(this.listeners_[eventType])) { + // Clone the list, since callbacks could add/remove listeners. + const listeners = [...this.listeners_[eventType]]; + + for (let i = 0; i < listeners.length; i++) { + listeners[i].callback.apply(listeners[i].context, varArgs); + } + } + } + + on(eventType: string, callback: (a: unknown) => void, context: unknown) { + this.validateEventType_(eventType); + this.listeners_[eventType] = this.listeners_[eventType] || []; + this.listeners_[eventType].push({ callback, context }); + + const eventData = this.getInitialEvent(eventType); + if (eventData) { + callback.apply(context, eventData); + } + } + + off(eventType: string, callback: (a: unknown) => void, context: unknown) { + this.validateEventType_(eventType); + const listeners = this.listeners_[eventType] || []; + for (let i = 0; i < listeners.length; i++) { + if ( + listeners[i].callback === callback && + (!context || context === listeners[i].context) + ) { + listeners.splice(i, 1); + return; + } + } + } + + private validateEventType_(eventType: string) { + assert( + this.allowedEvents_.find(et => { + return et === eventType; + }), + 'Unknown event: ' + eventType + ); + } +} + diff --git a/packages/remote-config/src/client/realtime_handler.ts b/packages/remote-config/src/client/realtime_handler.ts index 31cf889e7dc..f51018c6a73 100644 --- a/packages/remote-config/src/client/realtime_handler.ts +++ b/packages/remote-config/src/client/realtime_handler.ts @@ -21,6 +21,7 @@ import { calculateBackoffMillis, FirebaseError } from "@firebase/util"; import { ERROR_FACTORY, ErrorCode } from "../errors"; import { Storage } from "../storage/storage"; import { isBefore } from 'date-fns'; +import { VisibilityMonitor } from './visibility_monitor'; const API_KEY_HEADER = 'X-Goog-Api-Key'; const INSTALLATIONS_AUTH_TOKEN_HEADER = 'X-Goog-Firebase-Installations-Auth'; @@ -40,6 +41,7 @@ export class RealtimeHandler { ) { this.httpRetriesRemaining = ORIGINAL_RETRIES; this.setRetriesRemaining(); + VisibilityMonitor.getInstance().on('visible', this.onVisibilityChange, this); } private observers: Set = new Set(); @@ -48,6 +50,7 @@ export class RealtimeHandler { private controller?: AbortController; private reader: ReadableStreamDefaultReader | undefined; private httpRetriesRemaining: number = ORIGINAL_RETRIES; + private isInBackground: boolean = false; private async setRetriesRemaining() { // Retrieve number of remaining retries from last session. The minimum retry count being one. @@ -101,9 +104,9 @@ export class RealtimeHandler { * and canceling the stream reader if they exist. */ private closeRealtimeHttpConnection(): void { - if (this.controller) { - this.controller.abort(); - this.controller = undefined; + if (this.controller && !this.isInBackground) { + this.controller.abort(); + this.controller = undefined; } if (this.reader) { @@ -244,15 +247,22 @@ export class RealtimeHandler { //await configAutoFetch.listenForNotifications(); } } catch (error) { + if (this.isInBackground) { + // It's possible the app was backgrounded while the connection was open, which + // threw an exception trying to read the response. No real error here, so treat + // this as a success, even if we haven't read a 200 response code yet. + this.resetRetryCount(); + } else { //there might have been a transient error so the client will retry the connection. console.error('Exception connecting to real-time RC backend. Retrying the connection...:', error); + } } finally { // Close HTTP connection and associated streams. this.closeRealtimeHttpConnection(); this.setIsHttpConnectionRunning(false); // Update backoff metadata if the connection failed in the foreground. - const connectionFailed = responseCode == null || this.isStatusCodeRetryable(responseCode); + const connectionFailed = !this.isInBackground && (responseCode == null || this.isStatusCodeRetryable(responseCode)); if (connectionFailed) { await this.updateBackoffMetadataWithLastFailedStreamConnectionTime(new Date()); @@ -279,9 +289,10 @@ export class RealtimeHandler { const hasActiveListeners = this.observers.size > 0; const isNotDisabled = !this.isRealtimeDisabled; const isNoConnectionActive = !this.isConnectionActive; - return hasActiveListeners && isNotDisabled && isNoConnectionActive; + const inForeground = !this.isInBackground; + return hasActiveListeners && isNotDisabled && isNoConnectionActive && inForeground; } - + private async makeRealtimeHttpConnection(delayMillis: number): Promise { if (!this.canEstablishStreamConnection()) { return; @@ -291,6 +302,9 @@ export class RealtimeHandler { setTimeout(async () => { await this.beginRealtimeHttpStream(); }, delayMillis); + } else if (!this.isInBackground) { + const error = ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_STREAM_ERROR, { originalErrorMessage: 'Unable to connect to the server. Check your connection and try again.' }); + this.propagateError(error); } } @@ -308,4 +322,24 @@ export class RealtimeHandler { this.observers.add(observer); await this.beginRealtime(); } + + private abortRealtimeConnection(): void { + if (this.controller) { + this.controller.abort(); + this.controller = undefined; + this.isConnectionActive = false; + } + } + + private onVisibilityChange(visible: unknown) { + const wasInBackground = this.isInBackground; + this.isInBackground = !visible; + if (wasInBackground !== this.isInBackground) { + if (this.isInBackground) { + this.abortRealtimeConnection(); + } else { + this.beginRealtime(); + } + } + } } diff --git a/packages/remote-config/src/client/visibility_monitor.ts b/packages/remote-config/src/client/visibility_monitor.ts new file mode 100644 index 00000000000..6c1ec6c1d9e --- /dev/null +++ b/packages/remote-config/src/client/visibility_monitor.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assert } from '@firebase/util'; + +import { EventEmitter } from './eventEmitter'; + +declare const document: Document; + +export class VisibilityMonitor extends EventEmitter { + private visible_: boolean; + + static getInstance() { + return new VisibilityMonitor(); + } + + constructor() { + super(['visible']); + let hidden: string; + let visibilityChange: string; + if ( + typeof document !== 'undefined' && + typeof document.addEventListener !== 'undefined' + ) { + if (typeof document['hidden'] !== 'undefined') { + // Opera 12.10 and Firefox 18 and later support + visibilityChange = 'visibilitychange'; + hidden = 'hidden'; + } else if (typeof document['mozHidden'] !== 'undefined') { + visibilityChange = 'mozvisibilitychange'; + hidden = 'mozHidden'; + } else if (typeof document['msHidden'] !== 'undefined') { + visibilityChange = 'msvisibilitychange'; + hidden = 'msHidden'; + } else if (typeof document['webkitHidden'] !== 'undefined') { + visibilityChange = 'webkitvisibilitychange'; + hidden = 'webkitHidden'; + } + } + + // Initially, we always assume we are visible. This ensures that in browsers + // without page visibility support or in cases where we are never visible + // (e.g. chrome extension), we act as if we are visible, i.e. don't delay + // reconnects + this.visible_ = true; + + if (visibilityChange) { + document.addEventListener( + visibilityChange, + () => { + const visible = !document[hidden]; + if (visible !== this.visible_) { + this.visible_ = visible; + this.trigger('visible', visible); + } + }, + false + ); + } + } + + getInitialEvent(eventType: string): boolean[] { + assert(eventType === 'visible', 'Unknown event type: ' + eventType); + return [this.visible_]; + } +} + diff --git a/packages/remote-config/tsconfig.json b/packages/remote-config/tsconfig.json index f2942111423..22551f5f1b1 100644 --- a/packages/remote-config/tsconfig.json +++ b/packages/remote-config/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../config/tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "resolveJsonModule": true + "resolveJsonModule": true, + "strict": false }, "exclude": ["dist/**/*"] } From 4002ed9dea9438d1346be22883dfe2009cba8cce Mon Sep 17 00:00:00 2001 From: Samruddhi Date: Mon, 11 Aug 2025 18:22:00 +0530 Subject: [PATCH 2/3] minor changes --- .../remote-config/src/client/eventEmitter.ts | 3 ++- .../src/client/realtime_handler.ts | 22 +++++-------------- .../src/client/visibility_monitor.ts | 13 +++++++---- packages/remote-config/tsconfig.json | 3 +-- 4 files changed, 18 insertions(+), 23 deletions(-) diff --git a/packages/remote-config/src/client/eventEmitter.ts b/packages/remote-config/src/client/eventEmitter.ts index 2fb3e6f920a..5171c59931d 100644 --- a/packages/remote-config/src/client/eventEmitter.ts +++ b/packages/remote-config/src/client/eventEmitter.ts @@ -17,6 +17,7 @@ import { assert } from '@firebase/util'; +// 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. /** * Base class to be used if you want to emit events. Call the constructor with * the set of allowed event names. @@ -65,6 +66,7 @@ export abstract class EventEmitter { const eventData = this.getInitialEvent(eventType); if (eventData) { + //@ts-ignore callback.apply(context, eventData); } } @@ -92,4 +94,3 @@ export abstract class EventEmitter { ); } } - diff --git a/packages/remote-config/src/client/realtime_handler.ts b/packages/remote-config/src/client/realtime_handler.ts index f51018c6a73..a0bf4e84cd8 100644 --- a/packages/remote-config/src/client/realtime_handler.ts +++ b/packages/remote-config/src/client/realtime_handler.ts @@ -323,23 +323,13 @@ export class RealtimeHandler { await this.beginRealtime(); } - private abortRealtimeConnection(): void { - if (this.controller) { - this.controller.abort(); - this.controller = undefined; - this.isConnectionActive = false; - } - } - - private onVisibilityChange(visible: unknown) { - const wasInBackground = this.isInBackground; + private async onVisibilityChange(visible: unknown) { this.isInBackground = !visible; - if (wasInBackground !== this.isInBackground) { - if (this.isInBackground) { - this.abortRealtimeConnection(); - } else { - this.beginRealtime(); - } + if (!visible && this.controller) { + this.controller.abort(); + this.controller = undefined; + } else if(visible) { + await this.beginRealtime(); } } } diff --git a/packages/remote-config/src/client/visibility_monitor.ts b/packages/remote-config/src/client/visibility_monitor.ts index 6c1ec6c1d9e..71f687744ea 100644 --- a/packages/remote-config/src/client/visibility_monitor.ts +++ b/packages/remote-config/src/client/visibility_monitor.ts @@ -21,6 +21,7 @@ import { EventEmitter } from './eventEmitter'; declare const document: Document; +// 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. export class VisibilityMonitor extends EventEmitter { private visible_: boolean; @@ -40,13 +41,16 @@ export class VisibilityMonitor extends EventEmitter { // Opera 12.10 and Firefox 18 and later support visibilityChange = 'visibilitychange'; hidden = 'hidden'; - } else if (typeof document['mozHidden'] !== 'undefined') { + } //@ts-ignore + else if (typeof document['mozHidden'] !== 'undefined') { visibilityChange = 'mozvisibilitychange'; hidden = 'mozHidden'; - } else if (typeof document['msHidden'] !== 'undefined') { + } //@ts-ignore + else if (typeof document['msHidden'] !== 'undefined') { visibilityChange = 'msvisibilitychange'; hidden = 'msHidden'; - } else if (typeof document['webkitHidden'] !== 'undefined') { + } //@ts-ignore + else if (typeof document['webkitHidden'] !== 'undefined') { visibilityChange = 'webkitvisibilitychange'; hidden = 'webkitHidden'; } @@ -58,10 +62,12 @@ export class VisibilityMonitor extends EventEmitter { // reconnects this.visible_ = true; + //@ts-ignore if (visibilityChange) { document.addEventListener( visibilityChange, () => { + //@ts-ignore const visible = !document[hidden]; if (visible !== this.visible_) { this.visible_ = visible; @@ -78,4 +84,3 @@ export class VisibilityMonitor extends EventEmitter { return [this.visible_]; } } - diff --git a/packages/remote-config/tsconfig.json b/packages/remote-config/tsconfig.json index 22551f5f1b1..f2942111423 100644 --- a/packages/remote-config/tsconfig.json +++ b/packages/remote-config/tsconfig.json @@ -2,8 +2,7 @@ "extends": "../../config/tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "resolveJsonModule": true, - "strict": false + "resolveJsonModule": true }, "exclude": ["dist/**/*"] } From 61054126795078d5dca52d72e3d84f53fd76922d Mon Sep 17 00:00:00 2001 From: Samruddhi Date: Tue, 12 Aug 2025 12:24:24 +0530 Subject: [PATCH 3/3] resolving the spacing problem --- .../remote-config/src/client/eventEmitter.ts | 16 ++++++-- .../src/client/realtime_handler.ts | 38 ++++++++++++------- .../src/client/visibility_monitor.ts | 12 +++--- 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/packages/remote-config/src/client/eventEmitter.ts b/packages/remote-config/src/client/eventEmitter.ts index 5171c59931d..10e2201ba2b 100644 --- a/packages/remote-config/src/client/eventEmitter.ts +++ b/packages/remote-config/src/client/eventEmitter.ts @@ -48,7 +48,7 @@ export abstract class EventEmitter { /** * To be called by derived classes to trigger events. */ - protected trigger(eventType: string, ...varArgs: unknown[]) { + protected trigger(eventType: string, ...varArgs: unknown[]): void { if (Array.isArray(this.listeners_[eventType])) { // Clone the list, since callbacks could add/remove listeners. const listeners = [...this.listeners_[eventType]]; @@ -59,7 +59,11 @@ export abstract class EventEmitter { } } - on(eventType: string, callback: (a: unknown) => void, context: unknown) { + on( + eventType: string, + callback: (a: unknown) => void, + context: unknown + ): void { this.validateEventType_(eventType); this.listeners_[eventType] = this.listeners_[eventType] || []; this.listeners_[eventType].push({ callback, context }); @@ -71,7 +75,11 @@ export abstract class EventEmitter { } } - off(eventType: string, callback: (a: unknown) => void, context: unknown) { + off( + eventType: string, + callback: (a: unknown) => void, + context: unknown + ): void { this.validateEventType_(eventType); const listeners = this.listeners_[eventType] || []; for (let i = 0; i < listeners.length; i++) { @@ -85,7 +93,7 @@ export abstract class EventEmitter { } } - private validateEventType_(eventType: string) { + private validateEventType_(eventType: string): void { assert( this.allowedEvents_.find(et => { return et === eventType; diff --git a/packages/remote-config/src/client/realtime_handler.ts b/packages/remote-config/src/client/realtime_handler.ts index e431ac0fa10..d2b75db3cfd 100644 --- a/packages/remote-config/src/client/realtime_handler.ts +++ b/packages/remote-config/src/client/realtime_handler.ts @@ -41,7 +41,11 @@ export class RealtimeHandler { private readonly logger: Logger ) { void this.setRetriesRemaining(); - VisibilityMonitor.getInstance().on('visible', this.onVisibilityChange, this); + void VisibilityMonitor.getInstance().on( + 'visible', + this.onVisibilityChange, + this + ); } private observers: Set = @@ -106,8 +110,8 @@ export class RealtimeHandler { */ private closeRealtimeHttpConnection(): void { if (this.controller && !this.isInBackground) { - this.controller.abort(); - this.controller = undefined; + this.controller.abort(); + this.controller = undefined; } if (this.reader) { @@ -269,11 +273,11 @@ export class RealtimeHandler { // this as a success, even if we haven't read a 200 response code yet. this.resetRetryCount(); } else { - //there might have been a transient error so the client will retry the connection. - this.logger.error( - 'Exception connecting to real-time RC backend. Retrying the connection...:', - error - ); + //there might have been a transient error so the client will retry the connection. + this.logger.debug( + 'Exception connecting to real-time RC backend. Retrying the connection...:', + error + ); } } finally { // Close HTTP connection and associated streams. @@ -315,9 +319,14 @@ export class RealtimeHandler { const isNotDisabled = !this.isRealtimeDisabled; const isNoConnectionActive = !this.isConnectionActive; const inForeground = !this.isInBackground; - return hasActiveListeners && isNotDisabled && isNoConnectionActive && inForeground; + return ( + hasActiveListeners && + isNotDisabled && + isNoConnectionActive && + inForeground + ); } - + private async makeRealtimeHttpConnection(delayMillis: number): Promise { if (!this.canEstablishStreamConnection()) { return; @@ -328,7 +337,10 @@ export class RealtimeHandler { await this.beginRealtimeHttpStream(); }, delayMillis); } else if (!this.isInBackground) { - const error = ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_STREAM_ERROR, { originalErrorMessage: 'Unable to connect to the server. Check your connection and try again.' }); + const error = ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_STREAM_ERROR, { + originalErrorMessage: + 'Unable to connect to the server. Check your connection and try again.' + }); this.propagateError(error); } } @@ -358,12 +370,12 @@ export class RealtimeHandler { } } - private async onVisibilityChange(visible: unknown) { + private async onVisibilityChange(visible: unknown): Promise { this.isInBackground = !visible; if (!visible && this.controller) { this.controller.abort(); this.controller = undefined; - } else if(visible) { + } else if (visible) { await this.beginRealtime(); } } diff --git a/packages/remote-config/src/client/visibility_monitor.ts b/packages/remote-config/src/client/visibility_monitor.ts index 71f687744ea..27028e3eeca 100644 --- a/packages/remote-config/src/client/visibility_monitor.ts +++ b/packages/remote-config/src/client/visibility_monitor.ts @@ -25,7 +25,7 @@ declare const document: Document; export class VisibilityMonitor extends EventEmitter { private visible_: boolean; - static getInstance() { + static getInstance(): VisibilityMonitor { return new VisibilityMonitor(); } @@ -41,15 +41,15 @@ export class VisibilityMonitor extends EventEmitter { // Opera 12.10 and Firefox 18 and later support visibilityChange = 'visibilitychange'; hidden = 'hidden'; - } //@ts-ignore + } // @ts-ignore else if (typeof document['mozHidden'] !== 'undefined') { visibilityChange = 'mozvisibilitychange'; hidden = 'mozHidden'; - } //@ts-ignore + } // @ts-ignore else if (typeof document['msHidden'] !== 'undefined') { visibilityChange = 'msvisibilitychange'; hidden = 'msHidden'; - } //@ts-ignore + } // @ts-ignore else if (typeof document['webkitHidden'] !== 'undefined') { visibilityChange = 'webkitvisibilitychange'; hidden = 'webkitHidden'; @@ -62,12 +62,12 @@ export class VisibilityMonitor extends EventEmitter { // reconnects this.visible_ = true; - //@ts-ignore + // @ts-ignore if (visibilityChange) { document.addEventListener( visibilityChange, () => { - //@ts-ignore + // @ts-ignore const visible = !document[hidden]; if (visible !== this.visible_) { this.visible_ = visible;