Skip to content

feat(firestore): add support for onSnapshotsInSync #8379

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 50 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
c22d0ce
chore(firestore): add support for onSnapshotsInSync
SelaseKay Feb 28, 2025
68c1770
fix: formatting
MichaelVerdon Apr 29, 2025
0029fde
fix: formatting
MichaelVerdon Apr 29, 2025
87af997
fix: formatting
MichaelVerdon Apr 29, 2025
3238254
feat: add other impl
MichaelVerdon Apr 29, 2025
efc3a83
feat: add test files
MichaelVerdon Apr 29, 2025
748393b
feat: add test
MichaelVerdon Apr 29, 2025
b398fa0
feat: started native code
MichaelVerdon Apr 30, 2025
5d620ea
feat: started working on listeners
MichaelVerdon May 7, 2025
7749305
feat: setup android for onSnapshotInSync
russellwheatley May 20, 2025
3ed0181
feat: create ios implementation
MichaelVerdon May 20, 2025
6f7ab49
chore: ios implementation
MichaelVerdon May 21, 2025
a23800e
chore: more ios
MichaelVerdon May 21, 2025
20838bf
fix: bracket
MichaelVerdon May 21, 2025
15e4ded
chore: add definition
MichaelVerdon May 21, 2025
87c7e5f
chore: fixes
MichaelVerdon May 21, 2025
eed743a
chore: fixes
MichaelVerdon May 21, 2025
0f44818
chore: rm wrong arg
russellwheatley May 21, 2025
d7005ce
chore: fix bugs
MichaelVerdon May 21, 2025
ae7274b
feat: change location of tests and add check on iOS
MichaelVerdon May 27, 2025
1771cc6
feat: expose function
MichaelVerdon May 27, 2025
9041b80
feat: add and remove listeners
MichaelVerdon May 28, 2025
94b6082
chore: clean up tests
MichaelVerdon Jun 3, 2025
763e343
chore: fixed e2e test
MichaelVerdon Jun 4, 2025
b131da9
chore: add multiple sync e2e test
MichaelVerdon Jun 4, 2025
749b207
chore: fix formatting
MichaelVerdon Jun 4, 2025
2060218
chore: cleanup
MichaelVerdon Jun 4, 2025
6d624d4
chore: formatting
MichaelVerdon Jun 4, 2025
d1b1a80
chore: format android
MichaelVerdon Jun 4, 2025
faed3e4
chore: ios format
MichaelVerdon Jun 4, 2025
9982f21
Update packages/firestore/ios/RNFBFirestore/RNFBFirestoreModule.m
MichaelVerdon Jun 4, 2025
17d80c5
Update packages/firestore/ios/RNFBFirestore/RNFBFirestoreModule.m
MichaelVerdon Jun 4, 2025
7f914b8
Update packages/firestore/ios/RNFBFirestore/RNFBFirestoreModule.m
MichaelVerdon Jun 4, 2025
982262f
Update packages/firestore/ios/RNFBFirestore/RNFBFirestoreModule.m
MichaelVerdon Jun 4, 2025
ebca7a3
chore: remove redundant check
MichaelVerdon Jun 4, 2025
313a465
chore: fix test logic promises
MichaelVerdon Jun 4, 2025
3a256d5
chore: throw error on other platform
russellwheatley Jun 5, 2025
da39e34
chore: tests for macos
MichaelVerdon Jun 5, 2025
4004df1
chore: fix test logic
MichaelVerdon Jun 5, 2025
99ac64f
chore: formatting
MichaelVerdon Jun 5, 2025
e3353fa
chore: fix formatting issue
MichaelVerdon Jun 9, 2025
515671c
chore: formatting
MichaelVerdon Jun 9, 2025
3eb74d4
chore: improve e2e test
MichaelVerdon Jun 9, 2025
e9f0cfa
chore: formatting
MichaelVerdon Jun 9, 2025
b0901cd
chore: promise formatting again
MichaelVerdon Jun 9, 2025
fbceb35
fix: formatting
MichaelVerdon Jun 9, 2025
3e8cc7f
chore: revert e2e
MichaelVerdon Jun 9, 2025
8a586cf
fix: rid of redundant call
MichaelVerdon Jun 9, 2025
0a5e165
fix: linter
MichaelVerdon Jun 9, 2025
7839fa7
chore: change tests
MichaelVerdon Jun 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,13 +39,43 @@
import java.util.Objects;

public class UniversalFirebaseFirestoreModule extends UniversalFirebaseModule {
private static SparseArray<ListenerRegistration> onSnapshotInSyncListeners = new SparseArray<>();

private static HashMap<String, String> emulatorConfigs = new HashMap<>();

UniversalFirebaseFirestoreModule(Context context, String serviceName) {
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<Void> disableNetwork(String appName, String databaseId) {
return getFirestoreForApp(appName, databaseId).disableNetwork();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
96 changes: 96 additions & 0 deletions packages/firestore/e2e/firestore.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});
});
});
});
48 changes: 48 additions & 0 deletions packages/firestore/ios/RNFBFirestore/RNFBFirestoreModule.m
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@
*/

#import "RNFBFirestoreModule.h"
#import <RNFBApp/RNFBRCTEventEmitter.h>
#import <React/RCTUtils.h>
#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 -
Expand Down Expand Up @@ -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<FIRListenerRegistration> 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<FIRListenerRegistration> 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);
Expand Down
8 changes: 8 additions & 0 deletions packages/firestore/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions packages/firestore/lib/modular/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,24 @@
return firestore.collectionGroup.call(firestore, collectionId, MODULAR_DEPRECATION_ARG);
}

let _id_SnapshotInSync = 0;

export function onSnapshotsInSync(firestore, callback) {

Check warning on line 94 in packages/firestore/lib/modular/index.js

View check run for this annotation

Codecov / codecov/patch

packages/firestore/lib/modular/index.js#L94

Added line #L94 was not covered by tests
const listenerId = _id_SnapshotInSync++;
firestore.native.addSnapshotsInSync(listenerId);
const onSnapshotsInSyncSubscription = firestore.emitter.addListener(
firestore.eventNameForApp(`firestore_snapshots_in_sync_event:${listenerId}`),
() => {

Check warning on line 99 in packages/firestore/lib/modular/index.js

View check run for this annotation

Codecov / codecov/patch

packages/firestore/lib/modular/index.js#L99

Added line #L99 was not covered by tests
callback();
},
);

return () => {
onSnapshotsInSyncSubscription.remove();
firestore.native.removeSnapshotsInSync(listenerId);
};
}

/**
* @param {DocumentReference} reference
* @param {import('.').PartialWithFieldValue} data
Expand Down
8 changes: 8 additions & 0 deletions packages/firestore/lib/web/RNFBFirestoreModule.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
48 changes: 48 additions & 0 deletions tests/test-app/examples/firestore/onSnapshotInSync.js
Original file line number Diff line number Diff line change
@@ -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 (
<View>
<Text>React Native Firebase</Text>
<Text>onSnapshotsInSync API</Text>
<Button
title="add document"
onPress={async () => {
try {
addDocument();
} catch (e) {
console.log('EEEE', e);
}
}}
/>
<Button
title="unsubscribe to snapshot in sync"
onPress={async () => {
try {
unsubscribe();
} catch (e) {
console.log('EEEE', e);
}
}}
/>
</View>
);
}

AppRegistry.registerComponent('testing', () => App);
Loading