Skip to content

Commit f0355ed

Browse files
Support lazy-loaded Auth
1 parent d095ad3 commit f0355ed

12 files changed

+190
-79
lines changed

packages/component/src/provider.ts

+39-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import {
2222
InitializeOptions,
2323
InstantiationMode,
2424
Name,
25-
NameServiceMapping
25+
NameServiceMapping,
26+
OnInitCallBack
2627
} from './types';
2728
import { Component } from './component';
2829

@@ -37,6 +38,7 @@ export class Provider<T extends Name> {
3738
string,
3839
Deferred<NameServiceMapping[T]>
3940
> = new Map();
41+
private onInitCallbacks: Set<OnInitCallBack<T>> = new Set();
4042

4143
constructor(
4244
private readonly name: T,
@@ -250,9 +252,45 @@ export class Provider<T extends Name> {
250252
instanceDeferred.resolve(instance);
251253
}
252254
}
255+
256+
this.invokeOnInitCallbacks(instance, normalizedIdentifier);
253257
return instance;
254258
}
255259

260+
/**
261+
*
262+
* @param callback - a function that will be invoked after the provider has
263+
* been initialized by calling provider.initialize().
264+
* The function is invoked SYNCHRONOUSLY, so it should not execute any
265+
* longrunning tasks in order to not block the program.
266+
*
267+
* @returns a function to unregister the callback
268+
*/
269+
onInit(callback: (instance: NameServiceMapping[T]) => void): () => void {
270+
this.onInitCallbacks.add(callback);
271+
272+
return () => {
273+
this.onInitCallbacks.delete(callback);
274+
};
275+
}
276+
277+
/**
278+
* Invoke onInit callbacks synchronously
279+
* @param instance the service instance`
280+
*/
281+
private invokeOnInitCallbacks(
282+
instance: NameServiceMapping[T],
283+
identifier: string
284+
): void {
285+
for (const callback of this.onInitCallbacks) {
286+
try {
287+
callback(instance, identifier);
288+
} catch {
289+
// ignore errors in the onInit callback
290+
}
291+
}
292+
}
293+
256294
private getOrInitializeService({
257295
instanceIdentifier,
258296
options = {}

packages/component/src/types.ts

+5
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,8 @@ export interface NameServiceMapping {}
7575

7676
export type Name = keyof NameServiceMapping;
7777
export type Service = NameServiceMapping[Name];
78+
79+
export type OnInitCallBack<T extends Name> = (
80+
instance: NameServiceMapping[T],
81+
identifier: string
82+
) => void;

packages/firestore/.idea/runConfigurations/All_Tests__Emulator_.xml

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/firestore/.idea/runConfigurations/All_Tests__Emulator_w__Mock_Persistence_.xml

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/firestore/.idea/runConfigurations/Integration_Tests__Emulator_.xml

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/firestore/.idea/runConfigurations/Integration_Tests__Emulator_w__Mock_Persistence_.xml

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/firestore/.idea/runConfigurations/Unit_Tests.xml

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/firestore/.idea/runConfigurations/Unit_Tests__w__Mock_Persistence_.xml

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/firestore/src/api/credentials.ts

+116-42
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ import { Provider } from '@firebase/component';
2323

2424
import { User } from '../auth/user';
2525
import { hardAssert, debugAssert } from '../util/assert';
26+
import { AsyncQueue } from '../util/async_queue';
2627
import { Code, FirestoreError } from '../util/error';
2728
import { logDebug } from '../util/log';
29+
import { Deferred } from '../util/promise';
2830

2931
// TODO(mikelehen): This should be split into multiple files and probably
3032
// moved to an auth/ folder to match other platforms.
@@ -78,7 +80,7 @@ export class OAuthToken implements Token {
7880
* token and may need to invalidate other state if the current user has also
7981
* changed.
8082
*/
81-
export type CredentialChangeListener = (user: User) => void;
83+
export type CredentialChangeListener = (user: User) => Promise<void>;
8284

8385
/**
8486
* Provides methods for getting the uid and token for the current user and
@@ -98,8 +100,13 @@ export interface CredentialsProvider {
98100
* Specifies a listener to be notified of credential changes
99101
* (sign-in / sign-out, token changes). It is immediately called once with the
100102
* initial user.
103+
*
104+
* The change listener is invoked on the provided AsyncQueue.
101105
*/
102-
setChangeListener(changeListener: CredentialChangeListener): void;
106+
setChangeListener(
107+
asyncQueue: AsyncQueue,
108+
changeListener: CredentialChangeListener
109+
): void;
103110

104111
/** Removes the previously-set change listener. */
105112
removeChangeListener(): void;
@@ -120,14 +127,20 @@ export class EmptyCredentialsProvider implements CredentialsProvider {
120127

121128
invalidateToken(): void {}
122129

123-
setChangeListener(changeListener: CredentialChangeListener): void {
130+
setChangeListener(
131+
asyncQueue: AsyncQueue,
132+
changeListener: CredentialChangeListener
133+
): void {
124134
debugAssert(
125135
!this.changeListener,
126136
'Can only call setChangeListener() once.'
127137
);
128138
this.changeListener = changeListener;
129139
// Fire with initial user.
130-
changeListener(User.UNAUTHENTICATED);
140+
asyncQueue.enqueueRetryable(() => {
141+
changeListener(User.FIRST_PARTY);
142+
return Promise.resolve();
143+
});
131144
}
132145

133146
removeChangeListener(): void {
@@ -175,11 +188,25 @@ export class FirebaseCredentialsProvider implements CredentialsProvider {
175188
* The auth token listener registered with FirebaseApp, retained here so we
176189
* can unregister it.
177190
*/
178-
private tokenListener: ((token: string | null) => void) | null = null;
191+
private tokenListener: (() => void) | null = null;
179192

180193
/** Tracks the current User. */
181194
private currentUser: User = User.UNAUTHENTICATED;
182-
private receivedInitialUser: boolean = false;
195+
196+
/**
197+
* Promise that allows blocking on the next `tokenChange` event. The Promise
198+
* is re-assgined in `awaitTokenAndRaiseInitialEvent()` to allow blocking on
199+
* an a lazily loaded Auth instance. In this case, `this.receivedUser`
200+
* resolves once when the SDK first detects that there is no synchronous
201+
* Auth, and then gets re-created and resolves again once Auth is loaded.
202+
*/
203+
private receivedUser = new Deferred();
204+
205+
/**
206+
* Whether the initial token event has been raised. This can go back to
207+
* `false` if Firestore first starts without Auth and Auth is loaded later.
208+
*/
209+
private initialEventRaised: boolean = false;
183210

184211
/**
185212
* Counter used to detect if the token changed while a getToken request was
@@ -188,44 +215,62 @@ export class FirebaseCredentialsProvider implements CredentialsProvider {
188215
private tokenCounter = 0;
189216

190217
/** The listener registered with setChangeListener(). */
191-
private changeListener: CredentialChangeListener | null = null;
218+
private changeListener: CredentialChangeListener = () => Promise.resolve();
192219

193220
private forceRefresh = false;
194221

195-
private auth: FirebaseAuthInternal | null;
222+
private auth: FirebaseAuthInternal | null = null;
223+
224+
private asyncQueue: AsyncQueue | null = null;
196225

197226
constructor(authProvider: Provider<FirebaseAuthInternalName>) {
198227
this.tokenListener = () => {
199228
this.tokenCounter++;
229+
this.receivedUser.resolve();
200230
this.currentUser = this.getUser();
201-
this.receivedInitialUser = true;
202-
if (this.changeListener) {
203-
this.changeListener(this.currentUser);
231+
if (this.initialEventRaised && this.asyncQueue) {
232+
// We only invoke the change listener here if the initial event has been
233+
// raised. The initial event itself is invoked synchronously in
234+
// `awaitTokenAndRaiseInitialEvent()`.
235+
this.asyncQueue.enqueueRetryable(() => {
236+
this.changeListener(this.currentUser);
237+
return Promise.resolve();
238+
});
204239
}
205240
};
206241

207242
this.tokenCounter = 0;
208243

209-
this.auth = authProvider.getImmediate({ optional: true });
244+
const registerAuth = (auth: FirebaseAuthInternal): void => {
245+
logDebug('FirebaseCredentialsProvider', 'Auth detected');
246+
this.auth = auth;
247+
if (this.tokenListener) {
248+
// tokenListener can be removed by removeChangeListener()
249+
this.awaitTokenAndRaiseInitialEvent();
250+
this.auth.addAuthTokenListener(this.tokenListener);
251+
}
252+
};
210253

211-
if (this.auth) {
212-
this.auth.addAuthTokenListener(this.tokenListener!);
213-
} else {
214-
// if auth is not available, invoke tokenListener once with null token
215-
this.tokenListener(null);
216-
authProvider.get().then(
217-
auth => {
218-
this.auth = auth;
219-
if (this.tokenListener) {
220-
// tokenListener can be removed by removeChangeListener()
221-
this.auth.addAuthTokenListener(this.tokenListener);
222-
}
223-
},
224-
() => {
225-
/* this.authProvider.get() never rejects */
254+
authProvider.onInit(auth => registerAuth(auth));
255+
256+
// Our users can initialize Auth right after Firestore, so we give it
257+
// a chance to register itself with the component framework before we
258+
// determine whether to start up in unauthenticated mode.
259+
setTimeout(() => {
260+
if (!this.auth) {
261+
const auth = authProvider.getImmediate({ optional: true });
262+
if (auth) {
263+
registerAuth(auth);
264+
} else if (this.tokenListener) {
265+
// If auth is still not available, invoke tokenListener once with null
266+
// token
267+
logDebug('FirebaseCredentialsProvider', 'Auth not yet detected');
268+
this.tokenListener();
226269
}
227-
);
228-
}
270+
}
271+
}, 0);
272+
273+
this.awaitTokenAndRaiseInitialEvent();
229274
}
230275

231276
getToken(): Promise<Token | null> {
@@ -273,25 +318,21 @@ export class FirebaseCredentialsProvider implements CredentialsProvider {
273318
this.forceRefresh = true;
274319
}
275320

276-
setChangeListener(changeListener: CredentialChangeListener): void {
277-
debugAssert(
278-
!this.changeListener,
279-
'Can only call setChangeListener() once.'
280-
);
321+
setChangeListener(
322+
asyncQueue: AsyncQueue,
323+
changeListener: CredentialChangeListener
324+
): void {
325+
debugAssert(!this.asyncQueue, 'Can only call setChangeListener() once.');
326+
this.asyncQueue = asyncQueue;
281327
this.changeListener = changeListener;
282-
283-
// Fire the initial event
284-
if (this.receivedInitialUser) {
285-
changeListener(this.currentUser);
286-
}
287328
}
288329

289330
removeChangeListener(): void {
290331
if (this.auth) {
291332
this.auth.removeAuthTokenListener(this.tokenListener!);
292333
}
293334
this.tokenListener = null;
294-
this.changeListener = null;
335+
this.changeListener = () => Promise.resolve();
295336
}
296337

297338
// Auth.getUid() can return null even with a user logged in. It is because
@@ -306,6 +347,33 @@ export class FirebaseCredentialsProvider implements CredentialsProvider {
306347
);
307348
return new User(currentUid);
308349
}
350+
351+
/**
352+
* Blocks the AsyncQueue until the next user is available. This is invoked
353+
* on SDK start to wait for the first user token (or `null` if Auth is not yet
354+
* loaded). If Auth is loaded after Firestore,
355+
* `awaitTokenAndRaiseInitialEvent()` is also used to block Firestore until
356+
* Auth is fully initialized.
357+
*
358+
* This function also invokes the change listener synchronously once a token
359+
* is available.
360+
*/
361+
private awaitTokenAndRaiseInitialEvent(): void {
362+
this.initialEventRaised = false;
363+
if (this.asyncQueue) {
364+
// Create a new deferred Promise that gets resolved when we receive the
365+
// next token. Ensure that all previous Promises also get resolved.
366+
const awaitToken = new Deferred<void>();
367+
void awaitToken.promise.then(() => awaitToken.resolve());
368+
this.receivedUser = awaitToken;
369+
370+
this.asyncQueue.enqueueRetryable(async () => {
371+
await awaitToken.promise;
372+
await this.changeListener(this.currentUser);
373+
});
374+
}
375+
this.initialEventRaised = true;
376+
}
309377
}
310378

311379
// Manual type definition for the subset of Gapi we use.
@@ -368,9 +436,15 @@ export class FirstPartyCredentialsProvider implements CredentialsProvider {
368436
);
369437
}
370438

371-
setChangeListener(changeListener: CredentialChangeListener): void {
439+
setChangeListener(
440+
asyncQueue: AsyncQueue,
441+
changeListener: CredentialChangeListener
442+
): void {
372443
// Fire with initial uid.
373-
changeListener(User.FIRST_PARTY);
444+
asyncQueue.enqueueRetryable(() => {
445+
changeListener(User.FIRST_PARTY);
446+
return Promise.resolve();
447+
});
374448
}
375449

376450
removeChangeListener(): void {}

0 commit comments

Comments
 (0)