diff --git a/packages/firestore/__tests__/firestore.test.ts b/packages/firestore/__tests__/firestore.test.ts index 496dd80ea1..4088997f93 100644 --- a/packages/firestore/__tests__/firestore.test.ts +++ b/packages/firestore/__tests__/firestore.test.ts @@ -7,6 +7,7 @@ import FirebaseModule from '../../app/lib/internal/FirebaseModule'; import Query from '../lib/FirestoreQuery'; // @ts-ignore test import FirestoreDocumentSnapshot from '../lib/FirestoreDocumentSnapshot'; +import { parseSnapshotArgs } from '../lib/utils'; // @ts-ignore test import * as nativeModule from '@react-native-firebase/app/dist/module/internal/nativeModuleAndroidIos'; @@ -498,6 +499,35 @@ describe('Firestore', function () { }); }); }); + + describe('onSnapshot()', function () { + it("accepts { source: 'cache' } listener options", function () { + const parsed = parseSnapshotArgs([{ source: 'cache' }, () => {}]); + + expect(parsed.snapshotListenOptions).toEqual({ + includeMetadataChanges: false, + source: 'cache', + }); + }); + + it("accepts { source: 'default', includeMetadataChanges: true } listener options", function () { + const parsed = parseSnapshotArgs([ + { source: 'default', includeMetadataChanges: true }, + () => {}, + ]); + + expect(parsed.snapshotListenOptions).toEqual({ + includeMetadataChanges: true, + source: 'default', + }); + }); + + it("throws for unsupported listener source value 'server'", function () { + expect(() => + parseSnapshotArgs([{ source: 'server' as 'default' | 'cache' }, () => {}]), + ).toThrow("'options' SnapshotOptions.source must be one of 'default' or 'cache'."); + }); + }); }); describe('modular', function () { diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreCollectionModule.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreCollectionModule.java index e4c3833bfb..329c99150b 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreCollectionModule.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreCollectionModule.java @@ -334,6 +334,7 @@ private void handleQueryOnSnapshot( int listenerId, ReadableMap listenerOptions) { MetadataChanges metadataChanges; + SnapshotListenOptions.Builder snapshotListenOptionsBuilder = new SnapshotListenOptions.Builder(); if (listenerOptions != null && listenerOptions.hasKey("includeMetadataChanges") @@ -342,6 +343,15 @@ private void handleQueryOnSnapshot( } else { metadataChanges = MetadataChanges.EXCLUDE; } + snapshotListenOptionsBuilder.setMetadataChanges(metadataChanges); + + if (listenerOptions != null + && listenerOptions.hasKey("source") + && "cache".equals(listenerOptions.getString("source"))) { + snapshotListenOptionsBuilder.setSource(ListenSource.CACHE); + } else { + snapshotListenOptionsBuilder.setSource(ListenSource.DEFAULT); + } final EventListener listener = (querySnapshot, exception) -> { @@ -358,7 +368,7 @@ private void handleQueryOnSnapshot( }; ListenerRegistration listenerRegistration = - firestoreQuery.query.addSnapshotListener(metadataChanges, listener); + firestoreQuery.query.addSnapshotListener(snapshotListenOptionsBuilder.build(), listener); collectionSnapshotListeners.put(listenerId, listenerRegistration); } diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreDocumentModule.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreDocumentModule.java index 43c956cd9a..889aff95f0 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreDocumentModule.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreDocumentModule.java @@ -78,18 +78,26 @@ public void documentOnSnapshot( } }; - MetadataChanges metadataChanges; + SnapshotListenOptions.Builder snapshotListenOptionsBuilder = new SnapshotListenOptions.Builder(); if (listenerOptions != null && listenerOptions.hasKey("includeMetadataChanges") && listenerOptions.getBoolean("includeMetadataChanges")) { - metadataChanges = MetadataChanges.INCLUDE; + snapshotListenOptionsBuilder.setMetadataChanges(MetadataChanges.INCLUDE); } else { - metadataChanges = MetadataChanges.EXCLUDE; + snapshotListenOptionsBuilder.setMetadataChanges(MetadataChanges.EXCLUDE); + } + + if (listenerOptions != null + && listenerOptions.hasKey("source") + && "cache".equals(listenerOptions.getString("source"))) { + snapshotListenOptionsBuilder.setSource(ListenSource.CACHE); + } else { + snapshotListenOptionsBuilder.setSource(ListenSource.DEFAULT); } ListenerRegistration listenerRegistration = - documentReference.addSnapshotListener(metadataChanges, listener); + documentReference.addSnapshotListener(snapshotListenOptionsBuilder.build(), listener); documentSnapshotListeners.put(listenerId, listenerRegistration); } diff --git a/packages/firestore/e2e/DocumentReference/onSnapshot.e2e.js b/packages/firestore/e2e/DocumentReference/onSnapshot.e2e.js index f3c6a58109..d78807590b 100644 --- a/packages/firestore/e2e/DocumentReference/onSnapshot.e2e.js +++ b/packages/firestore/e2e/DocumentReference/onSnapshot.e2e.js @@ -16,7 +16,7 @@ */ const COLLECTION = 'firestore'; const NO_RULE_COLLECTION = 'no_rules'; -const { wipe } = require('../helpers'); +const { wipe, setDocumentOutOfBand } = require('../helpers'); describe('firestore().doc().onSnapshot()', function () { before(function () { @@ -305,6 +305,108 @@ describe('firestore().doc().onSnapshot()', function () { } }); + it("throws if SnapshotListenerOptions.source is invalid ('server')", function () { + try { + firebase.firestore().doc(`${NO_RULE_COLLECTION}/nope`).onSnapshot({ + source: 'server', + }); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + "'options' SnapshotOptions.source must be one of 'default' or 'cache'", + ); + return Promise.resolve(); + } + }); + + it('accepts source-only SnapshotListenerOptions', async function () { + if (Platform.other) { + return; + } + const callback = sinon.spy(); + const unsub = firebase.firestore().doc(`${COLLECTION}/source-only`).onSnapshot( + { + source: 'cache', + }, + callback, + ); + + await Utils.spyToBeCalledOnceAsync(callback); + unsub(); + }); + + it('accepts source + includeMetadataChanges SnapshotListenerOptions', async function () { + if (Platform.other) { + return; + } + const callback = sinon.spy(); + const unsub = firebase.firestore().doc(`${COLLECTION}/source-with-metadata`).onSnapshot( + { + source: 'default', + includeMetadataChanges: true, + }, + callback, + ); + + await Utils.spyToBeCalledOnceAsync(callback); + unsub(); + }); + + it('cache source listeners ignore out-of-band server writes', async function () { + if (Platform.other) { + return; + } + + const docPath = `${COLLECTION}/${Utils.randString(12, '#aA')}`; + const docRef = firebase.firestore().doc(docPath); + await docRef.set({ value: 1 }); + await docRef.get(); + + const callback = sinon.spy(); + const unsub = docRef.onSnapshot({ source: 'cache' }, callback); + try { + await Utils.spyToBeCalledOnceAsync(callback); + + await setDocumentOutOfBand(docPath, { value: 2 }); + await Utils.sleep(1500); + callback.should.be.callCount(1); + + await docRef.set({ value: 3 }); + await Utils.spyToBeCalledTimesAsync(callback, 2); + callback.args[1][0].get('value').should.equal(3); + } finally { + unsub(); + } + }); + + it('default source listeners receive out-of-band server writes', async function () { + if (Platform.other) { + return; + } + + const docPath = `${COLLECTION}/${Utils.randString(12, '#aA')}`; + const docRef = firebase.firestore().doc(docPath); + await docRef.set({ value: 1 }); + await docRef.get(); + + const callback = sinon.spy(); + const unsub = docRef.onSnapshot( + { source: 'default', includeMetadataChanges: true }, + callback, + ); + try { + await Utils.spyToBeCalledOnceAsync(callback); + + await setDocumentOutOfBand(docPath, { value: 2 }); + await Utils.spyToBeCalledTimesAsync(callback, 2, 8000); + + const latestSnapshot = callback.args[callback.callCount - 1][0]; + latestSnapshot.get('value').should.equal(2); + } finally { + unsub(); + } + }); + it('throws if next callback is invalid', function () { try { firebase.firestore().doc(`${NO_RULE_COLLECTION}/nope`).onSnapshot({ @@ -616,6 +718,58 @@ describe('firestore().doc().onSnapshot()', function () { } }); + it("throws if SnapshotListenerOptions.source is invalid ('server')", function () { + const { getFirestore, doc, onSnapshot } = firestoreModular; + try { + onSnapshot(doc(getFirestore(), `${NO_RULE_COLLECTION}/nope`), { + source: 'server', + }); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + "'options' SnapshotOptions.source must be one of 'default' or 'cache'", + ); + return Promise.resolve(); + } + }); + + it('accepts source-only SnapshotListenerOptions', async function () { + if (Platform.other) { + return; + } + const { getFirestore, doc, onSnapshot } = firestoreModular; + const callback = sinon.spy(); + const unsub = onSnapshot( + doc(getFirestore(), `${COLLECTION}/mod-source-only`), + { + source: 'cache', + }, + callback, + ); + + await Utils.spyToBeCalledOnceAsync(callback); + unsub(); + }); + + it('accepts source + includeMetadataChanges SnapshotListenerOptions', async function () { + if (Platform.other) { + return; + } + const { getFirestore, doc, onSnapshot } = firestoreModular; + const callback = sinon.spy(); + const unsub = onSnapshot( + doc(getFirestore(), `${COLLECTION}/mod-source-with-metadata`), + { + source: 'default', + includeMetadataChanges: true, + }, + callback, + ); + + await Utils.spyToBeCalledOnceAsync(callback); + unsub(); + }); + it('throws if next callback is invalid', function () { const { getFirestore, doc, onSnapshot } = firestoreModular; try { diff --git a/packages/firestore/e2e/Query/onSnapshot.e2e.js b/packages/firestore/e2e/Query/onSnapshot.e2e.js index bcc42eb6da..b6e1670298 100644 --- a/packages/firestore/e2e/Query/onSnapshot.e2e.js +++ b/packages/firestore/e2e/Query/onSnapshot.e2e.js @@ -14,7 +14,7 @@ * limitations under the License. * */ -const { wipe } = require('../helpers'); +const { wipe, setDocumentOutOfBand } = require('../helpers'); const COLLECTION = 'firestore'; const NO_RULE_COLLECTION = 'no_rules'; @@ -319,6 +319,69 @@ describe('firestore().collection().onSnapshot()', function () { } }); + it("throws if SnapshotListenerOptions.source is invalid ('server')", function () { + try { + firebase.firestore().collection(NO_RULE_COLLECTION).onSnapshot({ + source: 'server', + }); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + "'options' SnapshotOptions.source must be one of 'default' or 'cache'", + ); + return Promise.resolve(); + } + }); + + it('cache source query listeners ignore out-of-band server writes', async function () { + if (Platform.other) { + return; + } + + const collectionPath = `${COLLECTION}/${Utils.randString(12, '#aA')}/cache-source`; + const colRef = firebase.firestore().collection(collectionPath); + await colRef.doc('one').set({ enabled: true }); + await colRef.get(); + + const callback = sinon.spy(); + const unsub = colRef.onSnapshot({ source: 'cache' }, callback); + try { + await Utils.spyToBeCalledOnceAsync(callback); + await setDocumentOutOfBand(`${collectionPath}/server-write`, { enabled: false }); + await Utils.sleep(1500); + callback.should.be.callCount(1); + + await colRef.doc('local-write').set({ enabled: true }); + await Utils.spyToBeCalledTimesAsync(callback, 2); + } finally { + unsub(); + } + }); + + it('default source query listeners receive out-of-band server writes', async function () { + if (Platform.other) { + return; + } + + const collectionPath = `${COLLECTION}/${Utils.randString(12, '#aA')}/cache-source-meta`; + const colRef = firebase.firestore().collection(collectionPath); + await colRef.doc('one').set({ enabled: true }); + await colRef.get(); + + const callback = sinon.spy(); + const unsub = colRef.onSnapshot( + { source: 'default', includeMetadataChanges: true }, + callback, + ); + try { + await Utils.spyToBeCalledOnceAsync(callback); + await setDocumentOutOfBand(`${collectionPath}/server-write`, { enabled: false }); + await Utils.spyToBeCalledTimesAsync(callback, 2, 8000); + } finally { + unsub(); + } + }); + it('throws if next callback is invalid', function () { try { firebase.firestore().collection(NO_RULE_COLLECTION).onSnapshot({ @@ -637,6 +700,21 @@ describe('firestore().collection().onSnapshot()', function () { } }); + it("throws if SnapshotListenerOptions.source is invalid ('server')", function () { + const { getFirestore, collection, onSnapshot } = firestoreModular; + try { + onSnapshot(collection(getFirestore(), NO_RULE_COLLECTION), { + source: 'server', + }); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + "'options' SnapshotOptions.source must be one of 'default' or 'cache'", + ); + return Promise.resolve(); + } + }); + it('throws if next callback is invalid', function () { const { getFirestore, collection, onSnapshot } = firestoreModular; try { diff --git a/packages/firestore/e2e/helpers.js b/packages/firestore/e2e/helpers.js index 99cab707d3..1f8acd9cc9 100644 --- a/packages/firestore/e2e/helpers.js +++ b/packages/firestore/e2e/helpers.js @@ -44,6 +44,73 @@ exports.wipe = async function wipe(debug = false, databaseId = '(default)') { } }; +function toFirestoreValue(value) { + if (value === null) { + return { nullValue: null }; + } + + if (Array.isArray(value)) { + return { arrayValue: { values: value.map(toFirestoreValue) } }; + } + + if (typeof value === 'boolean') { + return { booleanValue: value }; + } + + if (typeof value === 'number') { + if (Number.isInteger(value)) { + return { integerValue: String(value) }; + } + return { doubleValue: value }; + } + + if (typeof value === 'string') { + return { stringValue: value }; + } + + if (typeof value === 'object') { + const fields = {}; + for (const [key, nestedValue] of Object.entries(value)) { + fields[key] = toFirestoreValue(nestedValue); + } + return { mapValue: { fields } }; + } + + throw new Error(`Unsupported Firestore REST value type: ${typeof value}`); +} + +exports.setDocumentOutOfBand = async function setDocumentOutOfBand( + path, + data, + databaseId = '(default)', +) { + const url = + `http://${getE2eEmulatorHost()}:8080/v1/projects/` + + getE2eTestProject() + + `/databases/${databaseId}/documents/${path}`; + + const fields = {}; + for (const [key, value] of Object.entries(data)) { + fields[key] = toFirestoreValue(value); + } + + const response = await fetch(url, { + method: 'PATCH', + headers: { + Authorization: 'Bearer owner', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ fields }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Unable to set out-of-band firestore document: ${response.status} ${body}`); + } + + return response.json(); +}; + exports.BUNDLE_QUERY_NAME = 'named-bundle-test'; exports.BUNDLE_COLLECTION = 'firestore-bundle-tests'; diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.h b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.h index 5df82d5275..926528009c 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.h +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.h @@ -24,6 +24,7 @@ #import "RNFBFirestoreSerialize.h" static NSString *const KEY_INCLUDE_METADATA_CHANGES = @"includeMetadataChanges"; +static NSString *const KEY_SOURCE = @"source"; @interface RNFBFirestoreCollectionModule : NSObject diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m index 1288cc931d..ff8ca8e457 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m @@ -337,9 +337,13 @@ - (void)handleQueryOnSnapshot:(FIRApp *)firebaseApp listenerId:(nonnull NSNumber *)listenerId listenerOptions:(NSDictionary *)listenerOptions { BOOL includeMetadataChanges = NO; + FIRListenSource source = FIRListenSourceDefault; if (listenerOptions[KEY_INCLUDE_METADATA_CHANGES] != nil) { includeMetadataChanges = [listenerOptions[KEY_INCLUDE_METADATA_CHANGES] boolValue]; } + if ([listenerOptions[KEY_SOURCE] isEqualToString:@"cache"]) { + source = FIRListenSourceCache; + } __weak RNFBFirestoreCollectionModule *weakSelf = self; id listenerBlock = ^(FIRQuerySnapshot *snapshot, NSError *error) { @@ -362,9 +366,11 @@ - (void)handleQueryOnSnapshot:(FIRApp *)firebaseApp } }; - id listener = [[firestoreQuery instance] - addSnapshotListenerWithIncludeMetadataChanges:includeMetadataChanges - listener:listenerBlock]; + FIRSnapshotListenOptions *snapshotListenOptions = [[[[FIRSnapshotListenOptions alloc] init] + optionsWithIncludeMetadataChanges:includeMetadataChanges] optionsWithSource:source]; + id listener = + [[firestoreQuery instance] addSnapshotListenerWithOptions:snapshotListenOptions + listener:listenerBlock]; collectionSnapshotListeners[listenerId] = listener; } diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreDocumentModule.h b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreDocumentModule.h index 8a314bdce1..4249e03945 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreDocumentModule.h +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreDocumentModule.h @@ -22,6 +22,9 @@ #import "RNFBFirestoreCommon.h" #import "RNFBFirestoreSerialize.h" +static NSString *const KEY_INCLUDE_METADATA_CHANGES = @"includeMetadataChanges"; +static NSString *const KEY_SOURCE = @"source"; + @interface RNFBFirestoreDocumentModule : NSObject @end diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreDocumentModule.m b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreDocumentModule.m index 2805b6d34e..6e51e42d12 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreDocumentModule.m +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreDocumentModule.m @@ -97,13 +97,19 @@ - (void)invalidate { }; BOOL includeMetadataChanges = NO; - if (listenerOptions[@"includeMetadataChanges"] != nil) { - includeMetadataChanges = [listenerOptions[@"includeMetadataChanges"] boolValue]; + FIRListenSource source = FIRListenSourceDefault; + if (listenerOptions[KEY_INCLUDE_METADATA_CHANGES] != nil) { + includeMetadataChanges = [listenerOptions[KEY_INCLUDE_METADATA_CHANGES] boolValue]; + } + if ([listenerOptions[KEY_SOURCE] isEqualToString:@"cache"]) { + source = FIRListenSourceCache; } + FIRSnapshotListenOptions *snapshotListenOptions = [[[[FIRSnapshotListenOptions alloc] init] + optionsWithIncludeMetadataChanges:includeMetadataChanges] optionsWithSource:source]; id listener = - [documentReference addSnapshotListenerWithIncludeMetadataChanges:includeMetadataChanges - listener:listenerBlock]; + [documentReference addSnapshotListenerWithOptions:snapshotListenOptions + listener:listenerBlock]; documentSnapshotListeners[listenerId] = listener; } diff --git a/packages/firestore/lib/FirestoreDocumentReference.ts b/packages/firestore/lib/FirestoreDocumentReference.ts index c3e4971ec0..586b75c278 100644 --- a/packages/firestore/lib/FirestoreDocumentReference.ts +++ b/packages/firestore/lib/FirestoreDocumentReference.ts @@ -38,7 +38,7 @@ import type DocumentSnapshot from './FirestoreDocumentSnapshot'; import type { FirestoreInternal, FirestoreSyncEventBodyInternal } from './types/internal'; import type { DocumentSnapshotNativeData } from './FirestoreDocumentSnapshot'; import type FirestorePath from './FirestorePath'; -import type { DocumentData, FirestoreDataConverter } from './types/firestore'; +import type { DocumentData, FirestoreDataConverter, ListenSource } from './types/firestore'; let FirestoreCollectionReference: | (new ( @@ -188,7 +188,7 @@ export default class DocumentReference { } onSnapshot(...args: unknown[]): () => void { - let snapshotListenOptions: { includeMetadataChanges?: boolean }; + let snapshotListenOptions: { includeMetadataChanges?: boolean; source?: ListenSource }; let callback: (snapshot: DocumentSnapshot | null, error: Error | null) => void; let onNext: (snapshot: DocumentSnapshot) => void; let onError: (error: Error) => void; diff --git a/packages/firestore/lib/FirestoreQuery.ts b/packages/firestore/lib/FirestoreQuery.ts index 94449cfc31..e3cc1d8105 100644 --- a/packages/firestore/lib/FirestoreQuery.ts +++ b/packages/firestore/lib/FirestoreQuery.ts @@ -34,7 +34,7 @@ import QuerySnapshot, { type QuerySnapshotNativeData } from './FirestoreQuerySna import { parseSnapshotArgs, validateWithConverter } from './utils'; import type FirestorePath from './FirestorePath'; -import type { DocumentData, FirestoreDataConverter } from './types/firestore'; +import type { DocumentData, FirestoreDataConverter, ListenSource } from './types/firestore'; import type { FirestoreInternal, DocumentFieldValueInternal, @@ -331,7 +331,7 @@ export default class Query { } onSnapshot(...args: unknown[]): () => void { - let snapshotListenOptions: { includeMetadataChanges?: boolean }; + let snapshotListenOptions: { includeMetadataChanges?: boolean; source?: ListenSource }; let callback: (snapshot: QuerySnapshot | null, error: Error | null) => void; let onNext: (snapshot: QuerySnapshot) => void; let onError: (error: Error) => void; diff --git a/packages/firestore/lib/types/firestore.ts b/packages/firestore/lib/types/firestore.ts index 611290b138..308774bd2d 100644 --- a/packages/firestore/lib/types/firestore.ts +++ b/packages/firestore/lib/types/firestore.ts @@ -201,8 +201,17 @@ export type QueryConstraintType = | 'endAt' | 'endBefore'; +/** + * Describe the source a query listens to. + * + * Set to `default` to listen to both cache and server changes. Set to `cache` + * to listen to changes in cache only. + */ +export type ListenSource = 'default' | 'cache'; + export interface SnapshotListenOptions { readonly includeMetadataChanges?: boolean; + readonly source?: ListenSource; } /** diff --git a/packages/firestore/lib/types/internal.ts b/packages/firestore/lib/types/internal.ts index 6703c8d46e..1172595c99 100644 --- a/packages/firestore/lib/types/internal.ts +++ b/packages/firestore/lib/types/internal.ts @@ -36,6 +36,7 @@ import type { WithFieldValue, AggregateType, PartialWithFieldValue, + ListenSource, } from './firestore'; import type { PersistentCacheIndexManager } from '../FirestorePersistentCacheIndexManager'; import type { QueryConstraint } from '../modular/query'; @@ -94,9 +95,10 @@ export interface FirestoreAggregateQueryResultInternal { [key: string]: unknown; } -/** Options for snapshot listeners (includeMetadataChanges). */ +/** Options for snapshot listeners (includeMetadataChanges, source). */ export interface FirestoreSnapshotListenOptionsInternal { includeMetadataChanges?: boolean; + source?: ListenSource; } /** Settings state on the Firestore module instance (ignoreUndefinedProperties, persistence). */ diff --git a/packages/firestore/lib/types/namespaced.ts b/packages/firestore/lib/types/namespaced.ts index e5f42e6341..31cb36a9dc 100644 --- a/packages/firestore/lib/types/namespaced.ts +++ b/packages/firestore/lib/types/namespaced.ts @@ -16,6 +16,7 @@ */ import { ReactNativeFirebase } from '@react-native-firebase/app'; +import type { ListenSource } from './firestore'; /** * Firebase Cloud Firestore package for React Native. @@ -491,7 +492,7 @@ export namespace FirebaseFirestoreTypes { * ```js * const unsubscribe = firebase.firestore().doc('users/alovelace') * .onSnapshot( - * { includeMetadataChanges: true }, // SnapshotListenerOptions + * { source: 'cache', includeMetadataChanges: true }, // SnapshotListenerOptions * (documentSnapshot) => {}, // onNext * (error) => console.error(error), // onError * ); @@ -1356,7 +1357,7 @@ export namespace FirebaseFirestoreTypes { * ```js * const unsubscribe = firebase.firestore().collection('users') * .onSnapshot( - * { includeMetadataChanges: true }, // SnapshotListenerOptions + * { source: 'cache', includeMetadataChanges: true }, // SnapshotListenerOptions * (querySnapshot) => {}, // onNext * (error) => console.error(error), // onError * ); @@ -1764,7 +1765,11 @@ export namespace FirebaseFirestoreTypes { /** * Include a change even if only the metadata of the query or of a document changed. Default is false. */ - includeMetadataChanges: boolean; + includeMetadataChanges?: boolean; + /** + * Set the source the query listens to. Default is 'default'. + */ + source?: ListenSource; } /** diff --git a/packages/firestore/lib/utils/index.ts b/packages/firestore/lib/utils/index.ts index 7d0c7a2736..7d2d2a7941 100644 --- a/packages/firestore/lib/utils/index.ts +++ b/packages/firestore/lib/utils/index.ts @@ -31,6 +31,7 @@ import type { PartialSnapshotObserverInternal, } from '../types/internal'; import FieldPath, { fromDotSeparatedString } from '../FieldPath'; +import type { ListenSource } from '../types/firestore'; export function extractFieldPathData(data: unknown, segments: string[]): unknown { if (!isObject(data)) { @@ -151,7 +152,10 @@ function isPartialObserver( } export interface ParseSnapshotArgsResult { - snapshotListenOptions: { includeMetadataChanges?: boolean }; + snapshotListenOptions: { + includeMetadataChanges?: boolean; + source?: ListenSource; + }; callback: (snapshot: unknown, error: Error | null) => void; onNext: (snapshot: unknown) => void; onError: (error: Error) => void; @@ -163,7 +167,10 @@ export function parseSnapshotArgs(args: unknown[]): ParseSnapshotArgsResult { } const NOOP = (): void => {}; - const snapshotListenOptions: { includeMetadataChanges?: boolean } = {}; + const snapshotListenOptions: { + includeMetadataChanges?: boolean; + source?: ListenSource; + } = {}; let callback: (snapshot: unknown, error: Error | null) => void = NOOP; let onError: (error: Error) => void = NOOP; let onNext: (snapshot: unknown) => void = NOOP; @@ -189,9 +196,10 @@ export function parseSnapshotArgs(args: unknown[]): ParseSnapshotArgsResult { } if (isObject(args[0]) && !isPartialObserver(args[0])) { - const opts = args[0] as { includeMetadataChanges?: boolean }; + const opts = args[0] as { includeMetadataChanges?: boolean; source?: ListenSource }; snapshotListenOptions.includeMetadataChanges = opts.includeMetadataChanges == null ? false : opts.includeMetadataChanges; + snapshotListenOptions.source = opts.source == null ? 'default' : opts.source; if (isFunction(args[1])) { if (isFunction(args[2])) { onNext = args[1] as (snapshot: unknown) => void; @@ -217,6 +225,12 @@ export function parseSnapshotArgs(args: unknown[]): ParseSnapshotArgsResult { } } + if (hasOwnProperty(snapshotListenOptions, 'source')) { + if (snapshotListenOptions.source !== 'default' && snapshotListenOptions.source !== 'cache') { + throw new Error("'options' SnapshotOptions.source must be one of 'default' or 'cache'."); + } + } + if (!isFunction(onNext)) { throw new Error("'observer.next' or 'onNext' expected a function."); } diff --git a/packages/firestore/type-test.ts b/packages/firestore/type-test.ts index ece2f553fa..70337e0a57 100644 --- a/packages/firestore/type-test.ts +++ b/packages/firestore/type-test.ts @@ -58,9 +58,7 @@ const nsDocRef = nsColl.doc('alice'); const nsQuery = nsColl.where('name', '==', 'test'); nsDocRef.set({ name: 'Alice', count: 1 }).then(() => {}); -nsDocRef - .set({ name: 'Alice' }, { merge: true }) - .then(() => {}); +nsDocRef.set({ name: 'Alice' }, { merge: true }).then(() => {}); nsDocRef.update({ count: 2 }).then(() => {}); nsDocRef.update('count', 3).then(() => {}); @@ -106,6 +104,10 @@ const unsubDoc2 = nsDocRef.onSnapshot( { includeMetadataChanges: true }, (_snap: FirebaseFirestoreTypes.DocumentSnapshot) => {}, ); +const unsubDoc4 = nsDocRef.onSnapshot( + { source: 'cache' }, + (_snap: FirebaseFirestoreTypes.DocumentSnapshot) => {}, +); const unsubDoc3 = nsDocRef.onSnapshot({ next: (_snap: FirebaseFirestoreTypes.DocumentSnapshot) => {}, error: (_e: Error) => {}, @@ -113,6 +115,7 @@ const unsubDoc3 = nsDocRef.onSnapshot({ unsubDoc1(); unsubDoc2(); unsubDoc3(); +unsubDoc4(); // ----- onSnapshot (query) ----- const unsubQuery1 = nsQuery.onSnapshot((snap: FirebaseFirestoreTypes.QuerySnapshot) => { @@ -122,8 +125,13 @@ const unsubQuery2 = nsQuery.onSnapshot( { includeMetadataChanges: true }, { next: (_snap: FirebaseFirestoreTypes.QuerySnapshot) => {}, error: (_e: Error) => {} }, ); +const unsubQuery3 = nsQuery.onSnapshot( + { source: 'cache', includeMetadataChanges: true }, + { next: (_snap: FirebaseFirestoreTypes.QuerySnapshot) => {}, error: (_e: Error) => {} }, +); unsubQuery1(); unsubQuery2(); +unsubQuery3(); // ----- Query: where, orderBy, limit, cursor ----- const nsQuery2 = nsColl @@ -145,13 +153,15 @@ console.log(nsLoadTask.then(() => {})); const nsNamed = nsFirestore.namedQuery('my-query'); console.log(nsNamed); -nsFirestore.runTransaction(async (tx: FirebaseFirestoreTypes.Transaction) => { - const snap = await tx.get(nsDocRef); - if (snap.exists()) { - tx.update(nsDocRef, { count: ((snap.data() as { count?: number })?.count ?? 0) + 1 }); - } - return null; -}).then(() => {}); +nsFirestore + .runTransaction(async (tx: FirebaseFirestoreTypes.Transaction) => { + const snap = await tx.get(nsDocRef); + if (snap.exists()) { + tx.update(nsDocRef, { count: ((snap.data() as { count?: number })?.count ?? 0) + 1 }); + } + return null; + }) + .then(() => {}); // ----- Firestore instance: persistence and network ----- nsFirestore.clearPersistence().then(() => {}); @@ -196,13 +206,15 @@ const nsArrayRemove = firebase.firestore.FieldValue.arrayRemove(1); void nsArrayRemove; const nsIncrement = firebase.firestore.FieldValue.increment(1); -nsDocRef.set({ - name: 'x', - deleted: nsDelete, - ts: nsServerTs, - arr: nsArrayUnion, - cnt: nsIncrement, -}).then(() => {}); +nsDocRef + .set({ + name: 'x', + deleted: nsDelete, + ts: nsServerTs, + arr: nsArrayUnion, + cnt: nsIncrement, + }) + .then(() => {}); // ----- withConverter (namespaced) ----- interface User { @@ -439,15 +451,23 @@ deleteDoc(modDoc).then(() => {}); // ----- onSnapshot (modular) ----- const unsubMod1 = onSnapshot(modDoc, snap => snap.data()); const unsubMod2 = onSnapshot(modDoc, { includeMetadataChanges: true }, snap => snap.data()); +const unsubMod5 = onSnapshot(modDoc, { source: 'cache' }, snap => snap.data()); const unsubMod3 = onSnapshot(modDoc, { next: _snap => {}, error: (_e: Error) => {}, }); const unsubMod4 = onSnapshot(modQuery1, snap => snap.docs); +const unsubMod6 = onSnapshot( + modQuery1, + { source: 'cache', includeMetadataChanges: true }, + snap => snap.docs, +); unsubMod1(); unsubMod2(); unsubMod3(); unsubMod4(); +unsubMod5(); +unsubMod6(); // ----- snapshotEqual, queryEqual ----- getDoc(modDoc).then(s1 => {