firebase / firebase/firebase-ios-sdk
Snapshot Listener not triggering when hasPendingWrites = false
- Dominant language
- C++
- Stars
- 6.7k
- Forks
- 1.8k
- Avg merge
- 2d 18h
- Merged PRs (30d)
- 75
Description
### Description
When you create a snapshot listener with `SnapshotListenOptions().withSource(.cache).withIncludeMetadataChanges(true)` it doesn't get triggered when a document syncs and its `hasPendingWrites` becomes `false`.
This happens under specific circumstances outlined in the reproducing section. Namely when you are not listening or fetching data from the server, but instead observing local changes only. Fetching data from the server triggers the cache listener as expected.
Why I believe this to be unexpected behaviour:
- If after you add a document, you fetch the collection with `getDocuments(source: .cache)`, it will return the correct metadata showing `hasPendingWrites=false`.
- If after you add a document, you remove the listener with `cacheListenerRegistration?.remove()` and recreate it, it will log the correct metadata.
```
Listener: 49EY8kyiqMlSwwpij28l hasPendingWrites=false isFromCache=true ["name": matt]
```
- If instead of `withSource(.cache)` you set up the listener with `withSource(.default)`. It behaves as expected. The listener is called twice.
```
Listener: Uryyuhhti6wW5JbUH624 hasPendingWrites=true isFromCache=false ["name": matt]
Listener: Uryyuhhti6wW5JbUH624 hasPendingWrites=false isFromCache=false ["name": matt]
```
### Reproducing the issue
1. Initialize a basic Firebase project with Firestore configured. Nothing fancy here, just a bare bones setup.
2. Run the Emulator. (I am running with: `firebase emulators:start --project demo-123`)
3. Run the iOS app (sample code below)
```
import UIKit
import FirebaseFirestore
class ViewController: UIViewController {
private var cacheListenerRegistration: ListenerRegistration?
override func viewDidLoad() {
super.viewDidLoad()
subscribeToCache()
}
private func subscribeToCache() {
print("subscribeToCache")
let options = SnapshotListenOptions()
.withSource(.cache)
.withIncludeMetadataChanges(true)
cacheListenerRegistration = Firestore.firestore().collection("users").order(by: "name").addSnapshotListener(options: options) { querySnapshot, error in
guard let documents = querySnapshot?.documents else {
return
}
for document in documents {
print("Listener: \(document.documentID) hasPendingWrites=\(document.metadata.hasPendingWrites) isFromCache=\(document.metadata.isFromCache) \(document.data())")
}
}
}
@IBAction func onFetchFromCache() {
print("onFetchFromCache")
Firestore.firestore().collection("users").getDocuments(source: .cache) { (querySnapshot, error) in
if let error {
print("Error getting documents: \(error)")
} else {
for document in querySnapshot!.documents {
print("Cache: \(document.documentID) hasPendingWrites=\(document.metadata.hasPendingWrites) isFromCache=\(document.metadata.isFromCache) \(document.data())")
}
}
}
}
@IBAction func onAddUser() {
print("onAddUser")
Firestore.firestore().collection("users").addDocument(data: ["name":"matt"])
}
}
```
4. Invoke `onAddUser()`. This triggers the cache listener only once when `hasPendingWrites=true`. I would expect a second call when the document syncs and `hasPendingWrites=false`.
```
Listener: Iw1kEjKrOz1KeIUo8Fsh hasPendingWrites=true isFromCache=true ["name": matt]
```
5. Invoke `onFetchFromCache()`. This returns the correct metadata, so the change has occurred but the listener was never triggered.
```
Cache: Iw1kEjKrOz1KeIUo8Fsh hasPendingWrites=false isFromCache=true ["name": matt]
```
### Firebase SDK Version
12.6.0
### Xcode Version
16.4
### Installation Method
Swift Package Manager
### Firebase Product(s)
Firestore
### Targeted Platforms
iOS
### Relevant Log Output
```shell
12.6.0 - [FirebaseCore][I-COR000001] Configuring the default app.
subscribeToCache
12.6.0 - [FirebaseFirestore][I-FST000001] Initializing. Current user:
12.6.0 - [FirebaseFirestore][I-FST000001] Using full collection scan to execute query: Query(canonical_id=users|f:|ob:nameasc__name__asc)
12.6.0 - [FirebaseFirestore][I-FST000001] RemoteStore 313063653138663630 restarting streams as connectivity changed
12.6.0 - [FirebaseFirestore][I-FST000001] WatchStream (313063653138633238) stop
12.6.0 - [FirebaseFirestore][I-FST000001] WriteStream (313063653230346638) stop
12.6.0 - [FirebaseCore][I-COR000033] Data Collection flag is not set.
onAddUser
12.6.0 - [FirebaseFirestore][I-FST000001] WriteStream (313063653230346638) start
12.6.0 - [FirebaseFirestore][I-FST000001] Creating Firestore stub.
Listener: CmZzFmjFcteZoNTcOt1y hasPendingWrites=true isFromCache=true ["name": matt]
12.6.0 - [FirebaseFirestore][I-FST000001] WriteStream (313063653230346638) initial request: : {
database: "projects/demo-123/databases/(default)"
}
12.6.0 - [FirebaseFirestore][I-FST000001] WriteStream (313063653230346638) headers (allowlisted):
12.6.0 - [FirebaseFirestore][I-FST000001] WriteStream (313063653230346638) response: : {
stream_id: "85"
stream_token: "0"
}
12.6.0 - [FirebaseFirestore][I-FST000001] WriteStream (313063653230346638) write request: : {
writes {
update {
name: "projects/demo-123/databases/(default)/documents/users/CmZzFmjFcteZoNTcOt1y"
fields {
key: "name"
value {
string_value: "matt"
}
}
}
}
stream_token: "0"
}
12.6.0 - [FirebaseFirestore][I-FST000001] WriteStream (313063653230346638) headers (allowlisted):
12.6.0 - [FirebaseFirestore][I-FST000001] WriteStream (313063653230346638) response: : {
stream_token: "1"
write_results {
update_time {
seconds: 1765337219
nanos: 377020000
}
}
commit_time {
seconds: 1765337219
nanos: 377020000
}
}
onFetchFromCache
12.6.0 - [FirebaseFirestore][I-FST000001] Using full collection scan to execute query: Query(canonical_id=users|f:|ob:__name__asc)
Cache: CmZzFmjFcteZoNTcOt1y hasPendingWrites=false isFromCache=true ["name": matt]
```
### If using Swift Package Manager, the project's Package.resolved
Expand Package.resolved snippet
```json
{
"originHash" : "c63c63846d9c539229e96de38d6af51417e28c0ee9a0bc48bd0f0f19d923c329",
"pins" : [
{
"identity" : "abseil-cpp-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : {
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
"version" : "1.2024072200.0"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
"version" : "11.2.0"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "087bb95235f676c1a37e928769a5b6645dcbd325",
"version" : "12.6.0"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "35b601a60fbbea2de3ea461f604deaaa4d8bbd0c",
"version" : "3.2.0"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "c2d59acf17a8ba7ed80a763593c67c9c7c006ad1",
"version" : "12.5.0"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
"version" : "8.1.0"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.69.1"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "c756a29784521063b6a1202907e2cc47f41b667c",
"version" : "4.5.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
"version" : "2.30910.0"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "2.4.0"
}
},
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
"version" : "1.33.3"
}
}
],
"version" : 3
}
```
### If using CocoaPods, the project's Podfile.lock
_No response_
Contributor guide
Assessment
This issue has not been assessed yet.