Skip to content

Commit d7992ea

Browse files
astanbmikehardy
authored andcommitted
feat(firestore): add 'source' option to enable local cache firestore listeners
- create ListenSource type to maintain parity with JS SDK - add test helper for out-of-band writes for local / server e2e testing
1 parent fc8b839 commit d7992ea

17 files changed

Lines changed: 457 additions & 42 deletions

packages/firestore/__tests__/firestore.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import FirebaseModule from '../../app/lib/internal/FirebaseModule';
77
import Query from '../lib/FirestoreQuery';
88
// @ts-ignore test
99
import FirestoreDocumentSnapshot from '../lib/FirestoreDocumentSnapshot';
10+
import { parseSnapshotArgs } from '../lib/utils';
1011
// @ts-ignore test
1112
import * as nativeModule from '@react-native-firebase/app/dist/module/internal/nativeModuleAndroidIos';
1213

@@ -499,6 +500,35 @@ describe('Firestore', function () {
499500
});
500501
});
501502
});
503+
504+
describe('onSnapshot()', function () {
505+
it("accepts { source: 'cache' } listener options", function () {
506+
const parsed = parseSnapshotArgs([{ source: 'cache' }, () => {}]);
507+
508+
expect(parsed.snapshotListenOptions).toEqual({
509+
includeMetadataChanges: false,
510+
source: 'cache',
511+
});
512+
});
513+
514+
it("accepts { source: 'default', includeMetadataChanges: true } listener options", function () {
515+
const parsed = parseSnapshotArgs([
516+
{ source: 'default', includeMetadataChanges: true },
517+
() => {},
518+
]);
519+
520+
expect(parsed.snapshotListenOptions).toEqual({
521+
includeMetadataChanges: true,
522+
source: 'default',
523+
});
524+
});
525+
526+
it("throws for unsupported listener source value 'server'", function () {
527+
expect(() =>
528+
parseSnapshotArgs([{ source: 'server' as 'default' | 'cache' }, () => {}]),
529+
).toThrow("'options' SnapshotOptions.source must be one of 'default' or 'cache'.");
530+
});
531+
});
502532
});
503533

