Skip to content

Commit bd24f71

Browse files
authored
feat(app): Allow for lazy registration of app components (#15)
* WIP: add lazy module instantiation * feat(app): allow for lazy loading of services * test(app): add integration test for webpack/browserify for lazy instantiation * fix(app): fix issue where auth listeners weren't being patched properly * docs(app): adding doc explaining Firebase Auth specific code in firebase_app.ts * fix(app): revert code refactor to _getService function * test(app): add tests and fix issue with multiple instances of a service There was an issue where we weren't properly limiting mulitple service instances. Added a test and refactored the code to support that use case * refactor(app): remove unneeded ternary * fix(app): fix issue where instanceIdentifier wasn't being passed Fixed an issue where instance identifiers weren't being passed to their creation factories. Added some tests to validate that we don't accidentally remove that in the future.
1 parent 706bdbb commit bd24f71

File tree

5 files changed

+222
-71
lines changed

5 files changed

+222
-71
lines changed

gulp/tasks/test.integration.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ function compileTypescript() {
4848
function compileWebpack() {
4949
return gulp.src('tests-integration/bundlers/**/*.test.js')
5050
.pipe(named())
51-
.pipe(webpackStream())
51+
.pipe(webpackStream({}, webpack))
5252
.pipe(rename(path => {
5353
const rawPath = path.basename.replace('.test', '');
5454
path.basename = `${rawPath}.webpack.test`;

src/app/firebase_app.ts

+83-67
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,10 @@ let LocalPromise = local.Promise as typeof Promise;
221221

222222
const DEFAULT_ENTRY_NAME = '[DEFAULT]';
223223

224+
// An array to capture listeners before the true auth functions
225+
// exist
226+
let tokenListeners = [];
227+
224228
/**
225229
* Global context object for a collection of services using
226230
* a shared authentication state.
@@ -229,27 +233,19 @@ class FirebaseAppImpl implements FirebaseApp {
229233
private options_: FirebaseOptions;
230234
private name_: string;
231235
private isDeleted_ = false;
232-
private services_: {[name: string]:
233-
{[instance: string]: FirebaseService}} = {};
234-
public INTERNAL: FirebaseAppInternals;
236+
private services_: {
237+
[name: string]: {
238+
[serviceName: string]: FirebaseService
239+
}
240+
} = {};
241+
242+
public INTERNAL;
235243

236244
constructor(options: FirebaseOptions,
237245
name: string,
238246
private firebase_: FirebaseNamespace) {
239247
this.name_ = name;
240248
this.options_ = deepCopy<FirebaseOptions>(options);
241-
242-
Object.keys(firebase_.INTERNAL.factories).forEach((serviceName) => {
243-
// Ignore virtual services
244-
let factoryName = firebase_.INTERNAL.useAsService(this, serviceName);
245-
if (factoryName === null) {
246-
return;
247-
}
248-
249-
// Defer calling createService until service is accessed.
250-
let getService = this.getService.bind(this, factoryName);
251-
patchProperty(this, serviceName, getService);
252-
});
253249
}
254250

255251
get name(): string {
@@ -286,34 +282,57 @@ class FirebaseAppImpl implements FirebaseApp {
286282
}
287283

288284
/**
289-
* Return the service instance associated with this app (creating it
290-
* on demand).
285+
* Return a service instance associated with this app (creating it
286+
* on demand), identified by the passed instanceIdentifier.
287+
*
288+
* NOTE: Currently storage is the only one that is leveraging this
289+
* functionality. They invoke it by calling:
290+
*
291+
* ```javascript
292+
* firebase.app().storage('STORAGE BUCKET ID')
293+
* ```
294+
*
295+
* The service name is passed to this already
296+
* @internal
291297
*/
292-
private getService(name: string, instanceString?: string): FirebaseService
293-
|null {
298+
_getService(name: string, instanceIdentifier: string = DEFAULT_ENTRY_NAME): FirebaseService {
294299
this.checkDestroyed_();
295300

296-
if (typeof this.services_[name] === 'undefined') {
301+
if (!this.services_[name]) {
297302
this.services_[name] = {};
298303
}
299304

300-
let instanceSpecifier = instanceString || DEFAULT_ENTRY_NAME;
301-
if (typeof this.services_[name]![instanceSpecifier] === 'undefined') {
302-
let firebaseService = this.firebase_.INTERNAL.factories[name](
303-
this, this.extendApp.bind(this), instanceString);
304-
this.services_[name]![instanceSpecifier] = firebaseService;
305-
return firebaseService;
306-
} else {
307-
return this.services_[name]![instanceSpecifier] as FirebaseService | null;
305+
if (!this.services_[name][instanceIdentifier]) {
306+
let service = this.firebase_.INTERNAL.factories[name](this, this.extendApp.bind(this), instanceIdentifier);
307+
this.services_[name][instanceIdentifier] = service;
308308
}
309+
310+
return this.services_[name][instanceIdentifier];
309311
}
310312

311313
/**
312314
* Callback function used to extend an App instance at the time
313315
* of service instance creation.
314316
*/
315317
private extendApp(props: {[name: string]: any}): void {
316-
deepExtend(this, props);
318+
// Copy the object onto the FirebaseAppImpl prototype
319+
deepExtend(FirebaseAppImpl.prototype, props);
320+
321+
/**
322+
* If the app has overwritten the addAuthTokenListener stub, forward
323+
* the active token listeners on to the true fxn.
324+
*
325+
* TODO: This function is required due to our current module
326+
* structure. Once we are able to rely strictly upon a single module
327+
* implementation, this code should be refactored and Auth should
328+
* provide these stubs and the upgrade logic
329+
*/
330+
if (props.INTERNAL && props.INTERNAL.addAuthTokenListener) {
331+
tokenListeners.forEach(listener => {
332+
this.INTERNAL.addAuthTokenListener(listener);
333+
});
334+
tokenListeners = [];
335+
}
317336
}
318337

319338
/**
@@ -327,6 +346,19 @@ class FirebaseAppImpl implements FirebaseApp {
327346
}
328347
};
329348

349+
FirebaseAppImpl.prototype.INTERNAL = {
350+
'getUid': () => null,
351+
'getToken': () => LocalPromise.resolve(null),
352+
'addAuthTokenListener': (callback: (token: string|null) => void) => {
353+
tokenListeners.push(callback);
354+
// Make sure callback is called, asynchronously, in the absence of the auth module
355+
setTimeout(() => callback(null), 0);
356+
},
357+
'removeAuthTokenListener': (callback) => {
358+
tokenListeners = tokenListeners.filter(listener => listener !== callback);
359+
},
360+
}
361+
330362
// Prevent dead-code elimination of these methods w/o invalid property
331363
// copying.
332364
FirebaseAppImpl.prototype.name &&
@@ -425,27 +457,12 @@ export function createFirebaseNamespace(): FirebaseNamespace {
425457
if (apps_[name!] !== undefined) {
426458
error('duplicate-app', {'name': name});
427459
}
428-
let app = new FirebaseAppImpl(options, name!,
429-
((namespace as any) as FirebaseNamespace));
460+
461+
let app = new FirebaseAppImpl(options, name!, namespace as FirebaseNamespace);
462+
430463
apps_[name!] = app;
431464
callAppHooks(app, 'create');
432465

433-
// Ensure that getUid, getToken, addAuthListener and removeAuthListener
434-
// have a default implementation if no service has patched the App
435-
// (i.e., Auth is not present).
436-
if (app.INTERNAL == undefined || app.INTERNAL.getToken == undefined) {
437-
deepExtend(app, {
438-
INTERNAL: {
439-
'getUid': () => null,
440-
'getToken': () => LocalPromise.resolve(null),
441-
'addAuthTokenListener': (callback: (token: string|null) => void) => {
442-
// Make sure callback is called, asynchronously, in the absence of the auth module
443-
setTimeout(() => callback(null), 0);
444-
},
445-
'removeAuthTokenListener': () => { /*_*/ },
446-
}
447-
});
448-
}
449466
return app;
450467
}
451468

@@ -471,39 +488,32 @@ export function createFirebaseNamespace(): FirebaseNamespace {
471488
appHook?: AppHook,
472489
allowMultipleInstances?: boolean):
473490
FirebaseServiceNamespace<FirebaseService> {
491+
// Cannot re-register a service that already exists
474492
if (factories[name]) {
475493
error('duplicate-service', {'name': name});
476494
}
477-
if (!!allowMultipleInstances) {
478-
// Check if the service allows multiple instances per app
479-
factories[name] = createService;
480-
} else {
481-
// If not, always return the same instance when a service is instantiated
482-
// with an instanceString different than the default.
483-
factories[name] =
484-
(app: FirebaseApp, extendApp?: (props: {[prop: string]: any}) => void,
485-
instanceString?: string) => {
486-
// If a new instance is requested for a service that does not allow
487-
// multiple instances, return the default instance
488-
return createService(app, extendApp, DEFAULT_ENTRY_NAME);
489-
};
490-
}
495+
496+
// Capture the service factory for later service instantiation
497+
factories[name] = createService;
498+
499+
// Capture the appHook, if passed
491500
if (appHook) {
492501
appHooks[name] = appHook;
493-
}
494502

495-
let serviceNamespace: FirebaseServiceNamespace<FirebaseService>;
503+
// Run the **new** app hook on all existing apps
504+
getApps().forEach(app => {
505+
appHook('create', app);
506+
});
507+
}
496508

497509
// The Service namespace is an accessor function ...
498-
serviceNamespace = (appArg?: FirebaseApp) => {
499-
if (appArg === undefined) {
500-
appArg = app();
501-
}
510+
const serviceNamespace = (appArg: FirebaseApp = app()) => {
502511
if (typeof(appArg as any)[name] !== 'function') {
503512
// Invalid argument.
504513
// This happens in the following case: firebase.storage('gs:/')
505514
error('invalid-app-argument', {'name': name});
506515
}
516+
507517
// Forward service instance lookup to the FirebaseApp.
508518
return (appArg as any)[name]();
509519
};
@@ -516,6 +526,12 @@ export function createFirebaseNamespace(): FirebaseNamespace {
516526
// Monkey-patch the serviceNamespace onto the firebase namespace
517527
(namespace as any)[name] = serviceNamespace;
518528

529+
// Patch the FirebaseAppImpl prototype
530+
FirebaseAppImpl.prototype[name] = function(...args) {
531+
const serviceFxn = this._getService.bind(this, name);
532+
return serviceFxn.apply(this, allowMultipleInstances ? args : []);
533+
}
534+
519535
return serviceNamespace;
520536
}
521537

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
var assert = require('chai').assert;
2+
// Partial inclusion is a browser-only feature
3+
var firebase = require('../../../dist/package/app');
4+
var helper = require('./test-helper.js');
5+
6+
describe("Lazy Firebase App (" + helper.getPackagerName() + ")", function() {
7+
it("firebase namespace", function() {
8+
assert.isDefined(firebase);
9+
});
10+
11+
it("SDK_VERSION", function() {
12+
assert.isDefined(firebase.SDK_VERSION);
13+
});
14+
15+
it('Should allow for lazy component init', function() {
16+
assert.isUndefined(firebase.database);
17+
firebase.initializeApp({});
18+
require('../../../dist/package/database');
19+
assert.isDefined(firebase.database);
20+
});
21+
});

tests/app/unit/firebase_app.test.ts

+116-3
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,121 @@ describe("Firebase App Class", () => {
166166
assert.equal(registrations, 2);
167167
});
168168

169+
it("Can lazy load a service", () => {
170+
let registrations = 0;
171+
172+
const app1 = firebase.initializeApp({});
173+
assert.isUndefined((app1 as any).lazyService);
174+
175+
firebase.INTERNAL.registerService('lazyService', (app: FirebaseApp) => {
176+
registrations += 1;
177+
return new TestService(app);
178+
});
179+
180+
assert.isDefined((app1 as any).lazyService);
181+
182+
// Initial service registration happens on first invocation
183+
assert.equal(registrations, 0);
184+
185+
// Verify service has been registered
186+
(firebase as any).lazyService();
187+
assert.equal(registrations, 1);
188+
189+
// Service should only be created once
190+
(firebase as any).lazyService();
191+
assert.equal(registrations, 1);
192+
193+
// Service should only be created once... regardless of how you invoke the function
194+
(firebase as any).lazyService(app1);
195+
assert.equal(registrations, 1);
196+
197+
// Service should already be defined for the second app
198+
const app2 = firebase.initializeApp({}, 'second');
199+
assert.isDefined((app1 as any).lazyService);
200+
201+
// Service still should not have registered for the second app
202+
assert.equal(registrations, 1);
203+
204+
// Service should initialize once called
205+
(app2 as any).lazyService();
206+
assert.equal(registrations, 2);
207+
});
208+
209+
it("Can lazy register App Hook", (done) => {
210+
let events = ['create', 'delete'];
211+
let hookEvents = 0;
212+
const app = firebase.initializeApp({});
213+
firebase.INTERNAL.registerService(
214+
'lazyServiceWithHook',
215+
(app: FirebaseApp) => {
216+
return new TestService(app);
217+
},
218+
undefined,
219+
(event: string, app: FirebaseApp) => {
220+
assert.equal(event, events[hookEvents]);
221+
hookEvents += 1;
222+
if (hookEvents === events.length) {
223+
done();
224+
}
225+
});
226+
// Ensure the hook is called synchronously
227+
assert.equal(hookEvents, 1);
228+
app.delete();
229+
});
230+
231+
it('Can register multiple instances of some services', () => {
232+
// Register Multi Instance Service
233+
firebase.INTERNAL.registerService(
234+
'multiInstance',
235+
(...args) => {
236+
const [app,,instanceIdentifier] = args;
237+
return new TestService(app, instanceIdentifier);
238+
},
239+
null,
240+
null,
241+
true
242+
);
243+
firebase.initializeApp({});
244+
245+
// Capture a given service ref
246+
const service = (firebase.app() as any).multiInstance();
247+
assert.strictEqual(service, (firebase.app() as any).multiInstance());
248+
249+
// Capture a custom instance service ref
250+
const serviceIdentifier = 'custom instance identifier';
251+
const service2 = (firebase.app() as any).multiInstance(serviceIdentifier);
252+
assert.strictEqual(service2, (firebase.app() as any).multiInstance(serviceIdentifier));
253+
254+
// Ensure that the two services **are not equal**
255+
assert.notStrictEqual(service.instanceIdentifier, service2.instanceIdentifier, '`instanceIdentifier` is not being set correctly');
256+
assert.notStrictEqual(service, service2);
257+
assert.notStrictEqual((firebase.app() as any).multiInstance(), (firebase.app() as any).multiInstance(serviceIdentifier));
258+
});
259+
260+
it(`Should return the same instance of a service if a service doesn't support multi instance`, () => {
261+
// Register Multi Instance Service
262+
firebase.INTERNAL.registerService(
263+
'singleInstance',
264+
(...args) => {
265+
const [app,,instanceIdentifier] = args;
266+
return new TestService(app, instanceIdentifier)
267+
},
268+
null,
269+
null,
270+
false // <-- multi instance flag
271+
);
272+
firebase.initializeApp({});
273+
274+
// Capture a given service ref
275+
const serviceIdentifier = 'custom instance identifier';
276+
const service = (firebase.app() as any).singleInstance();
277+
const service2 = (firebase.app() as any).singleInstance(serviceIdentifier);
278+
279+
// Ensure that the two services **are equal**
280+
assert.strictEqual(service.instanceIdentifier, service2.instanceIdentifier, '`instanceIdentifier` is not being set correctly');
281+
assert.strictEqual(service, service2);
282+
});
283+
169284
describe("Check for bad app names", () => {
170285
let tests = ["", 123, false, null];
171286
for (let data of tests) {
@@ -179,9 +294,7 @@ describe("Firebase App Class", () => {
179294
});
180295

181296
class TestService implements FirebaseService {
182-
constructor(private app_: FirebaseApp) {
183-
// empty
184-
}
297+
constructor(private app_: FirebaseApp, public instanceIdentifier?: string) {}
185298

186299
// TODO(koss): Shouldn't this just be an added method on
187300
// the service instance?

0 commit comments

Comments
 (0)