Skip to content

Commit a3cbe71

Browse files
authored
Make initializeApp idempotent (#5207)
* Add deepEqual function * make initializeApp idempotent * make initializeApp in app-compat idempotent * Create fair-readers-drum.md * address comments
1 parent 4bc015c commit a3cbe71

File tree

9 files changed

+453
-15
lines changed

9 files changed

+453
-15
lines changed

.changeset/fair-readers-drum.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@firebase/util": minor
3+
---
4+
5+
Added deepEqual for comparing objects

packages-exp/app-compat/src/firebaseNamespaceCore.ts

+7
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ export function createFirebaseNamespaceCore(
109109

110110
/**
111111
* Create a new App instance (name must be unique).
112+
*
113+
* This function is idempotent. It can be called more than once and return the same instance using the same options and config.
112114
*/
113115
function initializeAppCompat(
114116
options: FirebaseOptions,
@@ -118,6 +120,11 @@ export function createFirebaseNamespaceCore(
118120
options,
119121
rawConfig
120122
) as _FirebaseAppExp;
123+
124+
if (contains(apps, app.name)) {
125+
return apps[app.name];
126+
}
127+
121128
const appCompat = new firebaseAppImpl(app, namespace);
122129
apps[app.name] = appCompat;
123130
return appCompat;

packages-exp/app-compat/test/firebaseAppCompat.test.ts

+52-6
Original file line numberDiff line numberDiff line change
@@ -346,15 +346,61 @@ function firebaseAppTests(
346346
expect(firebase.apps.length).to.eq(2);
347347
});
348348

349-
it('duplicate DEFAULT initialize is an error.', () => {
350-
firebase.initializeApp({});
351-
expect(() => firebase.initializeApp({})).throws(/\[DEFAULT\].*exists/i);
349+
it('initializeApp can be called more than once and returns the same instance if the options and config are the same', () => {
350+
const app = firebase.initializeApp(
351+
{
352+
apiKey: 'test1'
353+
},
354+
{ automaticDataCollectionEnabled: true }
355+
);
356+
expect(
357+
firebase.initializeApp(
358+
{
359+
apiKey: 'test1'
360+
},
361+
{ automaticDataCollectionEnabled: true }
362+
)
363+
).to.equal(app);
364+
});
365+
366+
it('duplicate DEFAULT initialize with different options is an error.', () => {
367+
firebase.initializeApp({ apiKey: 'key1' });
368+
expect(() => firebase.initializeApp({ apiKey: 'key2' })).throws(
369+
/\[DEFAULT\].*exists/i
370+
);
371+
});
372+
373+
it('duplicate named App initialize with different options is an error.', () => {
374+
firebase.initializeApp({ apiKey: 'key1', appId: 'id' }, 'abc');
375+
expect(() => firebase.initializeApp({ apiKey: 'key1' }, 'abc')).throws(
376+
/'abc'.*exists/i
377+
);
352378
});
353379

354-
it('duplicate named App initialize is an error.', () => {
355-
firebase.initializeApp({}, 'abc');
380+
it('duplicate DEFAULT initialize with different config is an error.', () => {
381+
firebase.initializeApp(
382+
{ apiKey: 'key1' },
383+
{ automaticDataCollectionEnabled: true }
384+
);
385+
expect(() =>
386+
firebase.initializeApp(
387+
{ apiKey: 'key1' },
388+
{ automaticDataCollectionEnabled: false }
389+
)
390+
).throws(/\[DEFAULT\].*exists/i);
391+
});
356392

357-
expect(() => firebase.initializeApp({}, 'abc')).throws(/'abc'.*exists/i);
393+
it('duplicate named App initialize with different config is an error.', () => {
394+
firebase.initializeApp(
395+
{ apiKey: 'key1' },
396+
{ name: 'abc', automaticDataCollectionEnabled: true }
397+
);
398+
expect(() =>
399+
firebase.initializeApp(
400+
{ apiKey: 'key1' },
401+
{ name: 'abc', automaticDataCollectionEnabled: false }
402+
)
403+
).throws(/'abc'.*exists/i);
358404
});
359405

360406
it('automaticDataCollectionEnabled is `false` by default', () => {

packages-exp/app-exp/src/api.test.ts

+76-6
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,85 @@ describe('API tests', () => {
7474
expect(app2.name).to.equal(appName);
7575
});
7676

77-
it('throws when creating duplicate DEDAULT Apps', () => {
78-
initializeApp({});
79-
expect(() => initializeApp({})).throws(/\[DEFAULT\].*exists/i);
77+
it('can be called more than once and returns the same instance if the options and config are the same', () => {
78+
const app = initializeApp(
79+
{
80+
apiKey: 'test1'
81+
},
82+
{ automaticDataCollectionEnabled: true }
83+
);
84+
expect(
85+
initializeApp(
86+
{
87+
apiKey: 'test1'
88+
},
89+
{ automaticDataCollectionEnabled: true }
90+
)
91+
).to.equal(app);
92+
});
93+
94+
it('throws when creating duplicate DEDAULT Apps with different options', () => {
95+
initializeApp({
96+
apiKey: 'test1'
97+
});
98+
expect(() =>
99+
initializeApp({
100+
apiKey: 'test2'
101+
})
102+
).throws(/\[DEFAULT\].*exists/i);
103+
});
104+
105+
it('throws when creating duplicate named Apps with different options', () => {
106+
const appName = 'MyApp';
107+
initializeApp(
108+
{
109+
apiKey: 'test1'
110+
},
111+
appName
112+
);
113+
expect(() =>
114+
initializeApp(
115+
{
116+
apiKey: 'test2'
117+
},
118+
appName
119+
)
120+
).throws(/'MyApp'.*exists/i);
121+
});
122+
123+
it('throws when creating duplicate DEDAULT Apps with different config values', () => {
124+
initializeApp(
125+
{
126+
apiKey: 'test1'
127+
},
128+
{ automaticDataCollectionEnabled: true }
129+
);
130+
expect(() =>
131+
initializeApp(
132+
{
133+
apiKey: 'test1'
134+
},
135+
{ automaticDataCollectionEnabled: false }
136+
)
137+
).throws(/\[DEFAULT\].*exists/i);
80138
});
81139

82-
it('throws when creating duplicate named Apps', () => {
140+
it('throws when creating duplicate named Apps with different config values', () => {
83141
const appName = 'MyApp';
84-
initializeApp({}, appName);
85-
expect(() => initializeApp({}, appName)).throws(/'MyApp'.*exists/i);
142+
initializeApp(
143+
{
144+
apiKey: 'test1'
145+
},
146+
{ name: appName, automaticDataCollectionEnabled: true }
147+
);
148+
expect(() =>
149+
initializeApp(
150+
{
151+
apiKey: 'test1'
152+
},
153+
{ name: appName, automaticDataCollectionEnabled: false }
154+
)
155+
).throws(/'MyApp'.*exists/i);
86156
});
87157

88158
it('takes an object as the second parameter to create named App', () => {

packages-exp/app-exp/src/api.ts

+12-2
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
LogOptions,
4040
setUserLogHandler
4141
} from '@firebase/logger';
42+
import { deepEqual } from '@firebase/util';
4243

4344
/**
4445
* The current SDK version.
@@ -129,8 +130,17 @@ export function initializeApp(
129130
});
130131
}
131132

132-
if (_apps.has(name)) {
133-
throw ERROR_FACTORY.create(AppError.DUPLICATE_APP, { appName: name });
133+
const existingApp = _apps.get(name) as FirebaseAppImpl;
134+
if (existingApp) {
135+
// return the existing app if options and config deep equal the ones in the existing app.
136+
if (
137+
deepEqual(options, existingApp.options) &&
138+
deepEqual(config, existingApp.config)
139+
) {
140+
return existingApp;
141+
} else {
142+
throw ERROR_FACTORY.create(AppError.DUPLICATE_APP, { appName: name });
143+
}
134144
}
135145

136146
const container = new ComponentContainer(name);

packages-exp/app-exp/src/errors.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ const ERRORS: ErrorMap<AppError> = {
3131
"No Firebase App '{$appName}' has been created - " +
3232
'call Firebase App.initializeApp()',
3333
[AppError.BAD_APP_NAME]: "Illegal App name: '{$appName}",
34-
[AppError.DUPLICATE_APP]: "Firebase App named '{$appName}' already exists",
34+
[AppError.DUPLICATE_APP]:
35+
"Firebase App named '{$appName}' already exists with different options or config",
3536
[AppError.APP_DELETED]: "Firebase App named '{$appName}' already deleted",
3637
[AppError.INVALID_APP_ARGUMENT]:
3738
'firebase.{$appName}() takes either no argument or a ' +

packages-exp/app-exp/src/firebaseApp.ts

+13
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ import { ERROR_FACTORY, AppError } from './errors';
3030
export class FirebaseAppImpl implements FirebaseApp {
3131
private readonly _options: FirebaseOptions;
3232
private readonly _name: string;
33+
/**
34+
* Original config values passed in as a constructor parameter.
35+
* It is only used to compare with another config object to support idempotent initializeApp().
36+
*
37+
* Updating automaticDataCollectionEnabled on the App instance will not change its value in _config.
38+
*/
39+
private readonly _config: Required<FirebaseAppSettings>;
3340
private _automaticDataCollectionEnabled: boolean;
3441
private _isDeleted = false;
3542
private readonly _container: ComponentContainer;
@@ -40,6 +47,7 @@ export class FirebaseAppImpl implements FirebaseApp {
4047
container: ComponentContainer
4148
) {
4249
this._options = { ...options };
50+
this._config = { ...config };
4351
this._name = config.name;
4452
this._automaticDataCollectionEnabled =
4553
config.automaticDataCollectionEnabled;
@@ -69,6 +77,11 @@ export class FirebaseAppImpl implements FirebaseApp {
6977
return this._options;
7078
}
7179

80+
get config(): Required<FirebaseAppSettings> {
81+
this.checkDestroyed();
82+
return this._config;
83+
}
84+
7285
get container(): ComponentContainer {
7386
return this._container;
7487
}

packages/util/src/obj.ts

+38
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,41 @@ export function map<K extends string, V, U>(
5252
}
5353
return res as { [key in K]: U };
5454
}
55+
56+
/**
57+
* Deep equal two objects. Support Arrays and Objects.
58+
*/
59+
export function deepEqual(a: object, b: object): boolean {
60+
if (a === b) {
61+
return true;
62+
}
63+
64+
const aKeys = Object.keys(a);
65+
const bKeys = Object.keys(b);
66+
for (const k of aKeys) {
67+
if (!bKeys.includes(k)) {
68+
return false;
69+
}
70+
71+
const aProp = (a as Record<string, unknown>)[k];
72+
const bProp = (b as Record<string, unknown>)[k];
73+
if (isObject(aProp) && isObject(bProp)) {
74+
if (!deepEqual(aProp, bProp)) {
75+
return false;
76+
}
77+
} else if (aProp !== bProp) {
78+
return false;
79+
}
80+
}
81+
82+
for (const k of bKeys) {
83+
if (!aKeys.includes(k)) {
84+
return false;
85+
}
86+
}
87+
return true;
88+
}
89+
90+
function isObject(thing: unknown): thing is object {
91+
return thing !== null && typeof thing === 'object';
92+
}

0 commit comments

Comments
 (0)