diff --git a/packages/remote-config/src/client/eventEmitter.ts b/packages/remote-config/src/client/eventEmitter.ts new file mode 100644 index 00000000000..10e2201ba2b --- /dev/null +++ b/packages/remote-config/src/client/eventEmitter.ts @@ -0,0 +1,104 @@ +/** + * @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'; + +// 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. + */ +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[]): void { + 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 + ): void { + this.validateEventType_(eventType); + this.listeners_[eventType] = this.listeners_[eventType] || []; + this.listeners_[eventType].push({ callback, context }); + + const eventData = this.getInitialEvent(eventType); + if (eventData) { + //@ts-ignore + callback.apply(context, eventData); + } + } + + 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++) { + if ( + listeners[i].callback === callback && + (!context || context === listeners[i].context) + ) { + listeners.splice(i, 1); + return; + } + } + } + + private validateEventType_(eventType: string): void { + 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 e599e841bb0..8f8f7311d5e 100644 --- a/packages/remote-config/src/client/realtime_handler.ts +++ b/packages/remote-config/src/client/realtime_handler.ts @@ -21,6 +21,7 @@ import { ConfigUpdateObserver } from '../public_types'; import { calculateBackoffMillis, FirebaseError } from '@firebase/util'; import { ERROR_FACTORY, ErrorCode } from '../errors'; import { Storage } from '../storage/storage'; +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,11 @@ export class RealtimeHandler { private readonly logger: Logger ) { void this.setRetriesRemaining(); + void VisibilityMonitor.getInstance().on( + 'visible', + this.onVisibilityChange, + this + ); } private observers: Set = @@ -49,6 +55,7 @@ export class RealtimeHandler { private controller?: AbortController; private reader: ReadableStreamDefaultReader | undefined; private httpRetriesRemaining: number = ORIGINAL_RETRIES; + private isInBackground: boolean = false; private async setRetriesRemaining(): Promise { // Retrieve number of remaining retries from last session. The minimum retry count being one. @@ -102,7 +109,7 @@ export class RealtimeHandler { * and canceling the stream reader if they exist. */ private closeRealtimeHttpConnection(): void { - if (this.controller) { + if (this.controller && !this.isInBackground) { this.controller.abort(); this.controller = undefined; } @@ -260,11 +267,18 @@ export class RealtimeHandler { //await configAutoFetch.listenForNotifications(); } } catch (error) { - //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 - ); + 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. + this.logger.debug( + 'Exception connecting to real-time RC backend. Retrying the connection...:', + error + ); + } } finally { // Close HTTP connection and associated streams. this.closeRealtimeHttpConnection(); @@ -304,7 +318,13 @@ 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 { @@ -316,6 +336,12 @@ 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); } } @@ -343,4 +369,14 @@ export class RealtimeHandler { this.observers.delete(observer); } } + + private async onVisibilityChange(visible: unknown): Promise { + this.isInBackground = !visible; + 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 new file mode 100644 index 00000000000..27028e3eeca --- /dev/null +++ b/packages/remote-config/src/client/visibility_monitor.ts @@ -0,0 +1,86 @@ +/** + * @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; + +// 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; + + static getInstance(): VisibilityMonitor { + 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'; + } // @ts-ignore + else if (typeof document['mozHidden'] !== 'undefined') { + visibilityChange = 'mozvisibilitychange'; + hidden = 'mozHidden'; + } // @ts-ignore + else if (typeof document['msHidden'] !== 'undefined') { + visibilityChange = 'msvisibilitychange'; + hidden = 'msHidden'; + } // @ts-ignore + 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; + + // @ts-ignore + if (visibilityChange) { + document.addEventListener( + visibilityChange, + () => { + // @ts-ignore + 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_]; + } +}