Skip to content

Commit bb6b5ab

Browse files
authored
Make initialize methods for each product idempotent (#5272)
1 parent bfeac26 commit bb6b5ab

File tree

24 files changed

+414
-90
lines changed

24 files changed

+414
-90
lines changed

.changeset/fast-buses-scream.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@firebase/component': patch
3+
---
4+
5+
Store instance initialization options on the Provider.

common/api-review/app-check-exp.api.md

+4
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export class CustomProvider implements AppCheckProvider {
5151
getToken(): Promise<AppCheckTokenInternal>;
5252
// @internal (undocumented)
5353
initialize(app: FirebaseApp): void;
54+
// @internal (undocumented)
55+
isEqual(otherProvider: unknown): boolean;
5456
}
5557

5658
// @public
@@ -79,6 +81,8 @@ export class ReCaptchaV3Provider implements AppCheckProvider {
7981
getToken(): Promise<AppCheckTokenInternal>;
8082
// @internal (undocumented)
8183
initialize(app: FirebaseApp): void;
84+
// @internal (undocumented)
85+
isEqual(otherProvider: unknown): boolean;
8286
}
8387

8488
// @public
+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* @license
3+
* Copyright 2019 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 { expect } from 'chai';
19+
import { SinonStub, stub } from 'sinon';
20+
import '../testing/setup';
21+
import { getFullApp } from '../testing/get-fake-firebase-services';
22+
import { getAnalytics, initializeAnalytics } from './api';
23+
import { FirebaseApp, deleteApp } from '@firebase/app-exp';
24+
import { AnalyticsError } from './errors';
25+
import * as init from './initialize-analytics';
26+
const fakeAppParams = { appId: 'abcdefgh12345:23405', apiKey: 'AAbbCCdd12345' };
27+
28+
describe('FirebaseAnalytics API tests', () => {
29+
let initStub: SinonStub = stub();
30+
let app: FirebaseApp;
31+
32+
beforeEach(() => {
33+
initStub = stub(init, '_initializeAnalytics').resolves(
34+
'FAKE_MEASUREMENT_ID'
35+
);
36+
});
37+
38+
afterEach(async () => {
39+
await initStub();
40+
initStub.restore();
41+
if (app) {
42+
return deleteApp(app);
43+
}
44+
});
45+
46+
after(() => {
47+
delete window['gtag'];
48+
delete window['dataLayer'];
49+
});
50+
51+
it('initializeAnalytics() with same (no) options returns same instance', () => {
52+
app = getFullApp(fakeAppParams);
53+
const analyticsInstance = initializeAnalytics(app);
54+
const newInstance = initializeAnalytics(app);
55+
expect(analyticsInstance).to.equal(newInstance);
56+
});
57+
it('initializeAnalytics() with same options returns same instance', () => {
58+
app = getFullApp(fakeAppParams);
59+
const analyticsInstance = initializeAnalytics(app, {
60+
config: { 'send_page_view': false }
61+
});
62+
const newInstance = initializeAnalytics(app, {
63+
config: { 'send_page_view': false }
64+
});
65+
expect(analyticsInstance).to.equal(newInstance);
66+
});
67+
it('initializeAnalytics() with different options throws', () => {
68+
app = getFullApp(fakeAppParams);
69+
initializeAnalytics(app, {
70+
config: { 'send_page_view': false }
71+
});
72+
expect(() =>
73+
initializeAnalytics(app, {
74+
config: { 'send_page_view': true }
75+
})
76+
).to.throw(AnalyticsError.ALREADY_INITIALIZED);
77+
});
78+
it('initializeAnalytics() with different options (one undefined) throws', () => {
79+
app = getFullApp(fakeAppParams);
80+
initializeAnalytics(app);
81+
expect(() =>
82+
initializeAnalytics(app, {
83+
config: { 'send_page_view': true }
84+
})
85+
).to.throw(AnalyticsError.ALREADY_INITIALIZED);
86+
});
87+
it('getAnalytics() returns same instance created by previous getAnalytics()', () => {
88+
app = getFullApp(fakeAppParams);
89+
const analyticsInstance = getAnalytics(app);
90+
expect(getAnalytics(app)).to.equal(analyticsInstance);
91+
});
92+
it('getAnalytics() returns same instance created by initializeAnalytics()', () => {
93+
app = getFullApp(fakeAppParams);
94+
const analyticsInstance = initializeAnalytics(app);
95+
expect(getAnalytics(app)).to.equal(analyticsInstance);
96+
});
97+
});

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

+8-2
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ import {
3232
validateIndexedDBOpenable,
3333
areCookiesEnabled,
3434
isBrowserExtension,
35-
getModularInstance
35+
getModularInstance,
36+
deepEqual
3637
} from '@firebase/util';
3738
import { ANALYTICS_TYPE } from './constants';
3839
import {
@@ -97,7 +98,12 @@ export function initializeAnalytics(
9798
ANALYTICS_TYPE
9899
);
99100
if (analyticsProvider.isInitialized()) {
100-
throw ERROR_FACTORY.create(AnalyticsError.ALREADY_INITIALIZED);
101+
const existingInstance = analyticsProvider.getImmediate();
102+
if (deepEqual(options, analyticsProvider.getOptions())) {
103+
return existingInstance;
104+
} else {
105+
throw ERROR_FACTORY.create(AnalyticsError.ALREADY_INITIALIZED);
106+
}
101107
}
102108
const analyticsInstance = analyticsProvider.initialize({ options });
103109
return analyticsInstance;

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

+3-2
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ const ERRORS: ErrorMap<AnalyticsError> = {
3636
' already exists. ' +
3737
'Only one Firebase Analytics instance can be created for each appId.',
3838
[AnalyticsError.ALREADY_INITIALIZED]:
39-
'Firebase Analytics has already been initialized. ' +
40-
'initializeAnalytics() must only be called once. getAnalytics() can be used ' +
39+
'initializeAnalytics() cannot be called again with different options than those ' +
40+
'it was initially called with. It can be called again with the same options to ' +
41+
'return the existing instance, or getAnalytics() can be used ' +
4142
'to get a reference to the already-intialized instance.',
4243
[AnalyticsError.ALREADY_INITIALIZED_SETTINGS]:
4344
'Firebase Analytics has already been initialized.' +

packages-exp/analytics-exp/src/factory.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { getOrCreateDataLayer, wrapOrCreateGtag } from './helpers';
2121
import { AnalyticsError, ERROR_FACTORY } from './errors';
2222
import { _FirebaseInstallationsInternal } from '@firebase/installations-exp';
2323
import { areCookiesEnabled, isBrowserExtension } from '@firebase/util';
24-
import { initializeAnalytics } from './initialize-analytics';
24+
import { _initializeAnalytics } from './initialize-analytics';
2525
import { logger } from './logger';
2626
import { FirebaseApp, _FirebaseService } from '@firebase/app-exp';
2727

@@ -221,7 +221,7 @@ export function factory(
221221
}
222222
// Async but non-blocking.
223223
// This map reflects the completion state of all promises for each appId.
224-
initializationPromisesMap[appId] = initializeAnalytics(
224+
initializationPromisesMap[appId] = _initializeAnalytics(
225225
app,
226226
dynamicConfigPromisesList,
227227
measurementIdToAppId,

packages-exp/analytics-exp/src/index.test.ts

+2-8
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ let clock: sinon.SinonFakeTimers;
4848
let fakeInstallations: _FirebaseInstallationsInternal;
4949

5050
// Fake indexedDB.open() request
51-
let fakeRequest = {
51+
const fakeRequest = {
5252
onsuccess: () => {},
5353
result: {
5454
close: () => {}
@@ -67,13 +67,7 @@ function stubFetch(status: number, body: object): void {
6767
// Stub indexedDB.open() because sinon's clock does not know
6868
// how to wait for the real indexedDB callbacks to resolve.
6969
function stubIdbOpen(): void {
70-
(fakeRequest = {
71-
onsuccess: () => {},
72-
result: {
73-
close: () => {}
74-
}
75-
}),
76-
(idbOpenStub = stub(indexedDB, 'open').returns(fakeRequest as any));
70+
idbOpenStub = stub(indexedDB, 'open').returns(fakeRequest as any);
7771
}
7872

7973
describe('FirebaseAnalytics instance tests', () => {

packages-exp/analytics-exp/src/initialize-analytics.test.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import { expect } from 'chai';
1919
import { SinonStub, stub } from 'sinon';
2020
import '../testing/setup';
21-
import { initializeAnalytics } from './initialize-analytics';
21+
import { _initializeAnalytics } from './initialize-analytics';
2222
import {
2323
getFakeApp,
2424
getFakeInstallations
@@ -65,7 +65,7 @@ describe('initializeAnalytics()', () => {
6565
});
6666
it('gets FID and measurement ID and calls gtag config with them', async () => {
6767
stubFetch();
68-
await initializeAnalytics(
68+
await _initializeAnalytics(
6969
app,
7070
dynamicPromisesList,
7171
measurementIdToAppId,
@@ -81,7 +81,7 @@ describe('initializeAnalytics()', () => {
8181
});
8282
it('calls gtag config with options if provided', async () => {
8383
stubFetch();
84-
await initializeAnalytics(
84+
await _initializeAnalytics(
8585
app,
8686
dynamicPromisesList,
8787
measurementIdToAppId,
@@ -99,7 +99,7 @@ describe('initializeAnalytics()', () => {
9999
});
100100
it('puts dynamic fetch promise into dynamic promises list', async () => {
101101
stubFetch();
102-
await initializeAnalytics(
102+
await _initializeAnalytics(
103103
app,
104104
dynamicPromisesList,
105105
measurementIdToAppId,
@@ -113,7 +113,7 @@ describe('initializeAnalytics()', () => {
113113
});
114114
it('puts dynamically fetched measurementId into lookup table', async () => {
115115
stubFetch();
116-
await initializeAnalytics(
116+
await _initializeAnalytics(
117117
app,
118118
dynamicPromisesList,
119119
measurementIdToAppId,
@@ -126,7 +126,7 @@ describe('initializeAnalytics()', () => {
126126
it('warns on local/fetched measurement ID mismatch', async () => {
127127
stubFetch();
128128
const consoleStub = stub(console, 'warn');
129-
await initializeAnalytics(
129+
await _initializeAnalytics(
130130
getFakeApp({ ...fakeAppParams, measurementId: 'old-measurement-id' }),
131131
dynamicPromisesList,
132132
measurementIdToAppId,

packages-exp/analytics-exp/src/initialize-analytics.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ async function validateIndexedDB(): Promise<boolean> {
6565
*
6666
* @returns Measurement ID.
6767
*/
68-
export async function initializeAnalytics(
68+
export async function _initializeAnalytics(
6969
app: FirebaseApp,
7070
dynamicConfigPromisesList: Array<
7171
Promise<DynamicConfig | MinimalDynamicConfig>

packages-exp/analytics-exp/src/public-types.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ export interface Promotion {
228228
* Standard gtag.js control parameters.
229229
* For more information, see
230230
* {@link https://developers.google.com/gtagjs/reference/ga4-events
231-
* the GA4 reference documentation}.
231+
* | the GA4 reference documentation}.
232232
* @public
233233
*/
234234
export interface ControlParams {
@@ -242,7 +242,7 @@ export interface ControlParams {
242242
* Standard gtag.js event parameters.
243243
* For more information, see
244244
* {@link https://developers.google.com/gtagjs/reference/ga4-events
245-
* the GA4 reference documentation}.
245+
* | the GA4 reference documentation}.
246246
* @public
247247
*/
248248
export interface EventParams {

packages-exp/analytics-exp/testing/get-fake-firebase-services.ts

+43-11
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,22 @@
1515
* limitations under the License.
1616
*/
1717

18-
import { FirebaseApp } from '@firebase/app-exp';
18+
import {
19+
FirebaseApp,
20+
initializeApp,
21+
_registerComponent
22+
} from '@firebase/app-exp';
23+
import { Component, ComponentType } from '@firebase/component';
1924
import { _FirebaseInstallationsInternal } from '@firebase/installations-exp';
25+
import { AnalyticsService } from '../src/factory';
26+
27+
const fakeConfig = {
28+
projectId: 'projectId',
29+
authDomain: 'authDomain',
30+
messagingSenderId: 'messagingSenderId',
31+
databaseURL: 'databaseUrl',
32+
storageBucket: 'storageBucket'
33+
};
2034

2135
export function getFakeApp(fakeAppParams?: {
2236
appId?: string;
@@ -25,16 +39,7 @@ export function getFakeApp(fakeAppParams?: {
2539
}): FirebaseApp {
2640
return {
2741
name: 'appName',
28-
options: {
29-
apiKey: fakeAppParams?.apiKey,
30-
projectId: 'projectId',
31-
authDomain: 'authDomain',
32-
messagingSenderId: 'messagingSenderId',
33-
databaseURL: 'databaseUrl',
34-
storageBucket: 'storageBucket',
35-
appId: fakeAppParams?.appId,
36-
measurementId: fakeAppParams?.measurementId
37-
},
42+
options: { ...fakeConfig, ...fakeAppParams },
3843
automaticDataCollectionEnabled: true
3944
};
4045
}
@@ -52,3 +57,30 @@ export function getFakeInstallations(
5257
getToken: async () => 'authToken'
5358
};
5459
}
60+
61+
export function getFullApp(fakeAppParams?: {
62+
appId?: string;
63+
apiKey?: string;
64+
measurementId?: string;
65+
}): FirebaseApp {
66+
_registerComponent(
67+
new Component(
68+
'installations-exp-internal',
69+
() => {
70+
return {} as _FirebaseInstallationsInternal;
71+
},
72+
ComponentType.PUBLIC
73+
)
74+
);
75+
_registerComponent(
76+
new Component(
77+
'analytics-exp',
78+
() => {
79+
return {} as AnalyticsService;
80+
},
81+
ComponentType.PUBLIC
82+
)
83+
);
84+
const app = initializeApp({ ...fakeConfig, ...fakeAppParams });
85+
return app;
86+
}

packages-exp/analytics-exp/testing/integration-tests/integration.ts

-11
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import { getAnalytics, initializeAnalytics, logEvent } from '../../src/index';
2121
import '../setup';
2222
import { expect } from 'chai';
2323
import { stub } from 'sinon';
24-
import { AnalyticsError } from '../../src/errors';
2524

2625
let config: Record<string, string>;
2726
try {
@@ -86,15 +85,5 @@ describe('FirebaseAnalytics Integration Smoke Tests', () => {
8685
expect(eventCalls.length).to.equal(1);
8786
expect(eventCalls[0].name).to.include('method=email');
8887
});
89-
it('getAnalytics() does not throw if called after initializeAnalytics().', async () => {
90-
const analyticsInstance = getAnalytics(app);
91-
expect(analyticsInstance.app).to.equal(app);
92-
});
93-
it('initializeAnalytics() throws if called more than once.', async () => {
94-
expect(() => initializeAnalytics(app)).to.throw(
95-
AnalyticsError.ALREADY_INITIALIZED
96-
);
97-
await deleteApp(app);
98-
});
9988
});
10089
});

0 commit comments

Comments
 (0)