Skip to content

Commit 64b1924

Browse files
author
Brian Chen
authored
Adding onSnapshotsInSync (not public) (#2100)
1 parent 0462045 commit 64b1924

File tree

9 files changed

+411
-9
lines changed

9 files changed

+411
-9
lines changed

packages/firestore/src/api/database.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,58 @@ export class Firestore implements firestore.FirebaseFirestore, FirebaseService {
452452
return this._firestoreClient!.waitForPendingWrites();
453453
}
454454

455+
/**
456+
* Attaches a listener for a snapshots-in-sync event. The snapshots-in-sync
457+
* event indicates that all listeners affected by a given change have fired,
458+
* even if a single server-generated change affects multiple listeners.
459+
*
460+
* NOTE: The snapshots-in-sync event only indicates that listeners are in sync
461+
* with each other, but does not relate to whether those snapshots are in sync
462+
* with the server. Use SnapshotMetadata in the individual listeners to
463+
* determine if a snapshot is from the cache or the server.
464+
*
465+
* @param onSync A callback to be called every time all snapshot listeners are
466+
* in sync with each other.
467+
* @return An unsubscribe function that can be called to cancel the snapshot
468+
* listener.
469+
*/
470+
_onSnapshotsInSync(observer: PartialObserver<void>): Unsubscribe;
471+
_onSnapshotsInSync(onSync: () => void): Unsubscribe;
472+
_onSnapshotsInSync(arg: unknown): Unsubscribe {
473+
this.ensureClientConfigured();
474+
475+
if (isPartialObserver(arg)) {
476+
return this.onSnapshotsInSyncInternal(arg as PartialObserver<void>);
477+
} else {
478+
validateArgType('Firestore.onSnapshotsInSync', 'function', 1, arg);
479+
const observer: PartialObserver<void> = {
480+
next: arg as () => void
481+
};
482+
return this.onSnapshotsInSyncInternal(observer);
483+
}
484+
}
485+
486+
private onSnapshotsInSyncInternal(
487+
observer: PartialObserver<void>
488+
): Unsubscribe {
489+
const errHandler = (err: Error): void => {
490+
throw fail('Uncaught Error in onSnapshotsInSync');
491+
};
492+
const asyncObserver = new AsyncObserver<void>({
493+
next: () => {
494+
if (observer.next) {
495+
observer.next();
496+
}
497+
},
498+
error: errHandler
499+
});
500+
this._firestoreClient!.addSnapshotsInSyncListener(asyncObserver);
501+
return () => {
502+
asyncObserver.mute();
503+
this._firestoreClient!.removeSnapshotsInSyncListener(asyncObserver);
504+
};
505+
}
506+
455507
ensureClientConfigured(): FirestoreClient {
456508
if (!this._firestoreClient) {
457509
// Kick off starting the client but don't actually wait for it.

packages/firestore/src/core/event_manager.ts

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export class EventManager implements SyncEngineListener {
5353

5454
private onlineState: OnlineState = OnlineState.Unknown;
5555

56+
private snapshotsInSyncListeners: Set<Observer<void>> = new Set();
57+
5658
constructor(private syncEngine: SyncEngine) {
5759
this.syncEngine.subscribe(this);
5860
}
@@ -69,10 +71,18 @@ export class EventManager implements SyncEngineListener {
6971
}
7072
queryInfo.listeners.push(listener);
7173

72-
listener.applyOnlineStateChange(this.onlineState);
74+
// Run global snapshot listeners if a consistent snapshot has been emitted.
75+
const raisedEvent = listener.applyOnlineStateChange(this.onlineState);
76+
assert(
77+
!raisedEvent,
78+
"applyOnlineStateChange() shouldn't raise an event for brand-new listeners."
79+
);
7380

7481
if (queryInfo.viewSnap) {
75-
listener.onViewSnapshot(queryInfo.viewSnap);
82+
const raisedEvent = listener.onViewSnapshot(queryInfo.viewSnap);
83+
if (raisedEvent) {
84+
this.raiseSnapshotsInSyncEvent();
85+
}
7686
}
7787

7888
if (firstListen) {
@@ -105,16 +115,22 @@ export class EventManager implements SyncEngineListener {
105115
}
106116

107117
onWatchChange(viewSnaps: ViewSnapshot[]): void {
118+
let raisedEvent = false;
108119
for (const viewSnap of viewSnaps) {
109120
const query = viewSnap.query;
110121
const queryInfo = this.queries.get(query);
111122
if (queryInfo) {
112123
for (const listener of queryInfo.listeners) {
113-
listener.onViewSnapshot(viewSnap);
124+
if (listener.onViewSnapshot(viewSnap)) {
125+
raisedEvent = true;
126+
}
114127
}
115128
queryInfo.viewSnap = viewSnap;
116129
}
117130
}
131+
if (raisedEvent) {
132+
this.raiseSnapshotsInSyncEvent();
133+
}
118134
}
119135

120136
onWatchError(query: Query, error: Error): void {
@@ -132,11 +148,36 @@ export class EventManager implements SyncEngineListener {
132148

133149
onOnlineStateChange(onlineState: OnlineState): void {
134150
this.onlineState = onlineState;
151+
let raisedEvent = false;
135152
this.queries.forEach((_, queryInfo) => {
136153
for (const listener of queryInfo.listeners) {
137-
listener.applyOnlineStateChange(onlineState);
154+
// Run global snapshot listeners if a consistent snapshot has been emitted.
155+
if (listener.applyOnlineStateChange(onlineState)) {
156+
raisedEvent = true;
157+
}
138158
}
139159
});
160+
if (raisedEvent) {
161+
this.raiseSnapshotsInSyncEvent();
162+
}
163+
}
164+
165+
addSnapshotsInSyncListener(observer: Observer<void>): void {
166+
this.snapshotsInSyncListeners.add(observer);
167+
// Immediately fire an initial event, indicating all existing listeners
168+
// are in-sync.
169+
observer.next();
170+
}
171+
172+
removeSnapshotsInSyncListener(observer: Observer<void>): void {
173+
this.snapshotsInSyncListeners.delete(observer);
174+
}
175+
176+
// Call all global snapshot listeners that have been set.
177+
private raiseSnapshotsInSyncEvent(): void {
178+
this.snapshotsInSyncListeners.forEach(observer => {
179+
observer.next();
180+
});
140181
}
141182
}
142183

@@ -178,7 +219,13 @@ export class QueryListener {
178219
this.options = options || {};
179220
}
180221

181-
onViewSnapshot(snap: ViewSnapshot): void {
222+
/**
223+
* Applies the new ViewSnapshot to this listener, raising a user-facing event
224+
* if applicable (depending on what changed, whether the user has opted into
225+
* metadata-only changes, etc.). Returns true if a user-facing event was
226+
* indeed raised.
227+
*/
228+
onViewSnapshot(snap: ViewSnapshot): boolean {
182229
assert(
183230
snap.docChanges.length > 0 || snap.syncStateChanged,
184231
'We got a new snapshot with no changes?'
@@ -203,31 +250,38 @@ export class QueryListener {
203250
/* excludesMetadataChanges= */ true
204251
);
205252
}
206-
253+
let raisedEvent = false;
207254
if (!this.raisedInitialEvent) {
208255
if (this.shouldRaiseInitialEvent(snap, this.onlineState)) {
209256
this.raiseInitialEvent(snap);
257+
raisedEvent = true;
210258
}
211259
} else if (this.shouldRaiseEvent(snap)) {
212260
this.queryObserver.next(snap);
261+
raisedEvent = true;
213262
}
214263

215264
this.snap = snap;
265+
return raisedEvent;
216266
}
217267

218268
onError(error: Error): void {
219269
this.queryObserver.error(error);
220270
}
221271

222-
applyOnlineStateChange(onlineState: OnlineState): void {
272+
/** Returns whether a snapshot was raised. */
273+
applyOnlineStateChange(onlineState: OnlineState): boolean {
223274
this.onlineState = onlineState;
275+
let raisedEvent = false;
224276
if (
225277
this.snap &&
226278
!this.raisedInitialEvent &&
227279
this.shouldRaiseInitialEvent(this.snap, onlineState)
228280
) {
229281
this.raiseInitialEvent(this.snap);
282+
raisedEvent = true;
230283
}
284+
return raisedEvent;
231285
}
232286

233287
private shouldRaiseInitialEvent(

packages/firestore/src/core/firestore_client.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,23 @@ export class FirestoreClient {
612612
return this.databaseInfo.databaseId;
613613
}
614614

615+
addSnapshotsInSyncListener(observer: Observer<void>): void {
616+
this.verifyNotTerminated();
617+
this.asyncQueue.enqueueAndForget(() => {
618+
this.eventMgr.addSnapshotsInSyncListener(observer);
619+
return Promise.resolve();
620+
});
621+
}
622+
623+
removeSnapshotsInSyncListener(observer: Observer<void>): void {
624+
// Checks for shutdown but does not raise error, allowing remove after
625+
// shutdown to be a no-op.
626+
if (this.clientTerminated) {
627+
return;
628+
}
629+
this.eventMgr.removeSnapshotsInSyncListener(observer);
630+
}
631+
615632
get clientTerminated(): boolean {
616633
// Technically, the asyncQueue is still running, but only accepting operations
617634
// related to termination or supposed to be run after termination. It is effectively

packages/firestore/test/integration/api/database.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
withTestDoc,
3838
withTestDocAndInitialData,
3939
DEFAULT_SETTINGS,
40+
onSnapshotsInSync,
4041
withMockCredentialProviderTestDb
4142
} from '../util/helpers';
4243
import { User } from '../../../src/auth/user';
@@ -541,6 +542,43 @@ apiDescribe('Database', (persistence: boolean) => {
541542
});
542543
});
543544

545+
it('onSnapshotsInSync fires after listeners are in sync', () => {
546+
const testDocs = {
547+
a: { foo: 1 }
548+
};
549+
return withTestCollection(persistence, testDocs, async coll => {
550+
let events: string[] = [];
551+
const gotInitialSnapshot = new Deferred<void>();
552+
const doc = coll.doc('a');
553+
554+
doc.onSnapshot(snap => {
555+
events.push('doc');
556+
gotInitialSnapshot.resolve();
557+
});
558+
await gotInitialSnapshot.promise;
559+
events = [];
560+
561+
const done = new Deferred<void>();
562+
onSnapshotsInSync(doc.firestore, () => {
563+
events.push('snapshots-in-sync');
564+
if (events.length === 3) {
565+
// We should have an initial snapshots-in-sync event, then a snapshot
566+
// event for set(), then another event to indicate we're in sync
567+
// again.
568+
expect(events).to.deep.equal([
569+
'snapshots-in-sync',
570+
'doc',
571+
'snapshots-in-sync'
572+
]);
573+
done.resolve();
574+
}
575+
});
576+
577+
await doc.set({ foo: 3 });
578+
await done.promise;
579+
});
580+
});
581+
544582
apiDescribe('Queries are validated client-side', (persistence: boolean) => {
545583
// NOTE: Failure cases are validated in validation_test.ts
546584

packages/firestore/test/integration/api/validation.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ import {
2929
inOp,
3030
withAlternateTestDb,
3131
withTestCollection,
32-
withTestDb
32+
withTestDb,
33+
onSnapshotsInSync
3334
} from '../util/helpers';
3435

3536
// tslint:disable:no-floating-promises
@@ -335,6 +336,11 @@ apiDescribe('Validation:', (persistence: boolean) => {
335336
`Unknown option 'bad' passed to function ` +
336337
`Query.onSnapshot(). Available options: includeMetadataChanges`
337338
);
339+
340+
expect(() => onSnapshotsInSync(db, 'bad')).to.throw(
341+
`Function Firestore.onSnapshotsInSync() requires its first ` +
342+
`argument to be of type function, but it was: "bad"`
343+
);
338344
});
339345

340346
validationIt(persistence, 'get options are validated', db => {

packages/firestore/test/integration/util/helpers.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,15 @@ function wipeDb(db: firestore.FirebaseFirestore): Promise<void> {
358358
return Promise.resolve(undefined);
359359
}
360360

361+
// TODO(b/139890752): Remove helper and use public API once this is launched.
362+
export function onSnapshotsInSync(
363+
db: firestore.FirebaseFirestore,
364+
onSync: unknown
365+
): () => void {
366+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
367+
return (db as any)._onSnapshotsInSync(onSync);
368+
}
369+
361370
// TODO(in-queries): This exists just so we don't have to do the cast
362371
// repeatedly. Once we expose 'array-contains-any' publicly we can remove it and
363372
// just use 'array-contains-any' in all the tests.

0 commit comments

Comments
 (0)