504534
describe('modular', function () {

packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreCollectionModule.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,8 @@ private void handleQueryOnSnapshot(
347347
int listenerId,
348348
ReadableMap listenerOptions) {
349349
MetadataChanges metadataChanges;
350+
SnapshotListenOptions.Builder snapshotListenOptionsBuilder =
351+
new SnapshotListenOptions.Builder();
350352

351353
if (listenerOptions != null
352354
&& listenerOptions.hasKey("includeMetadataChanges")
@@ -355,6 +357,15 @@ private void handleQueryOnSnapshot(
355357
} else {
356358
metadataChanges = MetadataChanges.EXCLUDE;
357359
}
360+
snapshotListenOptionsBuilder.setMetadataChanges(metadataChanges);
361+
362+
if (listenerOptions != null
363+
&& listenerOptions.hasKey("source")
364+
&& "cache".equals(listenerOptions.getString("source"))) {
365+
snapshotListenOptionsBuilder.setSource(ListenSource.CACHE);
366+
} else {
367+
snapshotListenOptionsBuilder.setSource(ListenSource.DEFAULT);
368+
}
358369

359370
final EventListener<QuerySnapshot> listener =
360371
(querySnapshot, exception) -> {
@@ -371,7 +382,7 @@ private void handleQueryOnSnapshot(
371382
};
372383

373384
ListenerRegistration listenerRegistration =
374-
firestoreQuery.query.addSnapshotListener(metadataChanges, listener);
385+
firestoreQuery.query.addSnapshotListener(snapshotListenOptionsBuilder.build(), listener);
375386

376387
collectionSnapshotListeners.put(listenerId, listenerRegistration);
377388
}

packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreDocumentModule.java

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,18 +78,27 @@ public void documentOnSnapshot(
7878
}
7979
};
8080

81-
MetadataChanges metadataChanges;
81+
SnapshotListenOptions.Builder snapshotListenOptionsBuilder =
82+
new SnapshotListenOptions.Builder();
8283

8384
if (listenerOptions != null
8485
&& listenerOptions.hasKey("includeMetadataChanges")
8586
&& listenerOptions.getBoolean("includeMetadataChanges")) {
86-
metadataChanges = MetadataChanges.INCLUDE;
87+
snapshotListenOptionsBuilder.setMetadataChanges(MetadataChanges.INCLUDE);
8788
} else {
88-
metadataChanges = MetadataChanges.EXCLUDE;
89+
snapshotListenOptionsBuilder.setMetadataChanges(MetadataChanges.EXCLUDE);
90+
}
91+
92+
if (listenerOptions != null
93+
&& listenerOptions.hasKey("source")
94+
&& "cache".equals(listenerOptions.getString("source"))) {
95+
snapshotListenOptionsBuilder.setSource(ListenSource.CACHE);
96+
} else {
97+
snapshotListenOptionsBuilder.setSource(ListenSource.DEFAULT);
8998
}
9099

91100
ListenerRegistration listenerRegistration =
92-
documentReference.addSnapshotListener(metadataChanges, listener);
101+
documentReference.addSnapshotListener(snapshotListenOptionsBuilder.build(), listener);
93102

94103
documentSnapshotListeners.put(listenerId, listenerRegistration);
95104
}

packages/firestore/e2e/DocumentReference/onSnapshot.e2e.js

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717
const COLLECTION = 'firestore';
1818
const NO_RULE_COLLECTION = 'no_rules';
19-
const { wipe } = require('../helpers');
19+
const { wipe, setDocumentOutOfBand } = require('../helpers');
2020

2121
describe('firestore().doc().onSnapshot()', function () {
2222
before(function () {
@@ -305,6 +305,108 @@ describe('firestore().doc().onSnapshot()', function () {
305305
}
306306
});
307307

308+
it("throws if SnapshotListenerOptions.source is invalid ('server')", function () {
309+
try {
310+
firebase.firestore().doc(`${NO_RULE_COLLECTION}/nope`).onSnapshot({
311+
source: 'server',
312+
});
313+
return Promise.reject(new Error('Did not throw an Error.'));
314+
} catch (error) {
315+
error.message.should.containEql(
316+
"'options' SnapshotOptions.source must be one of 'default' or 'cache'",
317+
);
318+
return Promise.resolve();
319+
}
320+
});
321+
322+
it('accepts source-only SnapshotListenerOptions', async function () {
323+
if (Platform.other) {
324+
return;
325+
}
326+
const callback = sinon.spy();
327+
const unsub = firebase.firestore().doc(`${COLLECTION}/source-only`).onSnapshot(
328+
{
329+
source: 'cache',
330+
},
331+
callback,
332+
);
333+
334+
await Utils.spyToBeCalledOnceAsync(callback);
335+
unsub();
336+
});
337+
338+
it('accepts source + includeMetadataChanges SnapshotListenerOptions', async function () {
339+
if (Platform.other) {
340+
return;
341+
}
342+
const callback = sinon.spy();
343+
const unsub = firebase.firestore().doc(`${COLLECTION}/source-with-metadata`).onSnapshot(
344+
{
345+
source: 'default',
346+
includeMetadataChanges: true,
347+
},
348+
callback,
349+
);
350+
351+
await Utils.spyToBeCalledOnceAsync(callback);
352+
unsub();
353+
});
354+
355+
it('cache source listeners ignore out-of-band server writes', async function () {
356+
if (Platform.other) {
357+
return;
358+
}
359+
360+
const docPath = `${COLLECTION}/${Utils.randString(12, '#aA')}`;
361+
const docRef = firebase.firestore().doc(docPath);
362+
await docRef.set({ value: 1 });
363+
await docRef.get();
364+
365+
const callback = sinon.spy();
366+
const unsub = docRef.onSnapshot({ source: 'cache' }, callback);
367+
try {
368+
await Utils.spyToBeCalledOnceAsync(callback);
369+
370+
await setDocumentOutOfBand(docPath, { value: 2 });
371+
await Utils.sleep(1500);
372+
callback.should.be.callCount(1);
373+
374+
await docRef.set({ value: 3 });
375+
await Utils.spyToBeCalledTimesAsync(callback, 2);
376+
callback.args[1][0].get('value').should.equal(3);
377+
} finally {
378+
unsub();
379+
}
380+
});
381+
382+
it('default source listeners receive out-of-band server writes', async function () {
383+
if (Platform.other) {
384+
return;
385+
}
386+
387+
const docPath = `${COLLECTION}/${Utils.randString(12, '#aA')}`;
388+
const docRef = firebase.firestore().doc(docPath);
389+
await docRef.set({ value: 1 });
390+
await docRef.get();
391+
392+
const callback = sinon.spy();
393+
const unsub = docRef.onSnapshot(
394+
{ source: 'default', includeMetadataChanges: true },
395+
callback,
396+
);
397+
try {
398+
await Utils.spyToBeCalledOnceAsync(callback);
399+
400+
await setDocumentOutOfBand(docPath, { value: 2 });
401+
await Utils.spyToBeCalledTimesAsync(callback, 2, 8000);
402+
403+
const latestSnapshot = callback.args[callback.callCount - 1][0];
404+
latestSnapshot.get('value').should.equal(2);
405+
} finally {
406+
unsub();
407+
}
408+
});
409+
308410
it('throws if next callback is invalid', function () {
309411
try {
310412
firebase.firestore().doc(`${NO_RULE_COLLECTION}/nope`).onSnapshot({
@@ -616,6 +718,58 @@ describe('firestore().doc().onSnapshot()', function () {
616718
}
617719
});
618720

721+
it("throws if SnapshotListenerOptions.source is invalid ('server')", function () {
722+
const { getFirestore, doc, onSnapshot } = firestoreModular;
723+
try {
724+
onSnapshot(doc(getFirestore(), `${NO_RULE_COLLECTION}/nope`), {
725+
source: 'server',
726+
});
727+
return Promise.reject(new Error('Did not throw an Error.'));
728+
} catch (error) {
729+
error.message.should.containEql(
730+
"'options' SnapshotOptions.source must be one of 'default' or 'cache'",
731+
);
732+
return Promise.resolve();
733+
}
734+
});
735+
736+
it('accepts source-only SnapshotListenerOptions', async function () {
737+
if (Platform.other) {
738+
return;
739+
}
740+
const { getFirestore, doc, onSnapshot } = firestoreModular;
741+
const callback = sinon.spy();
742+
const unsub = onSnapshot(
743+
doc(getFirestore(), `${COLLECTION}/mod-source-only`),
744+
{
745+
source: 'cache',
746+
},
747+
callback,
748+
);
749+
750+
await Utils.spyToBeCalledOnceAsync(callback);
751+
unsub();
752+
});
753+
754+
it('accepts source + includeMetadataChanges SnapshotListenerOptions', async function () {
755+
if (Platform.other) {
756+
return;
757+
}
758+
const { getFirestore, doc, onSnapshot } = firestoreModular;
759+
const callback = sinon.spy();
760+
const unsub = onSnapshot(
761+
doc(getFirestore(), `${COLLECTION}/mod-source-with-metadata`),
762+
{
763+
source: 'default',
764+
includeMetadataChanges: true,
765+
},
766+
callback,
767+
);
768+
769+
await Utils.spyToBeCalledOnceAsync(callback);
770+
unsub();
771+
});
772+
619773
it('throws if next callback is invalid', function () {
620774
const { getFirestore, doc, onSnapshot } = firestoreModular;
621775
try {

packages/firestore/e2e/Query/onSnapshot.e2e.js

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*
1616
*/
17-
const { wipe } = require('../helpers');
17+
const { wipe, setDocumentOutOfBand } = require('../helpers');
1818
const COLLECTION = 'firestore';
1919
const NO_RULE_COLLECTION = 'no_rules';
2020

@@ -319,6 +319,69 @@ describe('firestore().collection().onSnapshot()', function () {
319319
}
320320
});
321321

322+
it("throws if SnapshotListenerOptions.source is invalid ('server')", function () {
323+
try {
324+
firebase.firestore().collection(NO_RULE_COLLECTION).onSnapshot({
325+
source: 'server',
326+
});
327+
return Promise.reject(new Error('Did not throw an Error.'));
328+
} catch (error) {
329+
error.message.should.containEql(
330+
"'options' SnapshotOptions.source must be one of 'default' or 'cache'",
331+
);
332+
return Promise.resolve();
333+
}
334+
});
335+
336+
it('cache source query listeners ignore out-of-band server writes', async function () {
337+
if (Platform.other) {
338+
return;
339+
}
340+
341+
const collectionPath = `${COLLECTION}/${Utils.randString(12, '#aA')}/cache-source`;
342+
const colRef = firebase.firestore().collection(collectionPath);
343+
await colRef.doc('one').set({ enabled: true });
344+
await colRef.get();
345+
346+
const callback = sinon.spy();
347+
const unsub = colRef.onSnapshot({ source: 'cache' }, callback);
348+
try {
349+
await Utils.spyToBeCalledOnceAsync(callback);
350+
await setDocumentOutOfBand(`${collectionPath}/server-write`, { enabled: false });
351+
await Utils.sleep(1500);
352+
callback.should.be.callCount(1);
353+
354+
await colRef.doc('local-write').set({ enabled: true });
355+
await Utils.spyToBeCalledTimesAsync(callback, 2);
356+
} finally {
357+
unsub();
358+
}
359+
});
360+
361+
it('default source query listeners receive out-of-band server writes', async function () {
362+
if (Platform.other) {
363+
return;
364+
}
365+
366+
const collectionPath = `${COLLECTION}/${Utils.randString(12, '#aA')}/cache-source-meta`;
367+
const colRef = firebase.firestore().collection(collectionPath);
368+
await colRef.doc('one').set({ enabled: true });
369+
await colRef.get();
370+
371+
const callback = sinon.spy();
372+
const unsub = colRef.onSnapshot(
373+
{ source: 'default', includeMetadataChanges: true },
374+
callback,
375+
);
376+
try {
377+
await Utils.spyToBeCalledOnceAsync(callback);
378+
await setDocumentOutOfBand(`${collectionPath}/server-write`, { enabled: false });
379+
await Utils.spyToBeCalledTimesAsync(callback, 2, 8000);
380+
} finally {
381+
unsub();
382+
}
383+
});
384+
322385
it('throws if next callback is invalid', function () {
323386
try {
324387
firebase.firestore().collection(NO_RULE_COLLECTION).onSnapshot({
@@ -637,6 +700,21 @@ describe('firestore().collection().onSnapshot()', function () {
637700
}
638701
});
639702

703+
it("throws if SnapshotListenerOptions.source is invalid ('server')", function () {
704+
const { getFirestore, collection, onSnapshot } = firestoreModular;
705+
try {
706+
onSnapshot(collection(getFirestore(), NO_RULE_COLLECTION), {
707+
source: 'server',
708+
});
709+
return Promise.reject(new Error('Did not throw an Error.'));
710+
} catch (error) {
711+
error.message.should.containEql(
712+
"'options' SnapshotOptions.source must be one of 'default' or 'cache'",
713+
);
714+
return Promise.resolve();
715+
}
716+
});
717+
640718
it('throws if next callback is invalid', function () {
641719
const { getFirestore, collection, onSnapshot } = firestoreModular;
642720
try {

0 commit comments

Comments
 (0)