diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreModule.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreModule.java index b8f5ceaab4..543f545dc5 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreModule.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreModule.java @@ -21,6 +21,7 @@ import static io.invertase.firebase.firestore.ReactNativeFirebaseFirestoreCommon.rejectPromiseFirestoreException; import static io.invertase.firebase.firestore.UniversalFirebaseFirestoreCommon.createFirestoreKey; import static io.invertase.firebase.firestore.UniversalFirebaseFirestoreCommon.getFirestoreForApp; +import static io.invertase.firebase.firestore.UniversalFirebaseFirestoreCommon.getQueryForFirestore; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; @@ -193,6 +194,63 @@ public void persistenceCacheIndexManager( promise.resolve(null); } + @ReactMethod + public void snapshotsInSyncListener( + String appName, + String databaseId, + int listenerId + ) { + FirebaseFirestore firebaseFirestore = getFirestoreForApp(appName, databaseId); + ReactNativeFirebaseFirestoreQuery firestoreQuery = + new ReactNativeFirebaseFirestoreQuery( + appName, + databaseId, + getQueryForFirestore(firebaseFirestore) + ); + + handleSnapshotsInSync(appName, databaseId); + } + + @ReactMethod + public void onSnapshotsInSync(String appName, String databaseId, Promise promise) { + ListenerRegistration registration = FirebaseFirestore.getInstance() + .addSnapshotsInSyncListener(() -> { + WritableMap result = Arguments.createMap(); + getReactApplicationContext() + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit("firestore_onSnapshotsInSync", result); + }); + + promise.resolve(null); + } + + private void handleSnapshotsInSync( + ReactNativeFirebaseFirestoreQuery firestoreQuery, + String appName, + String databaseId, + int listenerId + ) { + + final EventListener listener = + (querySnapshot, exception) -> { + if (exception != null) { + ListenerRegistration listenerRegistration = collectionSnapshotListeners.get(listenerId); + if (listenerRegistration != null) { + listenerRegistration.remove(); + collectionSnapshotListeners.remove(listenerId); + } + sendOnSnapshotError(appName, databaseId, listenerId, exception); + } else { + sendOnSnapshotEvent(appName, databaseId, listenerId, querySnapshot, metadataChanges); + } + }; + + ListenerRegistration listenerRegistration = + firestoreQuery.query.addSnapshotListener(metadataChanges, listener); + + collectionSnapshotListeners.put(listenerId, listenerRegistration); + } + private WritableMap taskProgressToWritableMap(LoadBundleTaskProgress progress) { WritableMap writableMap = Arguments.createMap(); writableMap.putDouble("bytesLoaded", progress.getBytesLoaded()); diff --git a/packages/firestore/e2e/DocumentReference/onSnapshotsInSync.e2e.js b/packages/firestore/e2e/DocumentReference/onSnapshotsInSync.e2e.js new file mode 100644 index 0000000000..b9c41c7e58 --- /dev/null +++ b/packages/firestore/e2e/DocumentReference/onSnapshotsInSync.e2e.js @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +describe('firestore().onSnapshotsInSync() modular', function () { + if (Platform.other) return; + + const { getFirestore, doc, onSnapshot, onSnapshotsInSync, setDoc } = firestoreModular; + const TEST_DOC = `${COLLECTION}/modular_sync_test`; + + it('calls onSnapshotsInSync after a snapshot is delivered', async function () { + const db = getFirestore(); + const docRef = doc(db, TEST_DOC); + + const snapshotSpy = sinon.spy(); + const syncSpy = sinon.spy(); + + // Set initial data + await setDoc(docRef, { foo: 'initial' }); + + // Subscribe to snapshot and sync + const unsubSnapshot = onSnapshot(docRef, snapshotSpy); + const unsubSync = onSnapshotsInSync(db, syncSpy); + + // Trigger change + await setDoc(docRef, { foo: 'updated' }); + + // Wait for both + await Utils.spyToBeCalledOnceAsync(snapshotSpy); + await Utils.spyToBeCalledOnceAsync(syncSpy); + + snapshotSpy.should.be.calledOnce(); + syncSpy.should.be.calledOnce(); + + unsubSnapshot(); + unsubSync(); + }); + + it('should not fire sync after unsubscribe', async function () { + const db = getFirestore(); + const syncSpy = sinon.spy(); + + const unsubSync = onSnapshotsInSync(db, syncSpy); + unsubSync(); + + await Utils.sleep(1000); + + await setDoc(doc(db, `${COLLECTION}/modular_sync_test_unsub`), { foo: 'change' }); + + await Utils.sleep(1000); + + syncSpy.should.not.be.called(); + }); +}); + diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreModule.m b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreModule.m index bdb1a1de48..e5197c6c64 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreModule.m +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreModule.m @@ -240,6 +240,14 @@ + (BOOL)requiresMainQueueSetup { resolve(nil); } +RCT_EXPORT_METHOD(addSnapshotsInSyncListener:(RCTResponseSenderBlock)callback) { + FIRListenerRegistration *registration = [[FIRFirestore firestore] + addSnapshotsInSyncListener:^{ + callback(@[]); + }]; +} + + - (NSMutableDictionary *)taskProgressToDictionary:(FIRLoadBundleTaskProgress *)progress { NSMutableDictionary *progressMap = [[NSMutableDictionary alloc] init]; progressMap[@"bytesLoaded"] = @(progress.bytesLoaded); diff --git a/packages/firestore/lib/index.js b/packages/firestore/lib/index.js index e95cb29713..a1a4fb438a 100644 --- a/packages/firestore/lib/index.js +++ b/packages/firestore/lib/index.js @@ -56,6 +56,7 @@ const nativeEvents = [ 'firestore_collection_sync_event', 'firestore_document_sync_event', 'firestore_transaction_event', + 'firestore_snapshots_in_sync_event', ]; class FirebaseFirestoreModule extends FirebaseModule { @@ -84,6 +85,13 @@ class FirebaseFirestoreModule extends FirebaseModule { ); }); + this.emitter.addListener(this.eventNameForApp('firestore_snapshots_in_sync_event'), event => { + this.emitter.emit( + this.eventNameForApp(`firestore_snapshots_in_sync_event:${event.listenerId}`), + event, + ); + }); + this._settings = { ignoreUndefinedProperties: false, persistence: true, @@ -134,6 +142,65 @@ class FirebaseFirestoreModule extends FirebaseModule { await this.native.terminate(); } + onSnapshot(...args) { + let snapshotListenOptions; + let callback; + let onNext; + let onError; + + try { + const options = parseSnapshotArgs(args); + snapshotListenOptions = options.snapshotListenOptions; + callback = options.callback; + onNext = options.onNext; + onError = options.onError; + } catch (e) { + throw new Error(`firebase.firestore().doc().onSnapshot(*) ${e.message}`); + } + + function handleSuccess(documentSnapshot) { + callback(documentSnapshot, null); + onNext(documentSnapshot); + } + + function handleError(error) { + callback(null, error); + onError(error); + } + + const listenerId = _id++; + + const onSnapshotSubscription = this._firestore.emitter.addListener( + this._firestore.eventNameForApp(`firestore_snapshots_in_sync_event:${listenerId}`), + event => { + if (event.body.error) { + handleError(NativeError.fromEvent(event.body.error, 'firestore')); + } else { + const documentSnapshot = createDeprecationProxy( + new FirestoreDocumentSnapshot(this._firestore, event.body.snapshot), + ); + handleSuccess(documentSnapshot); + } + }, + ); + + const unsubscribe = () => { + onSnapshotSubscription.remove(); + this._firestore.native.documentOffSnapshot(listenerId); + }; + + this.firestore.native.snapshotsInSyncListener(listenerId); + + return unsubscribe; + } + + async onSnapshotsInSync(firestore, callback) { + this.native.onSnapshotsInSync(firestore, callback, MODULAR_DEPRECATION_ARG); + return () => { + firestoreEmitter.removeListener('onSnapshotsInSync', callback); + }; + } + useEmulator(host, port) { if (!host || !isString(host) || !port || !isNumber(port)) { throw new Error('firebase.firestore().useEmulator() takes a non-empty host and port'); diff --git a/packages/firestore/lib/modular/snapshot.d.ts b/packages/firestore/lib/modular/snapshot.d.ts index 38384a5252..fb00198108 100644 --- a/packages/firestore/lib/modular/snapshot.d.ts +++ b/packages/firestore/lib/modular/snapshot.d.ts @@ -227,3 +227,28 @@ export declare function queryEqual, right: Query, ): boolean; + +/** + * Attaches a listener for a snapshots-in-sync event. + * The snapshots-in-sync event indicates that all listeners affected by a given change have fired, even if + * a single server-generated change affects multiple listeners. + * + * @param firestore + * @param onSync + */ +export declare function onSnapshotsInSync(firestore: Firestore, onSync: () => void): Unsubscribe; + +/** + * /** + * Attaches a listener for a snapshots-in-sync event. + * The snapshots-in-sync event indicates that all listeners affected by a given change have fired, even if + * a single server-generated change affects multiple listeners. + * + * @param firestore + * @param onSync + */ +export declare function onSnapshotsInSync(firestore: Firestore, observer: { + next?: (value: void) => void; + error?: (error: FirestoreError) => void; + complete?: () => void; +}): Unsubscribe; \ No newline at end of file diff --git a/packages/firestore/lib/modular/snapshot.js b/packages/firestore/lib/modular/snapshot.js index 946f1c819d..c143e5dfe3 100644 --- a/packages/firestore/lib/modular/snapshot.js +++ b/packages/firestore/lib/modular/snapshot.js @@ -5,6 +5,9 @@ */ import { MODULAR_DEPRECATION_ARG } from '../../../app/lib/common'; +import { NativeEventEmitter, NativeModules } from 'react-native'; + +const emitter = new NativeEventEmitter(NativeModules.RNFBFirestoreModule); /** * @param {Query | DocumentReference} reference