Skip to content

Manage HTTP connections based on tab visibility #9202

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: realtime-backoff
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions packages/remote-config/src/client/eventEmitter.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
}
50 changes: 43 additions & 7 deletions packages/remote-config/src/client/realtime_handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<ConfigUpdateObserver> =
Expand All @@ -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<void> {
// Retrieve number of remaining retries from last session. The minimum retry count being one.
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<void> {
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -343,4 +369,14 @@ export class RealtimeHandler {
this.observers.delete(observer);
}
}

private async onVisibilityChange(visible: unknown): Promise<void> {
this.isInBackground = !visible;
if (!visible && this.controller) {
this.controller.abort();
this.controller = undefined;
} else if (visible) {
await this.beginRealtime();
}
}
}
86 changes: 86 additions & 0 deletions packages/remote-config/src/client/visibility_monitor.ts
Original file line number Diff line number Diff line change
@@ -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_];
}
}
Loading