From a25118a6215d25005abfd68b50105f780d601cbe Mon Sep 17 00:00:00 2001 From: Alex Stanbury Date: Thu, 12 Mar 2026 15:17:53 +0000 Subject: [PATCH 1/9] feat(firestore): add 'source' option to enable local cache firestore listeners --- .../firestore/__tests__/firestore.test.ts | 58 +++++-- ...tiveFirebaseFirestoreCollectionModule.java | 12 +- ...NativeFirebaseFirestoreDocumentModule.java | 16 +- .../e2e/DocumentReference/onSnapshot.e2e.js | 143 ++++++++++++++++++ .../firestore/e2e/Query/onSnapshot.e2e.js | 75 +++++++++ .../RNFBFirestoreCollectionModule.h | 1 + .../RNFBFirestoreCollectionModule.m | 11 +- .../RNFBFirestoreDocumentModule.h | 3 + .../RNFBFirestoreDocumentModule.m | 17 ++- .../lib/FirestoreDocumentReference.ts | 2 +- packages/firestore/lib/FirestoreQuery.ts | 2 +- packages/firestore/lib/types/firestore.ts | 1 + packages/firestore/lib/types/internal.ts | 3 +- packages/firestore/lib/types/namespaced.ts | 10 +- packages/firestore/lib/utils/index.ts | 19 ++- packages/firestore/type-test.ts | 16 ++ 16 files changed, 354 insertions(+), 35 deletions(-) diff --git a/packages/firestore/__tests__/firestore.test.ts b/packages/firestore/__tests__/firestore.test.ts index 496dd80ea1..a07f8d247b 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'; @@ -160,7 +161,7 @@ describe('Firestore', function () { // eslint-disable-next-line no-console const warnOrig = console.warn; // eslint-disable-next-line no-console - console.warn = (_: string) => {}; + console.warn = (_: string) => { }; try { // @ts-ignore the type is incorrect *on purpose* to test type checking in javascript await firebase.firestore().settings({ host: 123 }); @@ -177,7 +178,7 @@ describe('Firestore', function () { // eslint-disable-next-line no-console const warnOrig = console.warn; // eslint-disable-next-line no-console - console.warn = (_: string) => {}; + console.warn = (_: string) => { }; try { await firebase.firestore().settings({ host: '' }); return Promise.reject(new Error('Did not throw an Error.')); @@ -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 () { @@ -949,9 +979,9 @@ describe('Firestore', function () { it('firestore.runTransaction()', function () { const firestore = getFirestore(); firestoreRefV9Deprecation( - () => runTransaction(firestore, async () => {}), + () => runTransaction(firestore, async () => { }), // @ts-expect-error Combines modular and namespace API - () => firestore.runTransaction(async () => {}), + () => firestore.runTransaction(async () => { }), 'runTransaction', ); }); @@ -1041,7 +1071,7 @@ describe('Firestore', function () { collectionRefV9Deprecation( // no equivalent method - () => {}, + () => { }, // @ts-expect-error Combines modular and namespace API () => query.isEqual(query), 'isEqual', @@ -1080,9 +1110,9 @@ describe('Firestore', function () { const query = collection(firestore, 'test'); collectionRefV9Deprecation( - () => onSnapshot(query, () => {}), + () => onSnapshot(query, () => { }), // @ts-expect-error Combines modular and namespace API - () => query.onSnapshot(() => {}), + () => query.onSnapshot(() => { }), 'onSnapshot', ); }); @@ -1213,7 +1243,7 @@ describe('Firestore', function () { docRefV9Deprecation( // no equivalent method - () => {}, + () => { }, // @ts-expect-error Combines modular and namespace API () => docRef.isEqual(docRef), 'isEqual', @@ -1226,9 +1256,9 @@ describe('Firestore', function () { const docRef = doc(firestore, 'some/foo'); docRefV9Deprecation( - () => onSnapshot(docRef, () => {}), + () => onSnapshot(docRef, () => { }), // @ts-expect-error Combines modular and namespace API - () => docRef.onSnapshot(() => {}), + () => docRef.onSnapshot(() => { }), 'onSnapshot', ); }); @@ -1279,7 +1309,7 @@ describe('Firestore', function () { docRefV9Deprecation( // no equivalent method - () => {}, + () => { }, () => snapshot.isEqual(snapshot), 'isEqual', ); @@ -1352,7 +1382,7 @@ describe('Firestore', function () { it('Filter static', function () { staticsV9Deprecation( // no corresponding method - () => {}, + () => { }, () => firestore.Filter, 'Filter', ); @@ -1476,7 +1506,7 @@ describe('Firestore', function () { const timestamp = new firestore.Timestamp(2, 3); timestampV9Deprecation( // no corresponding method - () => {}, + () => { }, () => timestamp.seconds, 'seconds', ); @@ -1486,7 +1516,7 @@ describe('Firestore', function () { const timestamp = new firestore.Timestamp(2000, 3000000); timestampV9Deprecation( // no corresponding method - () => {}, + () => { }, () => timestamp.nanoseconds, 'nanoseconds', ); 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..4afa6ab6df 100644 --- a/packages/firestore/e2e/DocumentReference/onSnapshot.e2e.js +++ b/packages/firestore/e2e/DocumentReference/onSnapshot.e2e.js @@ -305,6 +305,97 @@ 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('uses cache source for document listeners', async function () { + if (Platform.other) { + return; + } + + const docRef = firebase.firestore().doc(`${COLLECTION}/${Utils.randString(12, '#aA')}`); + await docRef.set({ enabled: true }); + await docRef.get(); + + let unsub = () => {}; + try { + await firebase.firestore().disableNetwork(); + const callback = sinon.spy(); + unsub = docRef.onSnapshot({ source: 'cache' }, callback); + await Utils.spyToBeCalledOnceAsync(callback); + callback.args[0][0].metadata.fromCache.should.equal(true); + } finally { + unsub(); + await firebase.firestore().enableNetwork(); + } + }); + + it('supports cache source with metadata changes', async function () { + if (Platform.other) { + return; + } + + const docRef = firebase.firestore().doc(`${COLLECTION}/${Utils.randString(12, '#aA')}`); + await docRef.set({ enabled: true }); + await docRef.get(); + + let unsub = () => {}; + try { + await firebase.firestore().disableNetwork(); + const callback = sinon.spy(); + unsub = docRef.onSnapshot({ source: 'cache', includeMetadataChanges: true }, callback); + await Utils.spyToBeCalledOnceAsync(callback); + callback.args[0][0].metadata.fromCache.should.equal(true); + } finally { + unsub(); + await firebase.firestore().enableNetwork(); + } + }); + it('throws if next callback is invalid', function () { try { firebase.firestore().doc(`${NO_RULE_COLLECTION}/nope`).onSnapshot({ @@ -616,6 +707,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..0ef30a7d16 100644 --- a/packages/firestore/e2e/Query/onSnapshot.e2e.js +++ b/packages/firestore/e2e/Query/onSnapshot.e2e.js @@ -319,6 +319,66 @@ 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('uses cache source for query listeners', 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(); + + let unsub = () => {}; + try { + await firebase.firestore().disableNetwork(); + const callback = sinon.spy(); + unsub = colRef.onSnapshot({ source: 'cache' }, callback); + await Utils.spyToBeCalledOnceAsync(callback); + callback.args[0][0].metadata.fromCache.should.equal(true); + } finally { + unsub(); + await firebase.firestore().enableNetwork(); + } + }); + + it('supports cache source with metadata changes', 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(); + + let unsub = () => {}; + try { + await firebase.firestore().disableNetwork(); + const callback = sinon.spy(); + unsub = colRef.onSnapshot({ source: 'cache', includeMetadataChanges: true }, callback); + await Utils.spyToBeCalledOnceAsync(callback); + callback.args[0][0].metadata.fromCache.should.equal(true); + } finally { + unsub(); + await firebase.firestore().enableNetwork(); + } + }); + it('throws if next callback is invalid', function () { try { firebase.firestore().collection(NO_RULE_COLLECTION).onSnapshot({ @@ -637,6 +697,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/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..ee8c1de3da 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,12 @@ - (void)handleQueryOnSnapshot:(FIRApp *)firebaseApp } }; + FIRSnapshotListenOptions *snapshotListenOptions = + [[[[FIRSnapshotListenOptions alloc] init] + optionsWithIncludeMetadataChanges:includeMetadataChanges] optionsWithSource:source]; id listener = [[firestoreQuery instance] - addSnapshotListenerWithIncludeMetadataChanges:includeMetadataChanges - listener:listenerBlock]; + 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..94e695d942 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreDocumentModule.m +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreDocumentModule.m @@ -97,13 +97,20 @@ - (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; } - id listener = - [documentReference addSnapshotListenerWithIncludeMetadataChanges:includeMetadataChanges - listener:listenerBlock]; + FIRSnapshotListenOptions *snapshotListenOptions = + [[[[FIRSnapshotListenOptions alloc] init] + optionsWithIncludeMetadataChanges:includeMetadataChanges] optionsWithSource:source]; + id listener = [documentReference + addSnapshotListenerWithOptions:snapshotListenOptions + listener:listenerBlock]; documentSnapshotListeners[listenerId] = listener; } diff --git a/packages/firestore/lib/FirestoreDocumentReference.ts b/packages/firestore/lib/FirestoreDocumentReference.ts index c3e4971ec0..97680f7b51 100644 --- a/packages/firestore/lib/FirestoreDocumentReference.ts +++ b/packages/firestore/lib/FirestoreDocumentReference.ts @@ -188,7 +188,7 @@ export default class DocumentReference { } onSnapshot(...args: unknown[]): () => void { - let snapshotListenOptions: { includeMetadataChanges?: boolean }; + let snapshotListenOptions: { includeMetadataChanges?: boolean; source?: 'default' | 'cache' }; 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..426be998b5 100644 --- a/packages/firestore/lib/FirestoreQuery.ts +++ b/packages/firestore/lib/FirestoreQuery.ts @@ -331,7 +331,7 @@ export default class Query { } onSnapshot(...args: unknown[]): () => void { - let snapshotListenOptions: { includeMetadataChanges?: boolean }; + let snapshotListenOptions: { includeMetadataChanges?: boolean; source?: 'default' | 'cache' }; 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..bdf19ef25c 100644 --- a/packages/firestore/lib/types/firestore.ts +++ b/packages/firestore/lib/types/firestore.ts @@ -203,6 +203,7 @@ export type QueryConstraintType = export interface SnapshotListenOptions { readonly includeMetadataChanges?: boolean; + readonly source?: 'default' | 'cache'; } /** diff --git a/packages/firestore/lib/types/internal.ts b/packages/firestore/lib/types/internal.ts index 6703c8d46e..2f2957b738 100644 --- a/packages/firestore/lib/types/internal.ts +++ b/packages/firestore/lib/types/internal.ts @@ -94,9 +94,10 @@ export interface FirestoreAggregateQueryResultInternal { [key: string]: unknown; } -/** Options for snapshot listeners (includeMetadataChanges). */ +/** Options for snapshot listeners (includeMetadataChanges, source). */ export interface FirestoreSnapshotListenOptionsInternal { includeMetadataChanges?: boolean; + source?: 'default' | 'cache'; } /** 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..6305667645 100644 --- a/packages/firestore/lib/types/namespaced.ts +++ b/packages/firestore/lib/types/namespaced.ts @@ -491,7 +491,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 +1356,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 +1764,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?: 'default' | 'cache'; } /** diff --git a/packages/firestore/lib/utils/index.ts b/packages/firestore/lib/utils/index.ts index 7d0c7a2736..db92089769 100644 --- a/packages/firestore/lib/utils/index.ts +++ b/packages/firestore/lib/utils/index.ts @@ -151,7 +151,10 @@ function isPartialObserver( } export interface ParseSnapshotArgsResult { - snapshotListenOptions: { includeMetadataChanges?: boolean }; + snapshotListenOptions: { + includeMetadataChanges?: boolean; + source?: 'default' | 'cache'; + }; callback: (snapshot: unknown, error: Error | null) => void; onNext: (snapshot: unknown) => void; onError: (error: Error) => void; @@ -163,7 +166,10 @@ export function parseSnapshotArgs(args: unknown[]): ParseSnapshotArgsResult { } const NOOP = (): void => {}; - const snapshotListenOptions: { includeMetadataChanges?: boolean } = {}; + const snapshotListenOptions: { + includeMetadataChanges?: boolean; + source?: 'default' | 'cache'; + } = {}; let callback: (snapshot: unknown, error: Error | null) => void = NOOP; let onError: (error: Error) => void = NOOP; let onNext: (snapshot: unknown) => void = NOOP; @@ -189,9 +195,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?: 'default' | 'cache' }; 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 +224,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..c446befca4 100644 --- a/packages/firestore/type-test.ts +++ b/packages/firestore/type-test.ts @@ -106,6 +106,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 +117,7 @@ const unsubDoc3 = nsDocRef.onSnapshot({ unsubDoc1(); unsubDoc2(); unsubDoc3(); +unsubDoc4(); // ----- onSnapshot (query) ----- const unsubQuery1 = nsQuery.onSnapshot((snap: FirebaseFirestoreTypes.QuerySnapshot) => { @@ -122,8 +127,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 @@ -439,15 +449,21 @@ 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 => { From 766b7d5c839fef5d01a911eeffbf035ee6a5dc51 Mon Sep 17 00:00:00 2001 From: Alex Stanbury Date: Thu, 12 Mar 2026 17:24:16 +0000 Subject: [PATCH 2/9] chore: formatting --- .../firestore/__tests__/firestore.test.ts | 34 +++++++-------- packages/firestore/type-test.ts | 42 ++++++++++--------- 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/packages/firestore/__tests__/firestore.test.ts b/packages/firestore/__tests__/firestore.test.ts index a07f8d247b..4088997f93 100644 --- a/packages/firestore/__tests__/firestore.test.ts +++ b/packages/firestore/__tests__/firestore.test.ts @@ -161,7 +161,7 @@ describe('Firestore', function () { // eslint-disable-next-line no-console const warnOrig = console.warn; // eslint-disable-next-line no-console - console.warn = (_: string) => { }; + console.warn = (_: string) => {}; try { // @ts-ignore the type is incorrect *on purpose* to test type checking in javascript await firebase.firestore().settings({ host: 123 }); @@ -178,7 +178,7 @@ describe('Firestore', function () { // eslint-disable-next-line no-console const warnOrig = console.warn; // eslint-disable-next-line no-console - console.warn = (_: string) => { }; + console.warn = (_: string) => {}; try { await firebase.firestore().settings({ host: '' }); return Promise.reject(new Error('Did not throw an Error.')); @@ -502,7 +502,7 @@ describe('Firestore', function () { describe('onSnapshot()', function () { it("accepts { source: 'cache' } listener options", function () { - const parsed = parseSnapshotArgs([{ source: 'cache' }, () => { }]); + const parsed = parseSnapshotArgs([{ source: 'cache' }, () => {}]); expect(parsed.snapshotListenOptions).toEqual({ includeMetadataChanges: false, @@ -513,7 +513,7 @@ describe('Firestore', function () { it("accepts { source: 'default', includeMetadataChanges: true } listener options", function () { const parsed = parseSnapshotArgs([ { source: 'default', includeMetadataChanges: true }, - () => { }, + () => {}, ]); expect(parsed.snapshotListenOptions).toEqual({ @@ -524,7 +524,7 @@ describe('Firestore', function () { it("throws for unsupported listener source value 'server'", function () { expect(() => - parseSnapshotArgs([{ source: 'server' as 'default' | 'cache' }, () => { }]), + parseSnapshotArgs([{ source: 'server' as 'default' | 'cache' }, () => {}]), ).toThrow("'options' SnapshotOptions.source must be one of 'default' or 'cache'."); }); }); @@ -979,9 +979,9 @@ describe('Firestore', function () { it('firestore.runTransaction()', function () { const firestore = getFirestore(); firestoreRefV9Deprecation( - () => runTransaction(firestore, async () => { }), + () => runTransaction(firestore, async () => {}), // @ts-expect-error Combines modular and namespace API - () => firestore.runTransaction(async () => { }), + () => firestore.runTransaction(async () => {}), 'runTransaction', ); }); @@ -1071,7 +1071,7 @@ describe('Firestore', function () { collectionRefV9Deprecation( // no equivalent method - () => { }, + () => {}, // @ts-expect-error Combines modular and namespace API () => query.isEqual(query), 'isEqual', @@ -1110,9 +1110,9 @@ describe('Firestore', function () { const query = collection(firestore, 'test'); collectionRefV9Deprecation( - () => onSnapshot(query, () => { }), + () => onSnapshot(query, () => {}), // @ts-expect-error Combines modular and namespace API - () => query.onSnapshot(() => { }), + () => query.onSnapshot(() => {}), 'onSnapshot', ); }); @@ -1243,7 +1243,7 @@ describe('Firestore', function () { docRefV9Deprecation( // no equivalent method - () => { }, + () => {}, // @ts-expect-error Combines modular and namespace API () => docRef.isEqual(docRef), 'isEqual', @@ -1256,9 +1256,9 @@ describe('Firestore', function () { const docRef = doc(firestore, 'some/foo'); docRefV9Deprecation( - () => onSnapshot(docRef, () => { }), + () => onSnapshot(docRef, () => {}), // @ts-expect-error Combines modular and namespace API - () => docRef.onSnapshot(() => { }), + () => docRef.onSnapshot(() => {}), 'onSnapshot', ); }); @@ -1309,7 +1309,7 @@ describe('Firestore', function () { docRefV9Deprecation( // no equivalent method - () => { }, + () => {}, () => snapshot.isEqual(snapshot), 'isEqual', ); @@ -1382,7 +1382,7 @@ describe('Firestore', function () { it('Filter static', function () { staticsV9Deprecation( // no corresponding method - () => { }, + () => {}, () => firestore.Filter, 'Filter', ); @@ -1506,7 +1506,7 @@ describe('Firestore', function () { const timestamp = new firestore.Timestamp(2, 3); timestampV9Deprecation( // no corresponding method - () => { }, + () => {}, () => timestamp.seconds, 'seconds', ); @@ -1516,7 +1516,7 @@ describe('Firestore', function () { const timestamp = new firestore.Timestamp(2000, 3000000); timestampV9Deprecation( // no corresponding method - () => { }, + () => {}, () => timestamp.nanoseconds, 'nanoseconds', ); diff --git a/packages/firestore/type-test.ts b/packages/firestore/type-test.ts index c446befca4..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(() => {}); @@ -155,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(() => {}); @@ -206,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 { @@ -455,8 +457,10 @@ const unsubMod3 = onSnapshot(modDoc, { error: (_e: Error) => {}, }); const unsubMod4 = onSnapshot(modQuery1, snap => snap.docs); -const unsubMod6 = onSnapshot(modQuery1, { source: 'cache', includeMetadataChanges: true }, snap => - snap.docs, +const unsubMod6 = onSnapshot( + modQuery1, + { source: 'cache', includeMetadataChanges: true }, + snap => snap.docs, ); unsubMod1(); unsubMod2(); From 7412ab122a8afc6467acd82c95d74998c130e190 Mon Sep 17 00:00:00 2001 From: Alex Stanbury Date: Fri, 13 Mar 2026 08:27:19 +0000 Subject: [PATCH 3/9] refactor(firestore): create ListenSource type to maintain parity with JS SDK --- packages/firestore/lib/types/firestore.ts | 44 +++++++++++++---------- packages/firestore/lib/types/internal.ts | 3 +- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/firestore/lib/types/firestore.ts b/packages/firestore/lib/types/firestore.ts index bdf19ef25c..c6dd345b2d 100644 --- a/packages/firestore/lib/types/firestore.ts +++ b/packages/firestore/lib/types/firestore.ts @@ -171,11 +171,11 @@ export declare class LoadBundleTask implements PromiseLike; - }; + readonly mergeFields?: Array; + }; export type WhereFilterOp = | '<' @@ -201,9 +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?: 'default' | 'cache'; + readonly source?: ListenSource; } /** @@ -263,10 +271,10 @@ export interface DocumentData { export type PartialWithFieldValue = | Partial | (T extends Primitive - ? T - : T extends {} - ? { [K in keyof T]?: PartialWithFieldValue | FieldValue } - : never); + ? T + : T extends {} + ? { [K in keyof T]?: PartialWithFieldValue | FieldValue } + : never); /** * Given a union type `U = T1 | T2 | ...`, returns an intersected type @@ -303,18 +311,18 @@ export type NestedUpdateFields = UnionToIntersection< export type UpdateData = T extends Primitive ? T : T extends {} - ? { - [K in keyof T]?: UpdateData | FieldValue; - } & NestedUpdateFields - : Partial; + ? { + [K in keyof T]?: UpdateData | FieldValue; + } & NestedUpdateFields + : Partial; export type WithFieldValue = | T | (T extends Primitive - ? T - : T extends {} - ? { [K in keyof T]: WithFieldValue | FieldValue } - : never); + ? T + : T extends {} + ? { [K in keyof T]: WithFieldValue | FieldValue } + : never); export interface FirestoreDataConverter< AppModelType, @@ -406,7 +414,7 @@ export declare class WriteBatch { commit(): Promise; } -export declare class LiteTransaction {} +export declare class LiteTransaction { } export declare class Transaction extends LiteTransaction { get( diff --git a/packages/firestore/lib/types/internal.ts b/packages/firestore/lib/types/internal.ts index 2f2957b738..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'; @@ -97,7 +98,7 @@ export interface FirestoreAggregateQueryResultInternal { /** Options for snapshot listeners (includeMetadataChanges, source). */ export interface FirestoreSnapshotListenOptionsInternal { includeMetadataChanges?: boolean; - source?: 'default' | 'cache'; + source?: ListenSource; } /** Settings state on the Firestore module instance (ignoreUndefinedProperties, persistence). */ From 3079d535d1e43746faea94adb7911af420d1accf Mon Sep 17 00:00:00 2001 From: Alex Stanbury Date: Fri, 13 Mar 2026 08:58:23 +0000 Subject: [PATCH 4/9] test(firestore): added out-of-band helper for writing to firestore within tests --- packages/firestore/e2e/helpers.js | 67 +++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) 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'; From ffaeb63e05222b5b0851497bf400cb9204bb51d4 Mon Sep 17 00:00:00 2001 From: Alex Stanbury Date: Fri, 13 Mar 2026 08:59:03 +0000 Subject: [PATCH 5/9] test(firestore): changed e2e tests to test local cache listener properly --- .../e2e/DocumentReference/onSnapshot.e2e.js | 49 ++++++++++++------- .../firestore/e2e/Query/onSnapshot.e2e.js | 33 +++++++------ 2 files changed, 48 insertions(+), 34 deletions(-) diff --git a/packages/firestore/e2e/DocumentReference/onSnapshot.e2e.js b/packages/firestore/e2e/DocumentReference/onSnapshot.e2e.js index 4afa6ab6df..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 () { @@ -352,47 +352,58 @@ describe('firestore().doc().onSnapshot()', function () { unsub(); }); - it('uses cache source for document listeners', async function () { + it('cache source listeners ignore out-of-band server writes', async function () { if (Platform.other) { return; } - const docRef = firebase.firestore().doc(`${COLLECTION}/${Utils.randString(12, '#aA')}`); - await docRef.set({ enabled: true }); + const docPath = `${COLLECTION}/${Utils.randString(12, '#aA')}`; + const docRef = firebase.firestore().doc(docPath); + await docRef.set({ value: 1 }); await docRef.get(); - let unsub = () => {}; + const callback = sinon.spy(); + const unsub = docRef.onSnapshot({ source: 'cache' }, callback); try { - await firebase.firestore().disableNetwork(); - const callback = sinon.spy(); - unsub = docRef.onSnapshot({ source: 'cache' }, callback); await Utils.spyToBeCalledOnceAsync(callback); - callback.args[0][0].metadata.fromCache.should.equal(true); + + 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(); - await firebase.firestore().enableNetwork(); } }); - it('supports cache source with metadata changes', async function () { + it('default source listeners receive out-of-band server writes', async function () { if (Platform.other) { return; } - const docRef = firebase.firestore().doc(`${COLLECTION}/${Utils.randString(12, '#aA')}`); - await docRef.set({ enabled: true }); + const docPath = `${COLLECTION}/${Utils.randString(12, '#aA')}`; + const docRef = firebase.firestore().doc(docPath); + await docRef.set({ value: 1 }); await docRef.get(); - let unsub = () => {}; + const callback = sinon.spy(); + const unsub = docRef.onSnapshot( + { source: 'default', includeMetadataChanges: true }, + callback, + ); try { - await firebase.firestore().disableNetwork(); - const callback = sinon.spy(); - unsub = docRef.onSnapshot({ source: 'cache', includeMetadataChanges: true }, callback); await Utils.spyToBeCalledOnceAsync(callback); - callback.args[0][0].metadata.fromCache.should.equal(true); + + 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(); - await firebase.firestore().enableNetwork(); } }); diff --git a/packages/firestore/e2e/Query/onSnapshot.e2e.js b/packages/firestore/e2e/Query/onSnapshot.e2e.js index 0ef30a7d16..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'; @@ -333,7 +333,7 @@ describe('firestore().collection().onSnapshot()', function () { } }); - it('uses cache source for query listeners', async function () { + it('cache source query listeners ignore out-of-band server writes', async function () { if (Platform.other) { return; } @@ -343,20 +343,22 @@ describe('firestore().collection().onSnapshot()', function () { await colRef.doc('one').set({ enabled: true }); await colRef.get(); - let unsub = () => {}; + const callback = sinon.spy(); + const unsub = colRef.onSnapshot({ source: 'cache' }, callback); try { - await firebase.firestore().disableNetwork(); - const callback = sinon.spy(); - unsub = colRef.onSnapshot({ source: 'cache' }, callback); await Utils.spyToBeCalledOnceAsync(callback); - callback.args[0][0].metadata.fromCache.should.equal(true); + 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(); - await firebase.firestore().enableNetwork(); } }); - it('supports cache source with metadata changes', async function () { + it('default source query listeners receive out-of-band server writes', async function () { if (Platform.other) { return; } @@ -366,16 +368,17 @@ describe('firestore().collection().onSnapshot()', function () { await colRef.doc('one').set({ enabled: true }); await colRef.get(); - let unsub = () => {}; + const callback = sinon.spy(); + const unsub = colRef.onSnapshot( + { source: 'default', includeMetadataChanges: true }, + callback, + ); try { - await firebase.firestore().disableNetwork(); - const callback = sinon.spy(); - unsub = colRef.onSnapshot({ source: 'cache', includeMetadataChanges: true }, callback); await Utils.spyToBeCalledOnceAsync(callback); - callback.args[0][0].metadata.fromCache.should.equal(true); + await setDocumentOutOfBand(`${collectionPath}/server-write`, { enabled: false }); + await Utils.spyToBeCalledTimesAsync(callback, 2, 8000); } finally { unsub(); - await firebase.firestore().enableNetwork(); } }); From afa4465cd26062eeef4e2befdf9125bf470c6e16 Mon Sep 17 00:00:00 2001 From: Alex Stanbury Date: Fri, 13 Mar 2026 09:02:18 +0000 Subject: [PATCH 6/9] chore: lint fixes --- .../RNFBFirestoreCollectionModule.m | 11 +++--- .../RNFBFirestoreDocumentModule.m | 11 +++--- packages/firestore/lib/types/firestore.ts | 34 +++++++++---------- 3 files changed, 27 insertions(+), 29 deletions(-) diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m index ee8c1de3da..ff8ca8e457 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m @@ -366,12 +366,11 @@ - (void)handleQueryOnSnapshot:(FIRApp *)firebaseApp } }; - FIRSnapshotListenOptions *snapshotListenOptions = - [[[[FIRSnapshotListenOptions alloc] init] - optionsWithIncludeMetadataChanges:includeMetadataChanges] optionsWithSource:source]; - id listener = [[firestoreQuery instance] - addSnapshotListenerWithOptions:snapshotListenOptions - 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.m b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreDocumentModule.m index 94e695d942..6e51e42d12 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreDocumentModule.m +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreDocumentModule.m @@ -105,12 +105,11 @@ - (void)invalidate { source = FIRListenSourceCache; } - FIRSnapshotListenOptions *snapshotListenOptions = - [[[[FIRSnapshotListenOptions alloc] init] - optionsWithIncludeMetadataChanges:includeMetadataChanges] optionsWithSource:source]; - id listener = [documentReference - addSnapshotListenerWithOptions:snapshotListenOptions - listener:listenerBlock]; + FIRSnapshotListenOptions *snapshotListenOptions = [[[[FIRSnapshotListenOptions alloc] init] + optionsWithIncludeMetadataChanges:includeMetadataChanges] optionsWithSource:source]; + id listener = + [documentReference addSnapshotListenerWithOptions:snapshotListenOptions + listener:listenerBlock]; documentSnapshotListeners[listenerId] = listener; } diff --git a/packages/firestore/lib/types/firestore.ts b/packages/firestore/lib/types/firestore.ts index c6dd345b2d..308774bd2d 100644 --- a/packages/firestore/lib/types/firestore.ts +++ b/packages/firestore/lib/types/firestore.ts @@ -171,11 +171,11 @@ export declare class LoadBundleTask implements PromiseLike; - }; + readonly mergeFields?: Array; + }; export type WhereFilterOp = | '<' @@ -271,10 +271,10 @@ export interface DocumentData { export type PartialWithFieldValue = | Partial | (T extends Primitive - ? T - : T extends {} - ? { [K in keyof T]?: PartialWithFieldValue | FieldValue } - : never); + ? T + : T extends {} + ? { [K in keyof T]?: PartialWithFieldValue | FieldValue } + : never); /** * Given a union type `U = T1 | T2 | ...`, returns an intersected type @@ -311,18 +311,18 @@ export type NestedUpdateFields = UnionToIntersection< export type UpdateData = T extends Primitive ? T : T extends {} - ? { - [K in keyof T]?: UpdateData | FieldValue; - } & NestedUpdateFields - : Partial; + ? { + [K in keyof T]?: UpdateData | FieldValue; + } & NestedUpdateFields + : Partial; export type WithFieldValue = | T | (T extends Primitive - ? T - : T extends {} - ? { [K in keyof T]: WithFieldValue | FieldValue } - : never); + ? T + : T extends {} + ? { [K in keyof T]: WithFieldValue | FieldValue } + : never); export interface FirestoreDataConverter< AppModelType, @@ -414,7 +414,7 @@ export declare class WriteBatch { commit(): Promise; } -export declare class LiteTransaction { } +export declare class LiteTransaction {} export declare class Transaction extends LiteTransaction { get( From 6295a5c20b1d089c650393f3dba1c796efd155cc Mon Sep 17 00:00:00 2001 From: Alex Stanbury Date: Fri, 13 Mar 2026 19:05:57 +0000 Subject: [PATCH 7/9] fix: add ListenerSource type instead of literal union --- packages/firestore/lib/FirestoreDocumentReference.ts | 4 ++-- packages/firestore/lib/FirestoreQuery.ts | 4 ++-- packages/firestore/lib/types/namespaced.ts | 3 ++- packages/firestore/lib/utils/index.ts | 5 +++-- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/firestore/lib/FirestoreDocumentReference.ts b/packages/firestore/lib/FirestoreDocumentReference.ts index 97680f7b51..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; source?: 'default' | 'cache' }; + 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 426be998b5..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; source?: 'default' | 'cache' }; + 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/namespaced.ts b/packages/firestore/lib/types/namespaced.ts index 6305667645..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. @@ -1768,7 +1769,7 @@ export namespace FirebaseFirestoreTypes { /** * Set the source the query listens to. Default is 'default'. */ - source?: 'default' | 'cache'; + source?: ListenSource; } /** diff --git a/packages/firestore/lib/utils/index.ts b/packages/firestore/lib/utils/index.ts index db92089769..9788e53db5 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 'lib/types/firestore'; export function extractFieldPathData(data: unknown, segments: string[]): unknown { if (!isObject(data)) { @@ -153,7 +154,7 @@ function isPartialObserver( export interface ParseSnapshotArgsResult { snapshotListenOptions: { includeMetadataChanges?: boolean; - source?: 'default' | 'cache'; + source?: ListenSource; }; callback: (snapshot: unknown, error: Error | null) => void; onNext: (snapshot: unknown) => void; @@ -168,7 +169,7 @@ export function parseSnapshotArgs(args: unknown[]): ParseSnapshotArgsResult { const NOOP = (): void => {}; const snapshotListenOptions: { includeMetadataChanges?: boolean; - source?: 'default' | 'cache'; + source?: ListenSource; } = {}; let callback: (snapshot: unknown, error: Error | null) => void = NOOP; let onError: (error: Error) => void = NOOP; From 8d60990b8db8b7564491e0181f0ad4ecf38dd363 Mon Sep 17 00:00:00 2001 From: Alex Stanbury Date: Mon, 23 Mar 2026 08:54:09 +0000 Subject: [PATCH 8/9] chore: fixes hardcoded strings to ListenSource type --- packages/firestore/lib/utils/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/firestore/lib/utils/index.ts b/packages/firestore/lib/utils/index.ts index 9788e53db5..b55cdd455e 100644 --- a/packages/firestore/lib/utils/index.ts +++ b/packages/firestore/lib/utils/index.ts @@ -196,7 +196,7 @@ export function parseSnapshotArgs(args: unknown[]): ParseSnapshotArgsResult { } if (isObject(args[0]) && !isPartialObserver(args[0])) { - const opts = args[0] as { includeMetadataChanges?: boolean; source?: 'default' | 'cache' }; + 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; From 458f91ec32a733faaac6c267787101f7738df317 Mon Sep 17 00:00:00 2001 From: Alex Stanbury Date: Mon, 23 Mar 2026 08:54:25 +0000 Subject: [PATCH 9/9] chore: tidy up import --- packages/firestore/lib/utils/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/firestore/lib/utils/index.ts b/packages/firestore/lib/utils/index.ts index b55cdd455e..7d2d2a7941 100644 --- a/packages/firestore/lib/utils/index.ts +++ b/packages/firestore/lib/utils/index.ts @@ -31,7 +31,7 @@ import type { PartialSnapshotObserverInternal, } from '../types/internal'; import FieldPath, { fromDotSeparatedString } from '../FieldPath'; -import type { ListenSource } from 'lib/types/firestore'; +import type { ListenSource } from '../types/firestore'; export function extractFieldPathData(data: unknown, segments: string[]): unknown { if (!isObject(data)) {