diff --git a/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestore.java b/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestore.java index 19c0f21b..180d286a 100644 --- a/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestore.java +++ b/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestore.java @@ -32,8 +32,6 @@ import io.invertase.firebase.Utils; public class RNFirebaseFirestore extends ReactContextBaseJavaModule { private static final String TAG = "RNFirebaseFirestore"; - private HashMap collectionReferences = new HashMap<>(); - private HashMap documentReferences = new HashMap<>(); // private SparseArray transactionHandlers = new SparseArray<>(); RNFirebaseFirestore(ReactApplicationContext reactContext) { @@ -51,6 +49,20 @@ public class RNFirebaseFirestore extends ReactContextBaseJavaModule { ref.get(promise); } + @ReactMethod + public void collectionOffSnapshot(String appName, String path, ReadableArray filters, + ReadableArray orders, ReadableMap options, String listenerId) { + RNFirebaseFirestoreCollectionReference.offSnapshot(listenerId); + } + + @ReactMethod + public void collectionOnSnapshot(String appName, String path, ReadableArray filters, + ReadableArray orders, ReadableMap options, String listenerId) { + RNFirebaseFirestoreCollectionReference ref = getCollectionForAppPath(appName, path, filters, orders, options); + ref.onSnapshot(listenerId); + } + + @ReactMethod public void documentBatch(final String appName, final ReadableArray writes, final ReadableMap commitOptions, final Promise promise) { @@ -134,18 +146,13 @@ public class RNFirebaseFirestore extends ReactContextBaseJavaModule { } @ReactMethod - public void documentOffSnapshot(String appName, String path, int listenerId) { - RNFirebaseFirestoreDocumentReference ref = getCachedDocumentForAppPath(appName, path); - ref.offSnapshot(listenerId); - - if (!ref.hasListeners()) { - clearCachedDocumentForAppPath(appName, path); - } + public void documentOffSnapshot(String appName, String path, String listenerId) { + RNFirebaseFirestoreDocumentReference.offSnapshot(listenerId); } @ReactMethod - public void documentOnSnapshot(String appName, String path, int listenerId) { - RNFirebaseFirestoreDocumentReference ref = getCachedDocumentForAppPath(appName, path); + public void documentOnSnapshot(String appName, String path, String listenerId) { + RNFirebaseFirestoreDocumentReference ref = getDocumentForAppPath(appName, path); ref.onSnapshot(listenerId); } @@ -204,36 +211,7 @@ public class RNFirebaseFirestore extends ReactContextBaseJavaModule { ReadableArray filters, ReadableArray orders, ReadableMap options) { - return new RNFirebaseFirestoreCollectionReference(appName, path, filters, orders, options); - } - - /** - * Get a cached document reference for a specific app and path - * - * @param appName - * @param path - * @return - */ - private RNFirebaseFirestoreDocumentReference getCachedDocumentForAppPath(String appName, String path) { - String key = appName + "/" + path; - RNFirebaseFirestoreDocumentReference ref = documentReferences.get(key); - if (ref == null) { - ref = getDocumentForAppPath(appName, path); - documentReferences.put(key, ref); - } - return ref; - } - - /** - * Clear a cached document reference for a specific app and path - * - * @param appName - * @param path - * @return - */ - private void clearCachedDocumentForAppPath(String appName, String path) { - String key = appName + "/" + path; - documentReferences.remove(key); + return new RNFirebaseFirestoreCollectionReference(this.getReactApplicationContext(), appName, path, filters, orders, options); } /** diff --git a/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestoreCollectionReference.java b/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestoreCollectionReference.java index 9cba86be..e8512d19 100644 --- a/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestoreCollectionReference.java +++ b/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestoreCollectionReference.java @@ -4,16 +4,21 @@ package io.invertase.firebase.firestore; import android.support.annotation.NonNull; import android.util.Log; +import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.WritableMap; import com.google.android.gms.tasks.OnCompleteListener; import com.google.android.gms.tasks.Task; +import com.google.firebase.firestore.EventListener; import com.google.firebase.firestore.FirebaseFirestoreException; +import com.google.firebase.firestore.ListenerRegistration; import com.google.firebase.firestore.Query; import com.google.firebase.firestore.QuerySnapshot; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -21,21 +26,26 @@ import io.invertase.firebase.Utils; public class RNFirebaseFirestoreCollectionReference { private static final String TAG = "RNFSCollectionReference"; + private static Map collectionSnapshotListeners = new HashMap<>(); + private final String appName; private final String path; private final ReadableArray filters; private final ReadableArray orders; private final ReadableMap options; private final Query query; + private ReactContext reactContext; - RNFirebaseFirestoreCollectionReference(String appName, String path, ReadableArray filters, - ReadableArray orders, ReadableMap options) { + RNFirebaseFirestoreCollectionReference(ReactContext reactContext, String appName, String path, + ReadableArray filters, ReadableArray orders, + ReadableMap options) { this.appName = appName; this.path = path; this.filters = filters; this.orders = orders; this.options = options; this.query = buildQuery(); + this.reactContext = reactContext; } void get(final Promise promise) { @@ -54,6 +64,42 @@ public class RNFirebaseFirestoreCollectionReference { }); } + public static void offSnapshot(final String listenerId) { + ListenerRegistration listenerRegistration = collectionSnapshotListeners.remove(listenerId); + if (listenerRegistration != null) { + listenerRegistration.remove(); + } + } + + public void onSnapshot(final String listenerId) { + if (!collectionSnapshotListeners.containsKey(listenerId)) { + final EventListener listener = new EventListener() { + @Override + public void onEvent(QuerySnapshot querySnapshot, FirebaseFirestoreException exception) { + if (exception == null) { + handleQuerySnapshotEvent(listenerId, querySnapshot); + } else { + ListenerRegistration listenerRegistration = collectionSnapshotListeners.remove(listenerId); + if (listenerRegistration != null) { + listenerRegistration.remove(); + } + handleQuerySnapshotError(listenerId, exception); + } + } + }; + ListenerRegistration listenerRegistration = this.query.addSnapshotListener(listener); + collectionSnapshotListeners.put(listenerId, listenerRegistration); + } + } + + /* + * INTERNALS/UTILS + */ + + boolean hasListeners() { + return !collectionSnapshotListeners.isEmpty(); + } + private Query buildQuery() { Query query = RNFirebaseFirestore.getFirestoreForApp(appName).collection(path); query = applyFilters(query); @@ -134,4 +180,39 @@ public class RNFirebaseFirestoreCollectionReference { } return query; } + + /** + * Handles documentSnapshot events. + * + * @param listenerId + * @param querySnapshot + */ + private void handleQuerySnapshotEvent(String listenerId, QuerySnapshot querySnapshot) { + WritableMap event = Arguments.createMap(); + WritableMap data = FirestoreSerialize.snapshotToWritableMap(querySnapshot); + + event.putString("appName", appName); + event.putString("path", path); + event.putString("listenerId", listenerId); + event.putMap("querySnapshot", data); + + Utils.sendEvent(reactContext, "firestore_collection_sync_event", event); + } + + /** + * Handles a documentSnapshot error event + * + * @param listenerId + * @param exception + */ + private void handleQuerySnapshotError(String listenerId, FirebaseFirestoreException exception) { + WritableMap event = Arguments.createMap(); + + event.putString("appName", appName); + event.putString("path", path); + event.putString("listenerId", listenerId); + event.putMap("error", RNFirebaseFirestore.getJSError(exception)); + + Utils.sendEvent(reactContext, "firestore_collection_sync_event", event); + } } diff --git a/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestoreDocumentReference.java b/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestoreDocumentReference.java index ffe442a0..8167f281 100644 --- a/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestoreDocumentReference.java +++ b/android/src/main/java/io/invertase/firebase/firestore/RNFirebaseFirestoreDocumentReference.java @@ -25,11 +25,12 @@ import io.invertase.firebase.Utils; public class RNFirebaseFirestoreDocumentReference { private static final String TAG = "RNFBFSDocumentReference"; + private static Map documentSnapshotListeners = new HashMap<>(); + private final String appName; private final String path; private ReactContext reactContext; private final DocumentReference ref; - private Map documentSnapshotListeners = new HashMap<>(); RNFirebaseFirestoreDocumentReference(ReactContext reactContext, String appName, String path) { this.appName = appName; @@ -79,14 +80,14 @@ public class RNFirebaseFirestoreDocumentReference { }); } - public void offSnapshot(final int listenerId) { + public static void offSnapshot(final String listenerId) { ListenerRegistration listenerRegistration = documentSnapshotListeners.remove(listenerId); if (listenerRegistration != null) { listenerRegistration.remove(); } } - public void onSnapshot(final int listenerId) { + public void onSnapshot(final String listenerId) { if (!documentSnapshotListeners.containsKey(listenerId)) { final EventListener listener = new EventListener() { @Override @@ -154,7 +155,7 @@ public class RNFirebaseFirestoreDocumentReference { * INTERNALS/UTILS */ - public boolean hasListeners() { + boolean hasListeners() { return !documentSnapshotListeners.isEmpty(); } @@ -164,14 +165,14 @@ public class RNFirebaseFirestoreDocumentReference { * @param listenerId * @param documentSnapshot */ - private void handleDocumentSnapshotEvent(int listenerId, DocumentSnapshot documentSnapshot) { + private void handleDocumentSnapshotEvent(String listenerId, DocumentSnapshot documentSnapshot) { WritableMap event = Arguments.createMap(); WritableMap data = FirestoreSerialize.snapshotToWritableMap(documentSnapshot); event.putString("appName", appName); event.putString("path", path); - event.putInt("listenerId", listenerId); - event.putMap("document", data); + event.putString("listenerId", listenerId); + event.putMap("documentSnapshot", data); Utils.sendEvent(reactContext, "firestore_document_sync_event", event); } @@ -182,12 +183,12 @@ public class RNFirebaseFirestoreDocumentReference { * @param listenerId * @param exception */ - private void handleDocumentSnapshotError(int listenerId, FirebaseFirestoreException exception) { + private void handleDocumentSnapshotError(String listenerId, FirebaseFirestoreException exception) { WritableMap event = Arguments.createMap(); event.putString("appName", appName); event.putString("path", path); - event.putInt("listenerId", listenerId); + event.putString("listenerId", listenerId); event.putMap("error", RNFirebaseFirestore.getJSError(exception)); Utils.sendEvent(reactContext, "firestore_document_sync_event", event); diff --git a/ios/RNFirebase/firestore/RNFirebaseFirestore.h b/ios/RNFirebase/firestore/RNFirebaseFirestore.h index e1cbcea6..4ecd5cae 100644 --- a/ios/RNFirebase/firestore/RNFirebaseFirestore.h +++ b/ios/RNFirebase/firestore/RNFirebaseFirestore.h @@ -10,8 +10,6 @@ #import @interface RNFirebaseFirestore : RCTEventEmitter {} -@property NSMutableDictionary *collectionReferences; -@property NSMutableDictionary *documentReferences; + (void)promiseRejectException:(RCTPromiseRejectBlock)reject error:(NSError *)error; diff --git a/ios/RNFirebase/firestore/RNFirebaseFirestore.m b/ios/RNFirebase/firestore/RNFirebaseFirestore.m index 8d2b7c97..08c5c6dc 100644 --- a/ios/RNFirebase/firestore/RNFirebaseFirestore.m +++ b/ios/RNFirebase/firestore/RNFirebaseFirestore.m @@ -13,7 +13,7 @@ RCT_EXPORT_MODULE(); - (id)init { self = [super init]; if (self != nil) { - _documentReferences = [[NSMutableDictionary alloc] init]; + } return self; } @@ -28,6 +28,25 @@ RCT_EXPORT_METHOD(collectionGet:(NSString *) appName [[self getCollectionForAppPath:appName path:path filters:filters orders:orders options:options] get:resolve rejecter:reject]; } +RCT_EXPORT_METHOD(collectionOffSnapshot:(NSString *) appName + path:(NSString *) path + filters:(NSArray *) filters + orders:(NSArray *) orders + options:(NSDictionary *) options + listenerId:(nonnull NSString *) listenerId) { + [RNFirebaseFirestoreCollectionReference offSnapshot:listenerId]; +} + +RCT_EXPORT_METHOD(collectionOnSnapshot:(NSString *) appName + path:(NSString *) path + filters:(NSArray *) filters + orders:(NSArray *) orders + options:(NSDictionary *) options + listenerId:(nonnull NSString *) listenerId) { + RNFirebaseFirestoreCollectionReference *ref = [self getCollectionForAppPath:appName path:path filters:filters orders:orders options:options]; + [ref onSnapshot:listenerId]; +} + RCT_EXPORT_METHOD(documentBatch:(NSString *) appName writes:(NSArray *) writes commitOptions:(NSDictionary *) commitOptions @@ -111,19 +130,14 @@ RCT_EXPORT_METHOD(documentGetAll:(NSString *) appName RCT_EXPORT_METHOD(documentOffSnapshot:(NSString *) appName path:(NSString *) path - listenerId:(nonnull NSNumber *) listenerId) { - RNFirebaseFirestoreDocumentReference *ref = [self getCachedDocumentForAppPath:appName path:path]; - [ref offSnapshot:listenerId]; - - if (![ref hasListeners]) { - [self clearCachedDocumentForAppPath:appName path:path]; - } + listenerId:(nonnull NSString *) listenerId) { + [RNFirebaseFirestoreDocumentReference offSnapshot:listenerId]; } RCT_EXPORT_METHOD(documentOnSnapshot:(NSString *) appName path:(NSString *) path - listenerId:(nonnull NSNumber *) listenerId) { - RNFirebaseFirestoreDocumentReference *ref = [self getCachedDocumentForAppPath:appName path:path]; + listenerId:(nonnull NSString *) listenerId) { + RNFirebaseFirestoreDocumentReference *ref = [self getDocumentForAppPath:appName path:path]; [ref onSnapshot:listenerId]; } @@ -158,23 +172,7 @@ RCT_EXPORT_METHOD(documentUpdate:(NSString *) appName } - (RNFirebaseFirestoreCollectionReference *)getCollectionForAppPath:(NSString *)appName path:(NSString *)path filters:(NSArray *)filters orders:(NSArray *)orders options:(NSDictionary *)options { - return [[RNFirebaseFirestoreCollectionReference alloc] initWithPathAndModifiers:appName path:path filters:filters orders:orders options:options]; -} - -- (RNFirebaseFirestoreDocumentReference *)getCachedDocumentForAppPath:(NSString *)appName path:(NSString *)path { - NSString *key = [NSString stringWithFormat:@"%@/%@", appName, path]; - RNFirebaseFirestoreDocumentReference *ref = _documentReferences[key]; - - if (ref == nil) { - ref = [self getDocumentForAppPath:appName path:path]; - _documentReferences[key] = ref; - } - return ref; -} - -- (void)clearCachedDocumentForAppPath:(NSString *)appName path:(NSString *)path { - NSString *key = [NSString stringWithFormat:@"%@/%@", appName, path]; - [_documentReferences removeObjectForKey:key]; + return [[RNFirebaseFirestoreCollectionReference alloc] initWithPathAndModifiers:self app:appName path:path filters:filters orders:orders options:options]; } - (RNFirebaseFirestoreDocumentReference *)getDocumentForAppPath:(NSString *)appName path:(NSString *)path { diff --git a/ios/RNFirebase/firestore/RNFirebaseFirestoreCollectionReference.h b/ios/RNFirebase/firestore/RNFirebaseFirestoreCollectionReference.h index 04c668dd..9bf003a2 100644 --- a/ios/RNFirebase/firestore/RNFirebaseFirestoreCollectionReference.h +++ b/ios/RNFirebase/firestore/RNFirebaseFirestoreCollectionReference.h @@ -5,10 +5,13 @@ #if __has_include() #import +#import +#import "RNFirebaseEvents.h" #import "RNFirebaseFirestore.h" #import "RNFirebaseFirestoreDocumentReference.h" @interface RNFirebaseFirestoreCollectionReference : NSObject +@property RCTEventEmitter *emitter; @property NSString *app; @property NSString *path; @property NSArray *filters; @@ -16,8 +19,10 @@ @property NSDictionary *options; @property FIRQuery *query; -- (id)initWithPathAndModifiers:(NSString *)app path:(NSString *)path filters:(NSArray *)filters orders:(NSArray *)orders options:(NSDictionary *)options; +- (id)initWithPathAndModifiers:(RCTEventEmitter *)emitter app:(NSString *)app path:(NSString *)path filters:(NSArray *)filters orders:(NSArray *)orders options:(NSDictionary *)options; - (void)get:(RCTPromiseResolveBlock) resolve rejecter:(RCTPromiseRejectBlock) reject; ++ (void)offSnapshot:(NSString *)listenerId; +- (void)onSnapshot:(NSString *)listenerId; + (NSDictionary *)snapshotToDictionary:(FIRQuerySnapshot *)querySnapshot; @end diff --git a/ios/RNFirebase/firestore/RNFirebaseFirestoreCollectionReference.m b/ios/RNFirebase/firestore/RNFirebaseFirestoreCollectionReference.m index 1b558963..70fb7ea6 100644 --- a/ios/RNFirebase/firestore/RNFirebaseFirestoreCollectionReference.m +++ b/ios/RNFirebase/firestore/RNFirebaseFirestoreCollectionReference.m @@ -4,13 +4,17 @@ #if __has_include() -- (id)initWithPathAndModifiers:(NSString *) app +static NSMutableDictionary *_listeners; + +- (id)initWithPathAndModifiers:(RCTEventEmitter *) emitter + app:(NSString *) app path:(NSString *) path filters:(NSArray *) filters orders:(NSArray *) orders options:(NSDictionary *) options { self = [super init]; if (self) { + _emitter = emitter; _app = app; _path = path; _filters = filters; @@ -18,6 +22,10 @@ _options = options; _query = [self buildQuery]; } + // Initialise the static listeners object if required + if (!_listeners) { + _listeners = [[NSMutableDictionary alloc] init]; + } return self; } @@ -33,6 +41,33 @@ }]; } ++ (void)offSnapshot:(NSString *) listenerId { + id listener = _listeners[listenerId]; + if (listener) { + [_listeners removeObjectForKey:listenerId]; + [listener remove]; + } +} + +- (void)onSnapshot:(NSString *) listenerId { + if (_listeners[listenerId] == nil) { + id listenerBlock = ^(FIRQuerySnapshot * _Nullable snapshot, NSError * _Nullable error) { + if (error) { + id listener = _listeners[listenerId]; + if (listener) { + [_listeners removeObjectForKey:listenerId]; + [listener remove]; + } + [self handleQuerySnapshotError:listenerId error:error]; + } else { + [self handleQuerySnapshotEvent:listenerId querySnapshot:snapshot]; + } + }; + id listener = [_query addSnapshotListener:listenerBlock]; + _listeners[listenerId] = listener; + } +} + - (FIRQuery *)buildQuery { FIRQuery *query = (FIRQuery*)[[RNFirebaseFirestore getFirestoreForApp:_app] collectionWithPath:_path]; query = [self applyFilters:query]; @@ -96,6 +131,28 @@ return query; } +- (void)handleQuerySnapshotError:(NSString *)listenerId + error:(NSError *)error { + NSMutableDictionary *event = [[NSMutableDictionary alloc] init]; + [event setValue:_app forKey:@"appName"]; + [event setValue:_path forKey:@"path"]; + [event setValue:listenerId forKey:@"listenerId"]; + [event setValue:[RNFirebaseFirestore getJSError:error] forKey:@"error"]; + + [_emitter sendEventWithName:FIRESTORE_COLLECTION_SYNC_EVENT body:event]; +} + +- (void)handleQuerySnapshotEvent:(NSString *)listenerId + querySnapshot:(FIRQuerySnapshot *)querySnapshot { + NSMutableDictionary *event = [[NSMutableDictionary alloc] init]; + [event setValue:_app forKey:@"appName"]; + [event setValue:_path forKey:@"path"]; + [event setValue:listenerId forKey:@"listenerId"]; + [event setValue:[RNFirebaseFirestoreCollectionReference snapshotToDictionary:querySnapshot] forKey:@"querySnapshot"]; + + [_emitter sendEventWithName:FIRESTORE_COLLECTION_SYNC_EVENT body:event]; +} + + (NSDictionary *)snapshotToDictionary:(FIRQuerySnapshot *)querySnapshot { NSMutableDictionary *snapshot = [[NSMutableDictionary alloc] init]; [snapshot setValue:[self documentChangesToArray:querySnapshot.documentChanges] forKey:@"changes"]; diff --git a/ios/RNFirebase/firestore/RNFirebaseFirestoreDocumentReference.h b/ios/RNFirebase/firestore/RNFirebaseFirestoreDocumentReference.h index 421c3a1c..eac466f5 100644 --- a/ios/RNFirebase/firestore/RNFirebaseFirestoreDocumentReference.h +++ b/ios/RNFirebase/firestore/RNFirebaseFirestoreDocumentReference.h @@ -15,15 +15,14 @@ @property NSString *app; @property NSString *path; @property FIRDocumentReference *ref; -@property NSMutableDictionary *listeners; - (id)initWithPath:(RCTEventEmitter *)emitter app:(NSString *)app path:(NSString *)path; - (void)collections:(RCTPromiseResolveBlock) resolve rejecter:(RCTPromiseRejectBlock) reject; - (void)create:(NSDictionary *)data resolver:(RCTPromiseResolveBlock) resolve rejecter:(RCTPromiseRejectBlock) reject; - (void)delete:(NSDictionary *)options resolver:(RCTPromiseResolveBlock) resolve rejecter:(RCTPromiseRejectBlock) reject; - (void)get:(RCTPromiseResolveBlock) resolve rejecter:(RCTPromiseRejectBlock) reject; -- (void)offSnapshot:(NSNumber *)listenerId; -- (void)onSnapshot:(NSNumber *)listenerId; ++ (void)offSnapshot:(NSString *)listenerId; +- (void)onSnapshot:(NSString *)listenerId; - (void)set:(NSDictionary *)data options:(NSDictionary *)options resolver:(RCTPromiseResolveBlock) resolve rejecter:(RCTPromiseRejectBlock) reject; - (void)update:(NSDictionary *)data resolver:(RCTPromiseResolveBlock) resolve rejecter:(RCTPromiseRejectBlock) reject; - (BOOL)hasListeners; diff --git a/ios/RNFirebase/firestore/RNFirebaseFirestoreDocumentReference.m b/ios/RNFirebase/firestore/RNFirebaseFirestoreDocumentReference.m index e6ede48f..cb56e86e 100644 --- a/ios/RNFirebase/firestore/RNFirebaseFirestoreDocumentReference.m +++ b/ios/RNFirebase/firestore/RNFirebaseFirestoreDocumentReference.m @@ -4,6 +4,8 @@ #if __has_include() +static NSMutableDictionary *_listeners; + - (id)initWithPath:(RCTEventEmitter *)emitter app:(NSString *) app path:(NSString *) path { @@ -13,6 +15,9 @@ _app = app; _path = path; _ref = [[RNFirebaseFirestore getFirestoreForApp:_app] documentWithPath:_path]; + } + // Initialise the static listeners object if required + if (!_listeners) { _listeners = [[NSMutableDictionary alloc] init]; } return self; @@ -49,7 +54,7 @@ }]; } -- (void)offSnapshot:(NSNumber *) listenerId { ++ (void)offSnapshot:(NSString *) listenerId { id listener = _listeners[listenerId]; if (listener) { [_listeners removeObjectForKey:listenerId]; @@ -57,7 +62,7 @@ } } -- (void)onSnapshot:(NSNumber *) listenerId { +- (void)onSnapshot:(NSString *) listenerId { if (_listeners[listenerId] == nil) { id listenerBlock = ^(FIRDocumentSnapshot * _Nullable snapshot, NSError * _Nullable error) { if (error) { @@ -130,7 +135,7 @@ return snapshot; } -- (void)handleDocumentSnapshotError:(NSNumber *)listenerId +- (void)handleDocumentSnapshotError:(NSString *)listenerId error:(NSError *)error { NSMutableDictionary *event = [[NSMutableDictionary alloc] init]; [event setValue:_app forKey:@"appName"]; @@ -141,13 +146,13 @@ [_emitter sendEventWithName:FIRESTORE_DOCUMENT_SYNC_EVENT body:event]; } -- (void)handleDocumentSnapshotEvent:(NSNumber *)listenerId +- (void)handleDocumentSnapshotEvent:(NSString *)listenerId documentSnapshot:(FIRDocumentSnapshot *)documentSnapshot { NSMutableDictionary *event = [[NSMutableDictionary alloc] init]; [event setValue:_app forKey:@"appName"]; [event setValue:_path forKey:@"path"]; [event setValue:listenerId forKey:@"listenerId"]; - [event setValue:[RNFirebaseFirestoreDocumentReference snapshotToDictionary:documentSnapshot] forKey:@"document"]; + [event setValue:[RNFirebaseFirestoreDocumentReference snapshotToDictionary:documentSnapshot] forKey:@"documentSnapshot"]; [_emitter sendEventWithName:FIRESTORE_DOCUMENT_SYNC_EVENT body:event]; } diff --git a/lib/modules/firestore/DocumentReference.js b/lib/modules/firestore/DocumentReference.js index a061ca18..9873c2cd 100644 --- a/lib/modules/firestore/DocumentReference.js +++ b/lib/modules/firestore/DocumentReference.js @@ -6,6 +6,7 @@ import CollectionReference from './CollectionReference'; import DocumentSnapshot from './DocumentSnapshot'; import Path from './Path'; import INTERNALS from './../../internals'; +import { firestoreAutoId } from '../../utils'; export type DeleteOptions = { lastUpdateTime?: string, @@ -19,9 +20,6 @@ export type WriteResult = { writeTime: string, } -// track all event registrations -let listeners = 0; - /** * @class DocumentReference */ @@ -94,12 +92,17 @@ export default class DocumentReference { onSnapshot(onNext: Function, onError?: Function): () => void { // TODO: Validation - const listenerId = listeners++; + const listenerId = firestoreAutoId(); + + const listener = (nativeDocumentSnapshot) => { + const documentSnapshot = new DocumentSnapshot(this, nativeDocumentSnapshot); + onNext(documentSnapshot); + }; // Listen to snapshot events this._firestore.on( this._firestore._getAppEventName(`onDocumentSnapshot:${listenerId}`), - onNext, + listener, ); // Listen for snapshot error events @@ -115,7 +118,7 @@ export default class DocumentReference { .documentOnSnapshot(this.path, listenerId); // Return an unsubscribe method - return this._offDocumentSnapshot.bind(this, listenerId, onNext); + return this._offDocumentSnapshot.bind(this, listenerId, listener); } set(data: { [string]: any }, writeOptions?: WriteOptions): Promise { @@ -130,11 +133,14 @@ export default class DocumentReference { } /** - * Remove auth change listener + * Remove document snapshot listener * @param listener */ _offDocumentSnapshot(listenerId: number, listener: Function) { this._firestore.log.info('Removing onDocumentSnapshot listener'); this._firestore.removeListener(this._firestore._getAppEventName(`onDocumentSnapshot:${listenerId}`), listener); + this._firestore.removeListener(this._firestore._getAppEventName(`onDocumentSnapshotError:${listenerId}`), listener); + this._firestore._native + .documentOffSnapshot(this.path, listenerId); } } diff --git a/lib/modules/firestore/Query.js b/lib/modules/firestore/Query.js index e415ff0b..c4f271f5 100644 --- a/lib/modules/firestore/Query.js +++ b/lib/modules/firestore/Query.js @@ -5,7 +5,8 @@ import DocumentSnapshot from './DocumentSnapshot'; import Path from './Path'; import QuerySnapshot from './QuerySnapshot'; -import INTERNALS from './../../internals'; +import INTERNALS from '../../internals'; +import { firestoreAutoId } from '../../utils'; const DIRECTIONS = { ASC: 'ASCENDING', @@ -51,6 +52,7 @@ export default class Query { _fieldFilters: FieldFilter[]; _fieldOrders: FieldOrder[]; _firestore: Object; + _iid: number; _queryOptions: QueryOptions; _referencePath: Path; @@ -128,7 +130,40 @@ export default class Query { } onSnapshot(onNext: () => any, onError?: () => any): () => void { + // TODO: Validation + const listenerId = firestoreAutoId(); + const listener = (nativeQuerySnapshot) => { + const querySnapshot = new QuerySnapshot(this._firestore, this, nativeQuerySnapshot); + onNext(querySnapshot); + }; + + // Listen to snapshot events + this._firestore.on( + this._firestore._getAppEventName(`onQuerySnapshot:${listenerId}`), + listener, + ); + + // Listen for snapshot error events + if (onError) { + this._firestore.on( + this._firestore._getAppEventName(`onQuerySnapshotError:${listenerId}`), + onError, + ); + } + + // Add the native listener + this._firestore._native + .collectionOnSnapshot( + this._referencePath.relativeName, + this._fieldFilters, + this._fieldOrders, + this._queryOptions, + listenerId + ); + + // Return an unsubscribe method + return this._offCollectionSnapshot.bind(this, listenerId, listener); } orderBy(fieldPath: string, directionStr?: Direction = 'asc'): Query { @@ -216,4 +251,22 @@ export default class Query { return new Query(this.firestore, this._referencePath, combinedFilters, this._fieldOrders, this._queryOptions); } + + /** + * Remove query snapshot listener + * @param listener + */ + _offCollectionSnapshot(listenerId: number, listener: Function) { + this._firestore.log.info('Removing onQuerySnapshot listener'); + this._firestore.removeListener(this._firestore._getAppEventName(`onQuerySnapshot:${listenerId}`), listener); + this._firestore.removeListener(this._firestore._getAppEventName(`onQuerySnapshotError:${listenerId}`), listener); + this._firestore._native + .collectionOffSnapshot( + this._referencePath.relativeName, + this._fieldFilters, + this._fieldOrders, + this._queryOptions, + listenerId + ); + } } diff --git a/lib/modules/firestore/QuerySnapshot.js b/lib/modules/firestore/QuerySnapshot.js index c1a6b035..4b3f4b3e 100644 --- a/lib/modules/firestore/QuerySnapshot.js +++ b/lib/modules/firestore/QuerySnapshot.js @@ -59,7 +59,7 @@ export default class QuerySnapshot { // TODO: Validation // validate.isFunction('callback', callback); - for (const doc of this.docs) { + for (const doc of this._docs) { callback(doc); } } diff --git a/lib/modules/firestore/index.js b/lib/modules/firestore/index.js index e50f0482..4f1ae343 100644 --- a/lib/modules/firestore/index.js +++ b/lib/modules/firestore/index.js @@ -16,11 +16,19 @@ import INTERNALS from './../../internals'; const unquotedIdentifier_ = '(?:[A-Za-z_][A-Za-z_0-9]*)'; const UNQUOTED_IDENTIFIER_REGEX = new RegExp(`^${unquotedIdentifier_}$`); +type CollectionSyncEvent = { + appName: string, + querySnapshot?: QuerySnapshot, + error?: Object, + listenerId: string, + path: string, +} + type DocumentSyncEvent = { appName: string, - document?: DocumentSnapshot, + documentSnapshot?: DocumentSnapshot, error?: Object, - listenerId: number, + listenerId: string, path: string, } @@ -139,11 +147,11 @@ export default class Firestore extends ModuleBase { * @param event * @private */ - _onCollectionSyncEvent(event: DocumentSyncEvent) { + _onCollectionSyncEvent(event: CollectionSyncEvent) { if (event.error) { - this.emit(this._getAppEventName(`onCollectionSnapshotError:${event.listenerId}`, event.error)); + this.emit(this._getAppEventName(`onQuerySnapshotError:${event.listenerId}`), event.error); } else { - this.emit(this._getAppEventName(`onCollectionSnapshot:${event.listenerId}`, event.document)); + this.emit(this._getAppEventName(`onQuerySnapshot:${event.listenerId}`), event.querySnapshot); } } @@ -156,8 +164,7 @@ export default class Firestore extends ModuleBase { if (event.error) { this.emit(this._getAppEventName(`onDocumentSnapshotError:${event.listenerId}`), event.error); } else { - const snapshot = new DocumentSnapshot(this, event.document); - this.emit(this._getAppEventName(`onDocumentSnapshot:${event.listenerId}`), snapshot); + this.emit(this._getAppEventName(`onDocumentSnapshot:${event.listenerId}`), event.documentSnapshot); } } } diff --git a/tests/src/tests/firestore/collectionReferenceTests.js b/tests/src/tests/firestore/collectionReferenceTests.js index d0ba525d..ab2a1f92 100644 --- a/tests/src/tests/firestore/collectionReferenceTests.js +++ b/tests/src/tests/firestore/collectionReferenceTests.js @@ -1,3 +1,5 @@ +import sinon from 'sinon'; +import 'should-sinon'; import should from 'should'; function collectionReferenceTests({ describe, it, context, firebase }) { @@ -50,6 +52,266 @@ function collectionReferenceTests({ describe, it, context, firebase }) { }); }); + context('onSnapshot()', () => { + it('calls callback with the initial data and then when document changes', () => { + return new Promise(async (resolve) => { + const collectionRef = firebase.native.firestore().collection('document-tests'); + const currentDocValue = { name: 'doc1' }; + const newDocValue = { name: 'updated' }; + + const callback = sinon.spy(); + + // Test + + let unsubscribe; + await new Promise((resolve2) => { + unsubscribe = collectionRef.onSnapshot((snapshot) => { + snapshot.forEach(doc => callback(doc.data())); + resolve2(); + }); + }); + + callback.should.be.calledWith(currentDocValue); + + const docRef = firebase.native.firestore().doc('document-tests/doc1'); + await docRef.set(newDocValue); + + await new Promise((resolve2) => { + setTimeout(() => resolve2(), 5); + }); + + // Assertions + + callback.should.be.calledWith(newDocValue); + callback.should.be.calledTwice(); + + // Tear down + + unsubscribe(); + + resolve(); + }); + }); + }); + + context('onSnapshot()', () => { + it('calls callback with the initial data and then when document is added', () => { + return new Promise(async (resolve) => { + const collectionRef = firebase.native.firestore().collection('document-tests'); + const currentDocValue = { name: 'doc1' }; + const newDocValue = { name: 'updated' }; + + const callback = sinon.spy(); + + // Test + + let unsubscribe; + await new Promise((resolve2) => { + unsubscribe = collectionRef.onSnapshot((snapshot) => { + snapshot.forEach(doc => callback(doc.data())); + resolve2(); + }); + }); + + callback.should.be.calledWith(currentDocValue); + + const docRef = firebase.native.firestore().doc('document-tests/doc2'); + await docRef.set(newDocValue); + + await new Promise((resolve2) => { + setTimeout(() => resolve2(), 5); + }); + + // Assertions + + callback.should.be.calledWith(currentDocValue); + callback.should.be.calledWith(newDocValue); + callback.should.be.calledThrice(); + + // Tear down + + unsubscribe(); + + resolve(); + }); + }); + }); + + context('onSnapshot()', () => { + it('doesn\'t call callback when the ref is updated with the same value', async () => { + return new Promise(async (resolve) => { + const collectionRef = firebase.native.firestore().collection('document-tests'); + const currentDocValue = { name: 'doc1' }; + + const callback = sinon.spy(); + + // Test + + let unsubscribe; + await new Promise((resolve2) => { + unsubscribe = collectionRef.onSnapshot((snapshot) => { + snapshot.forEach(doc => callback(doc.data())); + resolve2(); + }); + }); + + callback.should.be.calledWith(currentDocValue); + + const docRef = firebase.native.firestore().doc('document-tests/doc1'); + await docRef.set(currentDocValue); + + await new Promise((resolve2) => { + setTimeout(() => resolve2(), 5); + }); + + // Assertions + + callback.should.be.calledOnce(); // Callback is not called again + + // Tear down + + unsubscribe(); + + resolve(); + }); + }); + }); + + context('onSnapshot()', () => { + it('allows binding multiple callbacks to the same ref', () => { + return new Promise(async (resolve) => { + // Setup + const collectionRef = firebase.native.firestore().collection('document-tests'); + const currentDocValue = { name: 'doc1' }; + const newDocValue = { name: 'updated' }; + + const callbackA = sinon.spy(); + const callbackB = sinon.spy(); + + // Test + let unsubscribeA; + let unsubscribeB; + await new Promise((resolve2) => { + unsubscribeA = collectionRef.onSnapshot((snapshot) => { + snapshot.forEach(doc => callbackA(doc.data())); + resolve2(); + }); + }); + await new Promise((resolve2) => { + unsubscribeB = collectionRef.onSnapshot((snapshot) => { + snapshot.forEach(doc => callbackB(doc.data())); + resolve2(); + }); + }); + + callbackA.should.be.calledWith(currentDocValue); + callbackA.should.be.calledOnce(); + + callbackB.should.be.calledWith(currentDocValue); + callbackB.should.be.calledOnce(); + + const docRef = firebase.native.firestore().doc('document-tests/doc1'); + await docRef.set(newDocValue); + + await new Promise((resolve2) => { + setTimeout(() => resolve2(), 5); + }); + + callbackA.should.be.calledWith(newDocValue); + callbackB.should.be.calledWith(newDocValue); + + callbackA.should.be.calledTwice(); + callbackB.should.be.calledTwice(); + + // Tear down + + unsubscribeA(); + unsubscribeB(); + + resolve(); + }); + }); + }); + + context('onSnapshot()', () => { + it('listener stops listening when unsubscribed', () => { + return new Promise(async (resolve) => { + // Setup + const collectionRef = firebase.native.firestore().collection('document-tests'); + const currentDocValue = { name: 'doc1' }; + const newDocValue = { name: 'updated' }; + + const callbackA = sinon.spy(); + const callbackB = sinon.spy(); + + // Test + let unsubscribeA; + let unsubscribeB; + await new Promise((resolve2) => { + unsubscribeA = collectionRef.onSnapshot((snapshot) => { + snapshot.forEach(doc => callbackA(doc.data())); + resolve2(); + }); + }); + await new Promise((resolve2) => { + unsubscribeB = collectionRef.onSnapshot((snapshot) => { + snapshot.forEach(doc => callbackB(doc.data())); + resolve2(); + }); + }); + + callbackA.should.be.calledWith(currentDocValue); + callbackA.should.be.calledOnce(); + + callbackB.should.be.calledWith(currentDocValue); + callbackB.should.be.calledOnce(); + + const docRef = firebase.native.firestore().doc('document-tests/doc1'); + await docRef.set(newDocValue); + + await new Promise((resolve2) => { + setTimeout(() => resolve2(), 5); + }); + + callbackA.should.be.calledWith(newDocValue); + callbackB.should.be.calledWith(newDocValue); + + callbackA.should.be.calledTwice(); + callbackB.should.be.calledTwice(); + + // Unsubscribe A + + unsubscribeA(); + + await docRef.set(currentDocValue); + + await new Promise((resolve2) => { + setTimeout(() => resolve2(), 5); + }); + + callbackB.should.be.calledWith(currentDocValue); + + callbackA.should.be.calledTwice(); + callbackB.should.be.calledThrice(); + + // Unsubscribe B + + unsubscribeB(); + + await docRef.set(newDocValue); + + await new Promise((resolve2) => { + setTimeout(() => resolve2(), 5); + }); + + callbackA.should.be.calledTwice(); + callbackB.should.be.calledThrice(); + + resolve(); + }); + }); + }); + // Where context('where()', () => { it('correctly handles == boolean values', () => { diff --git a/tests/src/tests/firestore/documentReferenceTests.js b/tests/src/tests/firestore/documentReferenceTests.js index f8b9afd6..11166366 100644 --- a/tests/src/tests/firestore/documentReferenceTests.js +++ b/tests/src/tests/firestore/documentReferenceTests.js @@ -49,6 +49,8 @@ function collectionReferenceTests({ describe, it, context, firebase }) { callback.should.be.calledWith(currentDataValue); + // Update the document + await docRef.set(newDataValue); await new Promise((resolve2) => {