diff --git a/.changeset/fast-buses-scream.md b/.changeset/fast-buses-scream.md new file mode 100644 index 00000000000..94ffa8263ab --- /dev/null +++ b/.changeset/fast-buses-scream.md @@ -0,0 +1,5 @@ +--- +'@firebase/component': patch +--- + +Store instance initialization options on the Provider. diff --git a/common/api-review/app-check-exp.api.md b/common/api-review/app-check-exp.api.md index 5432a664d8f..976284c1cad 100644 --- a/common/api-review/app-check-exp.api.md +++ b/common/api-review/app-check-exp.api.md @@ -51,6 +51,8 @@ export class CustomProvider implements AppCheckProvider { getToken(): Promise; // @internal (undocumented) initialize(app: FirebaseApp): void; + // @internal (undocumented) + isEqual(otherProvider: unknown): boolean; } // @public @@ -79,6 +81,8 @@ export class ReCaptchaV3Provider implements AppCheckProvider { getToken(): Promise; // @internal (undocumented) initialize(app: FirebaseApp): void; + // @internal (undocumented) + isEqual(otherProvider: unknown): boolean; } // @public diff --git a/packages-exp/analytics-exp/src/api.test.ts b/packages-exp/analytics-exp/src/api.test.ts new file mode 100644 index 00000000000..90820083128 --- /dev/null +++ b/packages-exp/analytics-exp/src/api.test.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2019 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 { expect } from 'chai'; +import { SinonStub, stub } from 'sinon'; +import '../testing/setup'; +import { getFullApp } from '../testing/get-fake-firebase-services'; +import { getAnalytics, initializeAnalytics } from './api'; +import { FirebaseApp, deleteApp } from '@firebase/app-exp'; +import { AnalyticsError } from './errors'; +import * as init from './initialize-analytics'; +const fakeAppParams = { appId: 'abcdefgh12345:23405', apiKey: 'AAbbCCdd12345' }; + +describe('FirebaseAnalytics API tests', () => { + let initStub: SinonStub = stub(); + let app: FirebaseApp; + + beforeEach(() => { + initStub = stub(init, '_initializeAnalytics').resolves( + 'FAKE_MEASUREMENT_ID' + ); + }); + + afterEach(async () => { + await initStub(); + initStub.restore(); + if (app) { + return deleteApp(app); + } + }); + + after(() => { + delete window['gtag']; + delete window['dataLayer']; + }); + + it('initializeAnalytics() with same (no) options returns same instance', () => { + app = getFullApp(fakeAppParams); + const analyticsInstance = initializeAnalytics(app); + const newInstance = initializeAnalytics(app); + expect(analyticsInstance).to.equal(newInstance); + }); + it('initializeAnalytics() with same options returns same instance', () => { + app = getFullApp(fakeAppParams); + const analyticsInstance = initializeAnalytics(app, { + config: { 'send_page_view': false } + }); + const newInstance = initializeAnalytics(app, { + config: { 'send_page_view': false } + }); + expect(analyticsInstance).to.equal(newInstance); + }); + it('initializeAnalytics() with different options throws', () => { + app = getFullApp(fakeAppParams); + initializeAnalytics(app, { + config: { 'send_page_view': false } + }); + expect(() => + initializeAnalytics(app, { + config: { 'send_page_view': true } + }) + ).to.throw(AnalyticsError.ALREADY_INITIALIZED); + }); + it('initializeAnalytics() with different options (one undefined) throws', () => { + app = getFullApp(fakeAppParams); + initializeAnalytics(app); + expect(() => + initializeAnalytics(app, { + config: { 'send_page_view': true } + }) + ).to.throw(AnalyticsError.ALREADY_INITIALIZED); + }); + it('getAnalytics() returns same instance created by previous getAnalytics()', () => { + app = getFullApp(fakeAppParams); + const analyticsInstance = getAnalytics(app); + expect(getAnalytics(app)).to.equal(analyticsInstance); + }); + it('getAnalytics() returns same instance created by initializeAnalytics()', () => { + app = getFullApp(fakeAppParams); + const analyticsInstance = initializeAnalytics(app); + expect(getAnalytics(app)).to.equal(analyticsInstance); + }); +}); diff --git a/packages-exp/analytics-exp/src/api.ts b/packages-exp/analytics-exp/src/api.ts index 9605e5d3760..99fe14b2dfe 100644 --- a/packages-exp/analytics-exp/src/api.ts +++ b/packages-exp/analytics-exp/src/api.ts @@ -32,7 +32,8 @@ import { validateIndexedDBOpenable, areCookiesEnabled, isBrowserExtension, - getModularInstance + getModularInstance, + deepEqual } from '@firebase/util'; import { ANALYTICS_TYPE } from './constants'; import { @@ -97,7 +98,12 @@ export function initializeAnalytics( ANALYTICS_TYPE ); if (analyticsProvider.isInitialized()) { - throw ERROR_FACTORY.create(AnalyticsError.ALREADY_INITIALIZED); + const existingInstance = analyticsProvider.getImmediate(); + if (deepEqual(options, analyticsProvider.getOptions())) { + return existingInstance; + } else { + throw ERROR_FACTORY.create(AnalyticsError.ALREADY_INITIALIZED); + } } const analyticsInstance = analyticsProvider.initialize({ options }); return analyticsInstance; diff --git a/packages-exp/analytics-exp/src/errors.ts b/packages-exp/analytics-exp/src/errors.ts index b500f88d013..98293447c65 100644 --- a/packages-exp/analytics-exp/src/errors.ts +++ b/packages-exp/analytics-exp/src/errors.ts @@ -36,8 +36,9 @@ const ERRORS: ErrorMap = { ' already exists. ' + 'Only one Firebase Analytics instance can be created for each appId.', [AnalyticsError.ALREADY_INITIALIZED]: - 'Firebase Analytics has already been initialized. ' + - 'initializeAnalytics() must only be called once. getAnalytics() can be used ' + + 'initializeAnalytics() cannot be called again with different options than those ' + + 'it was initially called with. It can be called again with the same options to ' + + 'return the existing instance, or getAnalytics() can be used ' + 'to get a reference to the already-intialized instance.', [AnalyticsError.ALREADY_INITIALIZED_SETTINGS]: 'Firebase Analytics has already been initialized.' + diff --git a/packages-exp/analytics-exp/src/factory.ts b/packages-exp/analytics-exp/src/factory.ts index 5b6cc0ba3ab..6d9d54585ed 100644 --- a/packages-exp/analytics-exp/src/factory.ts +++ b/packages-exp/analytics-exp/src/factory.ts @@ -21,7 +21,7 @@ import { getOrCreateDataLayer, wrapOrCreateGtag } from './helpers'; import { AnalyticsError, ERROR_FACTORY } from './errors'; import { _FirebaseInstallationsInternal } from '@firebase/installations-exp'; import { areCookiesEnabled, isBrowserExtension } from '@firebase/util'; -import { initializeAnalytics } from './initialize-analytics'; +import { _initializeAnalytics } from './initialize-analytics'; import { logger } from './logger'; import { FirebaseApp, _FirebaseService } from '@firebase/app-exp'; @@ -221,7 +221,7 @@ export function factory( } // Async but non-blocking. // This map reflects the completion state of all promises for each appId. - initializationPromisesMap[appId] = initializeAnalytics( + initializationPromisesMap[appId] = _initializeAnalytics( app, dynamicConfigPromisesList, measurementIdToAppId, diff --git a/packages-exp/analytics-exp/src/index.test.ts b/packages-exp/analytics-exp/src/index.test.ts index 1438d95e31b..43bb2ea69a1 100644 --- a/packages-exp/analytics-exp/src/index.test.ts +++ b/packages-exp/analytics-exp/src/index.test.ts @@ -48,7 +48,7 @@ let clock: sinon.SinonFakeTimers; let fakeInstallations: _FirebaseInstallationsInternal; // Fake indexedDB.open() request -let fakeRequest = { +const fakeRequest = { onsuccess: () => {}, result: { close: () => {} @@ -67,13 +67,7 @@ function stubFetch(status: number, body: object): void { // Stub indexedDB.open() because sinon's clock does not know // how to wait for the real indexedDB callbacks to resolve. function stubIdbOpen(): void { - (fakeRequest = { - onsuccess: () => {}, - result: { - close: () => {} - } - }), - (idbOpenStub = stub(indexedDB, 'open').returns(fakeRequest as any)); + idbOpenStub = stub(indexedDB, 'open').returns(fakeRequest as any); } describe('FirebaseAnalytics instance tests', () => { diff --git a/packages-exp/analytics-exp/src/initialize-analytics.test.ts b/packages-exp/analytics-exp/src/initialize-analytics.test.ts index 93d2bc8d4b9..a6152a4dc34 100644 --- a/packages-exp/analytics-exp/src/initialize-analytics.test.ts +++ b/packages-exp/analytics-exp/src/initialize-analytics.test.ts @@ -18,7 +18,7 @@ import { expect } from 'chai'; import { SinonStub, stub } from 'sinon'; import '../testing/setup'; -import { initializeAnalytics } from './initialize-analytics'; +import { _initializeAnalytics } from './initialize-analytics'; import { getFakeApp, getFakeInstallations @@ -65,7 +65,7 @@ describe('initializeAnalytics()', () => { }); it('gets FID and measurement ID and calls gtag config with them', async () => { stubFetch(); - await initializeAnalytics( + await _initializeAnalytics( app, dynamicPromisesList, measurementIdToAppId, @@ -81,7 +81,7 @@ describe('initializeAnalytics()', () => { }); it('calls gtag config with options if provided', async () => { stubFetch(); - await initializeAnalytics( + await _initializeAnalytics( app, dynamicPromisesList, measurementIdToAppId, @@ -99,7 +99,7 @@ describe('initializeAnalytics()', () => { }); it('puts dynamic fetch promise into dynamic promises list', async () => { stubFetch(); - await initializeAnalytics( + await _initializeAnalytics( app, dynamicPromisesList, measurementIdToAppId, @@ -113,7 +113,7 @@ describe('initializeAnalytics()', () => { }); it('puts dynamically fetched measurementId into lookup table', async () => { stubFetch(); - await initializeAnalytics( + await _initializeAnalytics( app, dynamicPromisesList, measurementIdToAppId, @@ -126,7 +126,7 @@ describe('initializeAnalytics()', () => { it('warns on local/fetched measurement ID mismatch', async () => { stubFetch(); const consoleStub = stub(console, 'warn'); - await initializeAnalytics( + await _initializeAnalytics( getFakeApp({ ...fakeAppParams, measurementId: 'old-measurement-id' }), dynamicPromisesList, measurementIdToAppId, diff --git a/packages-exp/analytics-exp/src/initialize-analytics.ts b/packages-exp/analytics-exp/src/initialize-analytics.ts index 69d77dc811e..8e3ad0f5a44 100644 --- a/packages-exp/analytics-exp/src/initialize-analytics.ts +++ b/packages-exp/analytics-exp/src/initialize-analytics.ts @@ -65,7 +65,7 @@ async function validateIndexedDB(): Promise { * * @returns Measurement ID. */ -export async function initializeAnalytics( +export async function _initializeAnalytics( app: FirebaseApp, dynamicConfigPromisesList: Array< Promise diff --git a/packages-exp/analytics-exp/src/public-types.ts b/packages-exp/analytics-exp/src/public-types.ts index 9cbff37769a..2ad49e08350 100644 --- a/packages-exp/analytics-exp/src/public-types.ts +++ b/packages-exp/analytics-exp/src/public-types.ts @@ -228,7 +228,7 @@ export interface Promotion { * Standard gtag.js control parameters. * For more information, see * {@link https://developers.google.com/gtagjs/reference/ga4-events - * the GA4 reference documentation}. + * | the GA4 reference documentation}. * @public */ export interface ControlParams { @@ -242,7 +242,7 @@ export interface ControlParams { * Standard gtag.js event parameters. * For more information, see * {@link https://developers.google.com/gtagjs/reference/ga4-events - * the GA4 reference documentation}. + * | the GA4 reference documentation}. * @public */ export interface EventParams { diff --git a/packages-exp/analytics-exp/testing/get-fake-firebase-services.ts b/packages-exp/analytics-exp/testing/get-fake-firebase-services.ts index b1171a164d2..d4fe70c26b9 100644 --- a/packages-exp/analytics-exp/testing/get-fake-firebase-services.ts +++ b/packages-exp/analytics-exp/testing/get-fake-firebase-services.ts @@ -15,8 +15,22 @@ * limitations under the License. */ -import { FirebaseApp } from '@firebase/app-exp'; +import { + FirebaseApp, + initializeApp, + _registerComponent +} from '@firebase/app-exp'; +import { Component, ComponentType } from '@firebase/component'; import { _FirebaseInstallationsInternal } from '@firebase/installations-exp'; +import { AnalyticsService } from '../src/factory'; + +const fakeConfig = { + projectId: 'projectId', + authDomain: 'authDomain', + messagingSenderId: 'messagingSenderId', + databaseURL: 'databaseUrl', + storageBucket: 'storageBucket' +}; export function getFakeApp(fakeAppParams?: { appId?: string; @@ -25,16 +39,7 @@ export function getFakeApp(fakeAppParams?: { }): FirebaseApp { return { name: 'appName', - options: { - apiKey: fakeAppParams?.apiKey, - projectId: 'projectId', - authDomain: 'authDomain', - messagingSenderId: 'messagingSenderId', - databaseURL: 'databaseUrl', - storageBucket: 'storageBucket', - appId: fakeAppParams?.appId, - measurementId: fakeAppParams?.measurementId - }, + options: { ...fakeConfig, ...fakeAppParams }, automaticDataCollectionEnabled: true }; } @@ -52,3 +57,30 @@ export function getFakeInstallations( getToken: async () => 'authToken' }; } + +export function getFullApp(fakeAppParams?: { + appId?: string; + apiKey?: string; + measurementId?: string; +}): FirebaseApp { + _registerComponent( + new Component( + 'installations-exp-internal', + () => { + return {} as _FirebaseInstallationsInternal; + }, + ComponentType.PUBLIC + ) + ); + _registerComponent( + new Component( + 'analytics-exp', + () => { + return {} as AnalyticsService; + }, + ComponentType.PUBLIC + ) + ); + const app = initializeApp({ ...fakeConfig, ...fakeAppParams }); + return app; +} diff --git a/packages-exp/analytics-exp/testing/integration-tests/integration.ts b/packages-exp/analytics-exp/testing/integration-tests/integration.ts index e95a709b919..96995bccd2e 100644 --- a/packages-exp/analytics-exp/testing/integration-tests/integration.ts +++ b/packages-exp/analytics-exp/testing/integration-tests/integration.ts @@ -21,7 +21,6 @@ import { getAnalytics, initializeAnalytics, logEvent } from '../../src/index'; import '../setup'; import { expect } from 'chai'; import { stub } from 'sinon'; -import { AnalyticsError } from '../../src/errors'; let config: Record; try { @@ -86,15 +85,5 @@ describe('FirebaseAnalytics Integration Smoke Tests', () => { expect(eventCalls.length).to.equal(1); expect(eventCalls[0].name).to.include('method=email'); }); - it('getAnalytics() does not throw if called after initializeAnalytics().', async () => { - const analyticsInstance = getAnalytics(app); - expect(analyticsInstance.app).to.equal(app); - }); - it('initializeAnalytics() throws if called more than once.', async () => { - expect(() => initializeAnalytics(app)).to.throw( - AnalyticsError.ALREADY_INITIALIZED - ); - await deleteApp(app); - }); }); }); diff --git a/packages-exp/app-check-exp/src/api.test.ts b/packages-exp/app-check-exp/src/api.test.ts index 56ce9e8508b..fc06ffc1ba9 100644 --- a/packages-exp/app-check-exp/src/api.test.ts +++ b/packages-exp/app-check-exp/src/api.test.ts @@ -39,8 +39,9 @@ import * as client from './client'; import * as storage from './storage'; import * as internalApi from './internal-api'; import { deleteApp, FirebaseApp } from '@firebase/app-exp'; -import { ReCaptchaV3Provider } from './providers'; +import { CustomProvider, ReCaptchaV3Provider } from './providers'; import { AppCheckService } from './factory'; +import { AppCheckToken } from './public-types'; describe('api', () => { let app: FirebaseApp; @@ -57,16 +58,66 @@ describe('api', () => { }); describe('initializeAppCheck()', () => { - it('can only be called once', () => { + it('can only be called once (if given different provider classes)', () => { initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) }); expect(() => initializeAppCheck(app, { - provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) + provider: new CustomProvider({ + getToken: () => Promise.resolve({ token: 'mm' } as AppCheckToken) + }) + }) + ).to.throw(/appCheck\/already-initialized/); + }); + it('can only be called once (if given different ReCaptchaV3Providers)', () => { + initializeAppCheck(app, { + provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) + }); + expect(() => + initializeAppCheck(app, { + provider: new ReCaptchaV3Provider(FAKE_SITE_KEY + 'X') + }) + ).to.throw(/appCheck\/already-initialized/); + }); + it('can only be called once (if given different CustomProviders)', () => { + initializeAppCheck(app, { + provider: new CustomProvider({ + getToken: () => Promise.resolve({ token: 'ff' } as AppCheckToken) + }) + }); + expect(() => + initializeAppCheck(app, { + provider: new CustomProvider({ + getToken: () => Promise.resolve({ token: 'gg' } as AppCheckToken) + }) }) ).to.throw(/appCheck\/already-initialized/); }); + it('can be called multiple times (if given equivalent ReCaptchaV3Providers)', () => { + const appCheckInstance = initializeAppCheck(app, { + provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) + }); + expect( + initializeAppCheck(app, { + provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) + }) + ).to.equal(appCheckInstance); + }); + it('can be called multiple times (if given equivalent CustomProviders)', () => { + const appCheckInstance = initializeAppCheck(app, { + provider: new CustomProvider({ + getToken: () => Promise.resolve({ token: 'ff' } as AppCheckToken) + }) + }); + expect( + initializeAppCheck(app, { + provider: new CustomProvider({ + getToken: () => Promise.resolve({ token: 'ff' } as AppCheckToken) + }) + }) + ).to.equal(appCheckInstance); + }); it('initialize reCAPTCHA when a ReCaptchaV3Provider is provided', () => { const initReCAPTCHAStub = stub(reCAPTCHA, 'initialize').returns( diff --git a/packages-exp/app-check-exp/src/api.ts b/packages-exp/app-check-exp/src/api.ts index 21b753ffa23..a9b2b3e7bff 100644 --- a/packages-exp/app-check-exp/src/api.ts +++ b/packages-exp/app-check-exp/src/api.ts @@ -56,9 +56,19 @@ export function initializeAppCheck( const provider = _getProvider(app, 'app-check-exp'); if (provider.isInitialized()) { - throw ERROR_FACTORY.create(AppCheckError.ALREADY_INITIALIZED, { - appName: app.name - }); + const existingInstance = provider.getImmediate(); + const initialOptions = provider.getOptions() as unknown as AppCheckOptions; + if ( + initialOptions.isTokenAutoRefreshEnabled === + options.isTokenAutoRefreshEnabled && + initialOptions.provider.isEqual(options.provider) + ) { + return existingInstance; + } else { + throw ERROR_FACTORY.create(AppCheckError.ALREADY_INITIALIZED, { + appName: app.name + }); + } } const appCheck = provider.initialize({ options }); diff --git a/packages-exp/app-check-exp/src/errors.ts b/packages-exp/app-check-exp/src/errors.ts index cf48489b0e4..05324b639ee 100644 --- a/packages-exp/app-check-exp/src/errors.ts +++ b/packages-exp/app-check-exp/src/errors.ts @@ -31,8 +31,10 @@ export const enum AppCheckError { const ERRORS: ErrorMap = { [AppCheckError.ALREADY_INITIALIZED]: - 'You have already called initializeAppCheck() for FirebaseApp {$appName}, ' + - 'initializeAppCheck() can only be called once.', + 'You have already called initializeAppCheck() for FirebaseApp {$appName} with ' + + 'different options. To avoid this error, call initializeAppCheck() with the ' + + 'same options as when it was originally called. This will return the ' + + 'already initialized instance.', [AppCheckError.USE_BEFORE_ACTIVATION]: 'App Check is being used before initializeAppCheck() is called for FirebaseApp {$appName}. ' + 'Call initializeAppCheck() before instantiating other Firebase services.', diff --git a/packages-exp/app-check-exp/src/providers.ts b/packages-exp/app-check-exp/src/providers.ts index f249e9e3f47..b04afc8b9af 100644 --- a/packages-exp/app-check-exp/src/providers.ts +++ b/packages-exp/app-check-exp/src/providers.ts @@ -41,6 +41,7 @@ export class ReCaptchaV3Provider implements AppCheckProvider { * @param siteKey - ReCAPTCHA V3 siteKey. */ constructor(private _siteKey: string) {} + /** * Returns an App Check token. * @internal @@ -74,6 +75,17 @@ export class ReCaptchaV3Provider implements AppCheckProvider { /* we don't care about the initialization result */ }); } + + /** + * @internal + */ + isEqual(otherProvider: unknown): boolean { + if (otherProvider instanceof ReCaptchaV3Provider) { + return this._siteKey === otherProvider._siteKey; + } else { + return false; + } + } } /** @@ -120,4 +132,18 @@ export class CustomProvider implements AppCheckProvider { initialize(app: FirebaseApp): void { this._app = app; } + + /** + * @internal + */ + isEqual(otherProvider: unknown): boolean { + if (otherProvider instanceof CustomProvider) { + return ( + this._customProviderOptions.getToken.toString() === + otherProvider._customProviderOptions.getToken.toString() + ); + } else { + return false; + } + } } diff --git a/packages-exp/auth-exp/src/core/auth/initialize.test.ts b/packages-exp/auth-exp/src/core/auth/initialize.test.ts index 63a9864b187..7226d5b5374 100644 --- a/packages-exp/auth-exp/src/core/auth/initialize.test.ts +++ b/packages-exp/auth-exp/src/core/auth/initialize.test.ts @@ -50,6 +50,7 @@ import { import { ClientPlatform, _getClientVersion } from '../util/version'; import { initializeAuth } from './initialize'; import { registerAuth } from './register'; +import { debugErrorMap, prodErrorMap } from '../errors'; describe('core/auth/initialize', () => { let fakeApp: FirebaseApp; @@ -127,7 +128,8 @@ describe('core/auth/initialize', () => { } } - const fakePopupRedirectResolver: PopupRedirectResolver = FakePopupRedirectResolver; + const fakePopupRedirectResolver: PopupRedirectResolver = + FakePopupRedirectResolver; before(() => { registerAuth(ClientPlatform.BROWSER); @@ -203,15 +205,63 @@ describe('core/auth/initialize', () => { const auth = initializeAuth(fakeApp, { popupRedirectResolver: fakePopupRedirectResolver }) as AuthInternal; - await ((auth as unknown) as _FirebaseService)._delete(); + await (auth as unknown as _FirebaseService)._delete(); await auth._initializationPromise; expect(auth._isInitialized).to.be.false; }); - it('should throw if called more than once', () => { - initializeAuth(fakeApp); - expect(() => initializeAuth(fakeApp)).to.throw(); + it('should not throw if called again with same (no) params', () => { + const auth = initializeAuth(fakeApp); + expect(initializeAuth(fakeApp)).to.equal(auth); + }); + + it('should not throw if called again with same params', () => { + const auth = initializeAuth(fakeApp, { + errorMap: prodErrorMap, + persistence: fakeSessionPersistence, + popupRedirectResolver: fakePopupRedirectResolver + }); + expect( + initializeAuth(fakeApp, { + errorMap: prodErrorMap, + persistence: fakeSessionPersistence, + popupRedirectResolver: fakePopupRedirectResolver + }) + ).to.equal(auth); + }); + + it('should throw if called again with different params (popupRedirectResolver)', () => { + initializeAuth(fakeApp, { + popupRedirectResolver: fakePopupRedirectResolver + }); + expect(() => + initializeAuth(fakeApp, { + popupRedirectResolver: undefined + }) + ).to.throw(); + }); + + it('should throw if called again with different params (errorMap)', () => { + initializeAuth(fakeApp, { + errorMap: prodErrorMap + }); + expect(() => + initializeAuth(fakeApp, { + errorMap: debugErrorMap + }) + ).to.throw(); + }); + + it('should throw if called again with different params (persistence)', () => { + initializeAuth(fakeApp, { + persistence: [inMemoryPersistence, fakeSessionPersistence] + }); + expect(() => + initializeAuth(fakeApp, { + persistence: [fakeSessionPersistence, inMemoryPersistence] + }) + ).to.throw(); }); }); }); diff --git a/packages-exp/auth-exp/src/core/auth/initialize.ts b/packages-exp/auth-exp/src/core/auth/initialize.ts index 32e254ad84a..8f8528969eb 100644 --- a/packages-exp/auth-exp/src/core/auth/initialize.ts +++ b/packages-exp/auth-exp/src/core/auth/initialize.ts @@ -16,6 +16,7 @@ */ import { _getProvider, FirebaseApp } from '@firebase/app-exp'; +import { deepEqual } from '@firebase/util'; import { Auth, Dependencies } from '../../model/public_types'; import { AuthErrorCode } from '../errors'; @@ -54,7 +55,12 @@ export function initializeAuth(app: FirebaseApp, deps?: Dependencies): Auth { if (provider.isInitialized()) { const auth = provider.getImmediate() as AuthImpl; - _fail(auth, AuthErrorCode.ALREADY_INITIALIZED); + const initialOptions = provider.getOptions() as Dependencies; + if (deepEqual(initialOptions, deps ?? {})) { + return auth; + } else { + _fail(auth, AuthErrorCode.ALREADY_INITIALIZED); + } } const auth = provider.initialize({ options: deps }) as AuthImpl; @@ -67,9 +73,8 @@ export function _initializeAuthInstance( deps?: Dependencies ): void { const persistence = deps?.persistence || []; - const hierarchy = (Array.isArray(persistence) - ? persistence - : [persistence] + const hierarchy = ( + Array.isArray(persistence) ? persistence : [persistence] ).map(_getInstance); if (deps?.errorMap) { auth._updateErrorMap(deps.errorMap); diff --git a/packages-exp/auth-exp/src/core/errors.ts b/packages-exp/auth-exp/src/core/errors.ts index e955c4c1c0f..a79ecca7292 100644 --- a/packages-exp/auth-exp/src/core/errors.ts +++ b/packages-exp/auth-exp/src/core/errors.ts @@ -349,7 +349,10 @@ function _debugErrorMap(): ErrorMap { [AuthErrorCode.WEB_STORAGE_UNSUPPORTED]: 'This browser is not supported or 3rd party cookies and data may be disabled.', [AuthErrorCode.ALREADY_INITIALIZED]: - 'Auth can only be initialized once per app.' + 'initializeAuth() has already been called with ' + + 'different options. To avoid this error, call initializeAuth() with the ' + + 'same options as when it was originally called, or call getAuth() to return the' + + ' already initialized instance.' }; } diff --git a/packages-exp/performance-exp/src/index.test.ts b/packages-exp/performance-exp/src/index.test.ts index b4610acc7dd..9d8403f954d 100644 --- a/packages-exp/performance-exp/src/index.test.ts +++ b/packages-exp/performance-exp/src/index.test.ts @@ -16,12 +16,10 @@ */ import { expect } from 'chai'; -import { stub } from 'sinon'; import { initializePerformance } from './index'; import { ERROR_FACTORY, ErrorCode } from './utils/errors'; -import * as firebase from '@firebase/app-exp'; -import { Provider } from '@firebase/component'; import '../test/setup'; +import { deleteApp, FirebaseApp, initializeApp } from '@firebase/app-exp'; const fakeFirebaseConfig = { apiKey: 'api-key', @@ -33,18 +31,43 @@ const fakeFirebaseConfig = { appId: '1:111:web:a1234' }; -const fakeFirebaseApp = ({ - options: fakeFirebaseConfig -} as unknown) as firebase.FirebaseApp; - describe('Firebase Performance > initializePerformance()', () => { - it('throws if a perf instance has already been created', () => { - stub(firebase, '_getProvider').returns(({ - isInitialized: () => true - } as unknown) as Provider<'performance-exp'>); + let app: FirebaseApp; + beforeEach(() => { + app = initializeApp(fakeFirebaseConfig); + }); + afterEach(() => { + return deleteApp(app); + }); + it('returns same instance if given same (no) params a second time', () => { + const performanceInstance = initializePerformance(app); + expect(initializePerformance(app)).to.equal(performanceInstance); + }); + it('returns same instance if given same params a second time', () => { + const performanceInstance = initializePerformance(app, { + dataCollectionEnabled: false + }); + expect( + initializePerformance(app, { dataCollectionEnabled: false }) + ).to.equal(performanceInstance); + }); + it('throws if called with params after being called with no params', () => { + initializePerformance(app); + const expectedError = ERROR_FACTORY.create(ErrorCode.ALREADY_INITIALIZED); + expect(() => + initializePerformance(app, { dataCollectionEnabled: false }) + ).to.throw(expectedError.message); + }); + it('throws if called with no params after being called with params', () => { + initializePerformance(app, { instrumentationEnabled: false }); + const expectedError = ERROR_FACTORY.create(ErrorCode.ALREADY_INITIALIZED); + expect(() => initializePerformance(app)).to.throw(expectedError.message); + }); + it('throws if called a second time with different params', () => { + initializePerformance(app, { instrumentationEnabled: true }); const expectedError = ERROR_FACTORY.create(ErrorCode.ALREADY_INITIALIZED); - expect(() => initializePerformance(fakeFirebaseApp)).to.throw( - expectedError.message - ); + expect(() => + initializePerformance(app, { instrumentationEnabled: false }) + ).to.throw(expectedError.message); }); }); diff --git a/packages-exp/performance-exp/src/index.ts b/packages-exp/performance-exp/src/index.ts index 07d00b70dac..84470a5501d 100644 --- a/packages-exp/performance-exp/src/index.ts +++ b/packages-exp/performance-exp/src/index.ts @@ -45,7 +45,7 @@ import { import { name, version } from '../package.json'; import { Trace } from './resources/trace'; import '@firebase/installations-exp'; -import { getModularInstance } from '@firebase/util'; +import { deepEqual, getModularInstance } from '@firebase/util'; const DEFAULT_ENTRY_NAME = '[DEFAULT]'; @@ -79,7 +79,13 @@ export function initializePerformance( // throw if an instance was already created. // It could happen if initializePerformance() is called more than once, or getPerformance() is called first. if (provider.isInitialized()) { - throw ERROR_FACTORY.create(ErrorCode.ALREADY_INITIALIZED); + const existingInstance = provider.getImmediate(); + const initialSettings = provider.getOptions() as PerformanceSettings; + if (deepEqual(initialSettings, settings ?? {})) { + return existingInstance; + } else { + throw ERROR_FACTORY.create(ErrorCode.ALREADY_INITIALIZED); + } } const perfInstance = provider.initialize({ diff --git a/packages-exp/performance-exp/src/utils/errors.ts b/packages-exp/performance-exp/src/utils/errors.ts index 92e4053144b..83e55b3eed5 100644 --- a/packages-exp/performance-exp/src/utils/errors.ts +++ b/packages-exp/performance-exp/src/utils/errors.ts @@ -60,7 +60,11 @@ const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = { 'Custom metric name {$customMetricName} is invalid', [ErrorCode.INVALID_STRING_MERGER_PARAMETER]: 'Input for String merger is invalid, contact support team to resolve.', - [ErrorCode.ALREADY_INITIALIZED]: 'Performance can only be initialized once.' + [ErrorCode.ALREADY_INITIALIZED]: + 'initializePerformance() has already been called with ' + + 'different options. To avoid this error, call initializePerformance() with the ' + + 'same options as when it was originally called, or call getPerformance() to return the' + + ' already initialized instance.' }; interface ErrorParams { diff --git a/packages/component/src/provider.ts b/packages/component/src/provider.ts index 716f4c78d63..c52f5c72536 100644 --- a/packages/component/src/provider.ts +++ b/packages/component/src/provider.ts @@ -38,6 +38,8 @@ export class Provider { string, Deferred > = new Map(); + private readonly instancesOptions: Map> = + new Map(); private onInitCallbacks: Map>> = new Map(); constructor( @@ -171,9 +173,8 @@ export class Provider { instanceIdentifier, instanceDeferred ] of this.instancesDeferred.entries()) { - const normalizedIdentifier = this.normalizeInstanceIdentifier( - instanceIdentifier - ); + const normalizedIdentifier = + this.normalizeInstanceIdentifier(instanceIdentifier); try { // `getOrInitializeService()` should always return a valid instance since a component is guaranteed. use ! to make typescript happy. @@ -190,6 +191,7 @@ export class Provider { clearInstance(identifier: string = DEFAULT_ENTRY_NAME): void { this.instancesDeferred.delete(identifier); + this.instancesOptions.delete(identifier); this.instances.delete(identifier); } @@ -218,6 +220,10 @@ export class Provider { return this.instances.has(identifier); } + getOptions(identifier: string = DEFAULT_ENTRY_NAME): Record { + return this.instancesOptions.get(identifier) || {}; + } + initialize(opts: InitializeOptions = {}): NameServiceMapping[T] { const { options = {} } = opts; const normalizedIdentifier = this.normalizeInstanceIdentifier( @@ -243,9 +249,8 @@ export class Provider { instanceIdentifier, instanceDeferred ] of this.instancesDeferred.entries()) { - const normalizedDeferredIdentifier = this.normalizeInstanceIdentifier( - instanceIdentifier - ); + const normalizedDeferredIdentifier = + this.normalizeInstanceIdentifier(instanceIdentifier); if (normalizedIdentifier === normalizedDeferredIdentifier) { instanceDeferred.resolve(instance); } @@ -315,6 +320,7 @@ export class Provider { options }); this.instances.set(instanceIdentifier, instance); + this.instancesOptions.set(instanceIdentifier, options); /** * Invoke onInit listeners. diff --git a/packages/firestore/src/exp/database.ts b/packages/firestore/src/exp/database.ts index a5511a3856b..9b29a79c254 100644 --- a/packages/firestore/src/exp/database.ts +++ b/packages/firestore/src/exp/database.ts @@ -24,6 +24,7 @@ import { } from '@firebase/app-exp'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; import { Provider } from '@firebase/component'; +import { deepEqual } from '@firebase/util'; import { IndexedDbOfflineComponentProvider, @@ -128,10 +129,19 @@ export function initializeFirestore( const provider = _getProvider(app, 'firestore-exp'); if (provider.isInitialized()) { - throw new FirestoreError( - Code.FAILED_PRECONDITION, - 'Firestore can only be initialized once per app.' - ); + const existingInstance = provider.getImmediate(); + const initialSettings = provider.getOptions() as FirestoreSettings; + if (deepEqual(initialSettings, settings)) { + return existingInstance; + } else { + throw new FirestoreError( + Code.FAILED_PRECONDITION, + 'initializeFirestore() has already been called with ' + + 'different options. To avoid this error, call initializeFirestore() with the ' + + 'same options as when it was originally called, or call getFirestore() to return the' + + ' already initialized instance.' + ); + } } if (