diff --git a/packages/firestore/android/src/main/java/io/invertase/firebase/firestore/UniversalFirebaseFirestoreModule.java b/packages/firestore/android/src/main/java/io/invertase/firebase/firestore/UniversalFirebaseFirestoreModule.java index 77345d3529..6130236f78 100644 --- a/packages/firestore/android/src/main/java/io/invertase/firebase/firestore/UniversalFirebaseFirestoreModule.java +++ b/packages/firestore/android/src/main/java/io/invertase/firebase/firestore/UniversalFirebaseFirestoreModule.java @@ -22,10 +22,15 @@ import static io.invertase.firebase.firestore.UniversalFirebaseFirestoreCommon.instanceCache; import android.content.Context; +import android.util.SparseArray; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableMap; import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.Tasks; import com.google.firebase.firestore.FirebaseFirestore; +import com.google.firebase.firestore.ListenerRegistration; import com.google.firebase.firestore.LoadBundleTask; +import io.invertase.firebase.common.ReactNativeFirebaseEventEmitter; import io.invertase.firebase.common.UniversalFirebaseModule; import io.invertase.firebase.common.UniversalFirebasePreferences; import java.nio.charset.StandardCharsets; @@ -34,6 +39,7 @@ import java.util.Objects; public class UniversalFirebaseFirestoreModule extends UniversalFirebaseModule { + private static SparseArray onSnapshotInSyncListeners = new SparseArray<>(); private static HashMap emulatorConfigs = new HashMap<>(); @@ -41,6 +47,35 @@ public class UniversalFirebaseFirestoreModule extends UniversalFirebaseModule { super(context, serviceName); } + void addSnapshotsInSync(String appName, String databaseId, int listenerId) { + + FirebaseFirestore firebaseFirestore = getFirestoreForApp(appName, databaseId); + ListenerRegistration listenerRegistration = + firebaseFirestore.addSnapshotsInSyncListener( + () -> { + ReactNativeFirebaseEventEmitter emitter = + ReactNativeFirebaseEventEmitter.getSharedInstance(); + WritableMap body = Arguments.createMap(); + emitter.sendEvent( + new ReactNativeFirebaseFirestoreEvent( + ReactNativeFirebaseFirestoreEvent.SNAPSHOT_IN_SYNC_EVENT_SYNC, + body, + appName, + databaseId, + listenerId)); + }); + + onSnapshotInSyncListeners.put(listenerId, listenerRegistration); + } + + void removeSnapshotsInSync(String appName, String databaseId, int listenerId) { + ListenerRegistration listenerRegistration = onSnapshotInSyncListeners.get(listenerId); + if (listenerRegistration != null) { + listenerRegistration.remove(); + onSnapshotInSyncListeners.remove(listenerId); + } + } + Task disableNetwork(String appName, String databaseId) { return getFirestoreForApp(appName, databaseId).disableNetwork(); } diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreEvent.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreEvent.java index 535c1e0998..674caddb01 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreEvent.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreEvent.java @@ -26,6 +26,7 @@ public class ReactNativeFirebaseFirestoreEvent implements NativeEvent { static final String COLLECTION_EVENT_SYNC = "firestore_collection_sync_event"; static final String DOCUMENT_EVENT_SYNC = "firestore_document_sync_event"; static final String TRANSACTION_EVENT_SYNC = "firestore_transaction_event"; + static final String SNAPSHOT_IN_SYNC_EVENT_SYNC = "firestore_snapshots_in_sync_event"; private static final String KEY_ID = "listenerId"; private static final String KEY_BODY = "body"; private static final String KEY_APP_NAME = "appName"; 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..1be4991e46 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 @@ -193,6 +193,20 @@ public void persistenceCacheIndexManager( promise.resolve(null); } + @ReactMethod + public void addSnapshotsInSync( + String appName, String databaseId, int listenerId, Promise promise) { + module.addSnapshotsInSync(appName, databaseId, listenerId); + promise.resolve(null); + } + + @ReactMethod + public void removeSnapshotsInSync( + String appName, String databaseId, int listenerId, Promise promise) { + module.removeSnapshotsInSync(appName, databaseId, listenerId); + promise.resolve(null); + } + private WritableMap taskProgressToWritableMap(LoadBundleTaskProgress progress) { WritableMap writableMap = Arguments.createMap(); writableMap.putDouble("bytesLoaded", progress.getBytesLoaded()); diff --git a/packages/firestore/e2e/firestore.e2e.js b/packages/firestore/e2e/firestore.e2e.js index 4e245c0c15..9d584b6d1d 100644 --- a/packages/firestore/e2e/firestore.e2e.js +++ b/packages/firestore/e2e/firestore.e2e.js @@ -915,5 +915,101 @@ describe('firestore()', function () { }); }); }); + + describe('snapshotsInSync', function () { + const { getFirestore, onSnapshotsInSync, doc, setDoc, deleteDoc } = firestoreModular; + + it('snapshotsInSync fires when document is updated and synced', async function () { + const events = []; + + const db = getFirestore(); + const testDoc1 = doc(db, `${COLLECTION}/snapshotsInSync1`); + const testDoc2 = doc(db, `${COLLECTION}/snapshotsInSync2`); + + if (Platform.other) { + // Should throw error for lite SDK + try { + const unsubscribe = onSnapshotsInSync(getFirestore(), () => {}); + unsubscribe(); + } catch (e) { + e.message.should.equal('Not supported in the lite SDK.'); + } + return; + } + + let unsubscribe; + const syncPromise = new Promise(resolve => { + unsubscribe = onSnapshotsInSync(db, () => { + events.push('sync'); + if (events.length >= 1) { + resolve(); + } + }); + }); + + await Promise.all([setDoc(testDoc1, { test: 1 }), setDoc(testDoc2, { test: 2 })]); + + await syncPromise; + + unsubscribe(); + + // Verify unsubscribe worked by doing another write + await setDoc(testDoc1, { test: 3 }); + + // Cleanup + await Promise.all([deleteDoc(testDoc1), deleteDoc(testDoc2)]); + + events.length.should.be.greaterThan(0); + events.forEach(event => event.should.equal('sync')); + }); + + it('unsubscribe() call should prevent further sync events', async function () { + const events = []; + + const db = getFirestore(); + const testDoc1 = doc(db, `${COLLECTION}/snapshotsInSync1`); + const testDoc2 = doc(db, `${COLLECTION}/snapshotsInSync2`); + + if (Platform.other) { + // Should throw error for lite SDK + try { + const unsubscribe = onSnapshotsInSync(getFirestore(), () => {}); + unsubscribe(); + } catch (e) { + e.message.should.equal('Not supported in the lite SDK.'); + } + return; + } + + let unsubscribe; + const syncPromise = new Promise(resolve => { + unsubscribe = onSnapshotsInSync(db, () => { + events.push('sync'); + if (events.length >= 1) { + resolve(); + } + }); + }); + + // Trigger initial sync events + await Promise.all([setDoc(testDoc1, { test: 1 }), setDoc(testDoc2, { test: 2 })]); + + await syncPromise; + + // Record the number of events before unsubscribe + const eventsBeforeUnsubscribe = events.length; + + await unsubscribe(); + + await setDoc(testDoc1, { test: 3 }); + await setDoc(testDoc2, { test: 4 }); + + await Promise.all([deleteDoc(testDoc1), deleteDoc(testDoc2)]); + + // Verify that no additional events were recorded after unsubscribe + events.length.should.equal(eventsBeforeUnsubscribe); + events.forEach(event => event.should.equal('sync')); + }); + }); }); }); diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreModule.m b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreModule.m index bdb1a1de48..5b9c7a63c8 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreModule.m +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreModule.m @@ -16,12 +16,15 @@ */ #import "RNFBFirestoreModule.h" +#import #import #import "FirebaseFirestoreInternal/FIRPersistentCacheIndexManager.h" #import "RNFBFirestoreCommon.h" #import "RNFBPreferences.h" NSMutableDictionary *emulatorConfigs; +static __strong NSMutableDictionary *snapshotsInSyncListeners; +static NSString *const RNFB_FIRESTORE_SNAPSHOTS_IN_SYNC = @"firestore_snapshots_in_sync_event"; @implementation RNFBFirestoreModule #pragma mark - @@ -240,6 +243,51 @@ + (BOOL)requiresMainQueueSetup { resolve(nil); } +RCT_EXPORT_METHOD(addSnapshotsInSync + : (FIRApp *)firebaseApp + : (NSString *)databaseId + : (nonnull NSNumber *)listenerId + : (RCTPromiseResolveBlock)resolve + : (RCTPromiseRejectBlock)reject) { + if (snapshotsInSyncListeners[listenerId]) { + resolve(nil); + return; + } + + FIRFirestore *firestore = [RNFBFirestoreCommon getFirestoreForApp:firebaseApp + databaseId:databaseId]; + + id listener = [firestore addSnapshotsInSyncListener:^{ + [[RNFBRCTEventEmitter shared] + sendEventWithName:RNFB_FIRESTORE_SNAPSHOTS_IN_SYNC + body:@{ + @"appName" : [RNFBSharedUtils getAppJavaScriptName:firebaseApp.name], + @"databaseId" : databaseId, + @"listenerId" : listenerId, + @"body" : @{} + }]; + }]; + + snapshotsInSyncListeners[listenerId] = listener; + + resolve(nil); +} + +RCT_EXPORT_METHOD(removeSnapshotsInSync + : (FIRApp *)firebaseApp + : (NSString *)databaseId + : (nonnull NSNumber *)listenerId + : (RCTPromiseResolveBlock)resolve + : (RCTPromiseRejectBlock)reject) { + id listener = snapshotsInSyncListeners[listenerId]; + if (listener) { + [listener remove]; + [snapshotsInSyncListeners removeObjectForKey:listenerId]; + } + + resolve(nil); +} + - (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..6e72a38193 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, diff --git a/packages/firestore/lib/modular/index.js b/packages/firestore/lib/modular/index.js index 29674af9b0..132587b662 100644 --- a/packages/firestore/lib/modular/index.js +++ b/packages/firestore/lib/modular/index.js @@ -89,6 +89,24 @@ export function collectionGroup(firestore, collectionId) { return firestore.collectionGroup.call(firestore, collectionId, MODULAR_DEPRECATION_ARG); } +let _id_SnapshotInSync = 0; + +export function onSnapshotsInSync(firestore, callback) { + const listenerId = _id_SnapshotInSync++; + firestore.native.addSnapshotsInSync(listenerId); + const onSnapshotsInSyncSubscription = firestore.emitter.addListener( + firestore.eventNameForApp(`firestore_snapshots_in_sync_event:${listenerId}`), + () => { + callback(); + }, + ); + + return () => { + onSnapshotsInSyncSubscription.remove(); + firestore.native.removeSnapshotsInSync(listenerId); + }; +} + /** * @param {DocumentReference} reference * @param {import('.').PartialWithFieldValue} data diff --git a/packages/firestore/lib/web/RNFBFirestoreModule.js b/packages/firestore/lib/web/RNFBFirestoreModule.js index bb7e6e99b4..42dab50c22 100644 --- a/packages/firestore/lib/web/RNFBFirestoreModule.js +++ b/packages/firestore/lib/web/RNFBFirestoreModule.js @@ -132,6 +132,14 @@ export default { return rejectWithCodeAndMessage('unsupported', 'Not supported in the lite SDK.'); }, + addSnapshotsInSync() { + return rejectWithCodeAndMessage('unsupported', 'Not supported in the lite SDK.'); + }, + + removeSnapshotsInSync() { + return rejectWithCodeAndMessage('unsupported', 'Not supported in the lite SDK.'); + }, + /** * Use the Firestore emulator. * @param {string} appName - The app name. diff --git a/tests/test-app/examples/firestore/onSnapshotInSync.js b/tests/test-app/examples/firestore/onSnapshotInSync.js new file mode 100644 index 0000000000..b20f326919 --- /dev/null +++ b/tests/test-app/examples/firestore/onSnapshotInSync.js @@ -0,0 +1,48 @@ +import React, { useEffect } from 'react'; +import { AppRegistry, Button, Text, View } from 'react-native'; + +import '@react-native-firebase/app'; +import firestore, { onSnapshotsInSync } from '@react-native-firebase/firestore'; + +const fire = firestore(); +function App() { + let unsubscribe; + useEffect(() => { + unsubscribe = onSnapshotsInSync(fire, () => { + console.log('onSnapshotsInSync'); + }); + }, []); + + async function addDocument() { + await firestore().collection('flutter-tests').doc('one').set({ foo: 'bar' }); + } + + return ( + + React Native Firebase + onSnapshotsInSync API +