From e69843aa4ce2b00202116ee4cb154bf79bbb981e Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Mon, 22 Jun 2026 17:36:38 +0200 Subject: [PATCH 1/2] fix(firestore): large snapshots do not block frame scheduling --- .../FlutterFirebaseFirestorePlugin.java | 6 +- .../DocumentSnapshotsStreamHandler.java | 12 ++- .../QuerySnapshotsStreamHandler.java | 12 ++- .../example/integration_test/query_e2e.dart | 83 +++++++++++++++++++ .../FLTDocumentSnapshotStreamHandler.m | 11 ++- .../FLTQuerySnapshotStreamHandler.m | 11 ++- 6 files changed, 121 insertions(+), 14 deletions(-) diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/FlutterFirebaseFirestorePlugin.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/FlutterFirebaseFirestorePlugin.java index 14046b1179d6..763bb36facb6 100644 --- a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/FlutterFirebaseFirestorePlugin.java +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/FlutterFirebaseFirestorePlugin.java @@ -989,7 +989,8 @@ public void querySnapshot( includeMetadataChanges, PigeonParser.parsePigeonServerTimestampBehavior( options.getServerTimestampBehavior()), - PigeonParser.parseListenSource(source)))); + PigeonParser.parseListenSource(source), + cachedThreadPool))); } @Override @@ -1012,7 +1013,8 @@ public void documentReferenceSnapshot( includeMetadataChanges, PigeonParser.parsePigeonServerTimestampBehavior( parameters.getServerTimestampBehavior()), - PigeonParser.parseListenSource(source)))); + PigeonParser.parseListenSource(source), + cachedThreadPool))); } @Override diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/DocumentSnapshotsStreamHandler.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/DocumentSnapshotsStreamHandler.java index b2e1245d2e87..c7949d96fece 100644 --- a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/DocumentSnapshotsStreamHandler.java +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/DocumentSnapshotsStreamHandler.java @@ -20,6 +20,7 @@ import io.flutter.plugins.firebase.firestore.utils.ExceptionConverter; import io.flutter.plugins.firebase.firestore.utils.PigeonParser; import java.util.Map; +import java.util.concurrent.Executor; public class DocumentSnapshotsStreamHandler implements StreamHandler { @@ -30,19 +31,22 @@ public class DocumentSnapshotsStreamHandler implements StreamHandler { DocumentSnapshot.ServerTimestampBehavior serverTimestampBehavior; ListenSource source; + Executor snapshotExecutor; public DocumentSnapshotsStreamHandler( FirebaseFirestore firestore, DocumentReference documentReference, Boolean includeMetadataChanges, DocumentSnapshot.ServerTimestampBehavior serverTimestampBehavior, - ListenSource source) { + ListenSource source, + Executor snapshotExecutor) { this.firestore = firestore; this.documentReference = documentReference; this.metadataChanges = includeMetadataChanges ? MetadataChanges.INCLUDE : MetadataChanges.EXCLUDE; this.serverTimestampBehavior = serverTimestampBehavior; this.source = source; + this.snapshotExecutor = snapshotExecutor; } @Override @@ -50,6 +54,7 @@ public void onListen(Object arguments, EventSink events) { SnapshotListenOptions.Builder optionsBuilder = new SnapshotListenOptions.Builder(); optionsBuilder.setMetadataChanges(metadataChanges); optionsBuilder.setSource(source); + optionsBuilder.setExecutor(snapshotExecutor); listenerRegistration = documentReference.addSnapshotListener( @@ -66,9 +71,10 @@ public void onListen(Object arguments, EventSink events) { // MessageChannel serializes it end-to-end. Pigeon 26 no longer flattens // nested types via `.toList()`, so calling `.toList()` here would send a // raw list that the Dart side can no longer decode. - events.success( + Object pigeonSnapshot = PigeonParser.toPigeonDocumentSnapshot( - documentSnapshot, serverTimestampBehavior)); + documentSnapshot, serverTimestampBehavior); + events.success(pigeonSnapshot); } }); } diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/QuerySnapshotsStreamHandler.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/QuerySnapshotsStreamHandler.java index e0fe62b31ec7..c3f3043ffca0 100644 --- a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/QuerySnapshotsStreamHandler.java +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/QuerySnapshotsStreamHandler.java @@ -19,6 +19,7 @@ import io.flutter.plugins.firebase.firestore.utils.ExceptionConverter; import io.flutter.plugins.firebase.firestore.utils.PigeonParser; import java.util.Map; +import java.util.concurrent.Executor; public class QuerySnapshotsStreamHandler implements StreamHandler { @@ -29,17 +30,20 @@ public class QuerySnapshotsStreamHandler implements StreamHandler { DocumentSnapshot.ServerTimestampBehavior serverTimestampBehavior; ListenSource source; + Executor snapshotExecutor; public QuerySnapshotsStreamHandler( Query query, Boolean includeMetadataChanges, DocumentSnapshot.ServerTimestampBehavior serverTimestampBehavior, - ListenSource source) { + ListenSource source, + Executor snapshotExecutor) { this.query = query; this.metadataChanges = includeMetadataChanges ? MetadataChanges.INCLUDE : MetadataChanges.EXCLUDE; this.serverTimestampBehavior = serverTimestampBehavior; this.source = source; + this.snapshotExecutor = snapshotExecutor; } @Override @@ -47,6 +51,7 @@ public void onListen(Object arguments, EventSink events) { SnapshotListenOptions.Builder optionsBuilder = new SnapshotListenOptions.Builder(); optionsBuilder.setMetadataChanges(metadataChanges); optionsBuilder.setSource(source); + optionsBuilder.setExecutor(snapshotExecutor); listenerRegistration = query.addSnapshotListener( @@ -63,8 +68,9 @@ public void onListen(Object arguments, EventSink events) { // nested `InternalDocumentSnapshot` / `InternalDocumentChange` / // `InternalSnapshotMetadata` with their proper type codes. Pigeon 26 // no longer flattens nested types via `.toList()`. - events.success( - PigeonParser.toPigeonQuerySnapshot(querySnapshot, serverTimestampBehavior)); + Object pigeonSnapshot = + PigeonParser.toPigeonQuerySnapshot(querySnapshot, serverTimestampBehavior); + events.success(pigeonSnapshot); } }); } diff --git a/packages/cloud_firestore/cloud_firestore/example/integration_test/query_e2e.dart b/packages/cloud_firestore/cloud_firestore/example/integration_test/query_e2e.dart index 5c62d73b5db8..2edbeb0cef5a 100644 --- a/packages/cloud_firestore/cloud_firestore/example/integration_test/query_e2e.dart +++ b/packages/cloud_firestore/cloud_firestore/example/integration_test/query_e2e.dart @@ -8,6 +8,7 @@ import 'dart:math'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void runQueryTests() { @@ -374,6 +375,88 @@ void runQueryTests() { await subscription.cancel(); }); + testWidgets( + 'large snapshots do not block frame scheduling', + (WidgetTester tester) async { + CollectionReference> collection = + await initializeTest('large-snapshot-listener'); + const int documentCount = 1000; + final String payload = List.filled(1024, 'x').join(); + + for (int start = 0; start < documentCount; start += 400) { + final WriteBatch batch = firestore.batch(); + final int end = min(start + 400, documentCount); + for (int index = start; index < end; index++) { + batch.set(collection.doc('doc-$index'), { + 'index': index, + 'payload': payload, + }); + } + await batch.commit(); + } + + final Completer initialSnapshot = Completer(); + final Completer receivedUpdates = Completer(); + var initialSnapshotReceived = false; + var updateSnapshots = 0; + + final StreamSubscription>> + subscription = collection.snapshots().listen((snapshot) { + if (!initialSnapshotReceived && snapshot.size == documentCount) { + initialSnapshotReceived = true; + initialSnapshot.complete(); + return; + } + + if (initialSnapshotReceived && snapshot.docChanges.isNotEmpty) { + updateSnapshots++; + if (updateSnapshots >= 3 && !receivedUpdates.isCompleted) { + receivedUpdates.complete(); + } + } + }); + addTearDown(subscription.cancel); + + await initialSnapshot.future.timeout(const Duration(seconds: 30)); + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: CircularProgressIndicator()), + ), + ); + + var updatesDone = false; + final updateFuture = Future(() async { + for (int index = 0; index < 3; index++) { + await collection.doc('doc-0').update({ + 'counter': index, + 'payload': payload, + }); + } + await receivedUpdates.future.timeout(const Duration(seconds: 30)); + }).whenComplete(() { + updatesDone = true; + }); + + final pumpDurations = []; + while (!updatesDone) { + final Stopwatch stopwatch = Stopwatch()..start(); + await tester.pump(const Duration(milliseconds: 16)); + stopwatch.stop(); + pumpDurations.add(stopwatch.elapsed); + } + await updateFuture; + + expect(pumpDurations, isNotEmpty); + final Duration longestPump = pumpDurations.reduce( + (current, next) => current > next ? current : next, + ); + expect(longestPump, lessThan(const Duration(milliseconds: 250))); + }, + timeout: const Timeout.factor(10), + skip: kIsWeb || defaultTargetPlatform == TargetPlatform.windows, + ); + test( 'listeners throws a [FirebaseException] with Query', () async { diff --git a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTDocumentSnapshotStreamHandler.m b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTDocumentSnapshotStreamHandler.m index e8119c70d761..87744b74b083 100644 --- a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTDocumentSnapshotStreamHandler.m +++ b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTDocumentSnapshotStreamHandler.m @@ -16,6 +16,7 @@ @interface FLTDocumentSnapshotStreamHandler () @property(readwrite, strong) id listenerRegistration; +@property(nonatomic) dispatch_queue_t snapshotQueue; @end @implementation FLTDocumentSnapshotStreamHandler @@ -32,6 +33,8 @@ - (nonnull instancetype)initWithFirestore:(nonnull FIRFirestore *)firestore self.includeMetadataChanges = includeMetadataChanges; self.serverTimestampBehavior = serverTimestampBehavior; self.source = source; + self.snapshotQueue = dispatch_queue_create( + "io.flutter.plugins.firebase.firestore.document_snapshot", DISPATCH_QUEUE_SERIAL); } return self; } @@ -54,12 +57,14 @@ - (FlutterError *_Nullable)onListenWithArguments:(id _Nullable)arguments andOptionalNSError:error]); }); } else { - dispatch_async(dispatch_get_main_queue(), ^{ + dispatch_async(self.snapshotQueue, ^{ // Emit the Pigeon object directly; the Pigeon-aware codec on the // MessageChannel serializes it end-to-end. Pigeon 26 no longer flattens // nested types via `toList`. - events([FirestorePigeonParser toPigeonDocumentSnapshot:snapshot - serverTimestampBehavior:self.serverTimestampBehavior]); + InternalDocumentSnapshot *pigeonSnapshot = + [FirestorePigeonParser toPigeonDocumentSnapshot:snapshot + serverTimestampBehavior:self.serverTimestampBehavior]; + events(pigeonSnapshot); }); } }; diff --git a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTQuerySnapshotStreamHandler.m b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTQuerySnapshotStreamHandler.m index b90ee46db10a..d674770fb280 100644 --- a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTQuerySnapshotStreamHandler.m +++ b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTQuerySnapshotStreamHandler.m @@ -16,6 +16,7 @@ @interface FLTQuerySnapshotStreamHandler () @property(readwrite, strong) id listenerRegistration; +@property(nonatomic) dispatch_queue_t snapshotQueue; @end @implementation FLTQuerySnapshotStreamHandler @@ -32,6 +33,8 @@ - (instancetype)initWithFirestore:(FIRFirestore *)firestore _includeMetadataChanges = includeMetadataChanges; _serverTimestampBehavior = serverTimestampBehavior; _source = source; + _snapshotQueue = dispatch_queue_create("io.flutter.plugins.firebase.firestore.query_snapshot", + DISPATCH_QUEUE_SERIAL); } return self; } @@ -64,13 +67,15 @@ - (FlutterError *_Nullable)onListenWithArguments:(id _Nullable)arguments andOptionalNSError:error]); }); } else { - dispatch_async(dispatch_get_main_queue(), ^{ + dispatch_async(self.snapshotQueue, ^{ // Emit the Pigeon object directly; the Pigeon-aware codec serializes nested // `InternalDocumentSnapshot` / `InternalDocumentChange` / `InternalSnapshotMetadata` // with their proper type codes. Pigeon 26 no longer flattens nested types // via `toList`. - events([FirestorePigeonParser toPigeonQuerySnapshot:snapshot - serverTimestampBehavior:self.serverTimestampBehavior]); + InternalQuerySnapshot *pigeonSnapshot = + [FirestorePigeonParser toPigeonQuerySnapshot:snapshot + serverTimestampBehavior:self.serverTimestampBehavior]; + events(pigeonSnapshot); }); } }; From bef9d7f08f6ce4b9297a982294ca354d9a888adb Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Tue, 23 Jun 2026 10:58:17 +0200 Subject: [PATCH 2/2] fix --- .../firestore/FlutterFirebaseFirestorePlugin.java | 3 +-- .../DocumentSnapshotsStreamHandler.java | 12 +++--------- .../FLTDocumentSnapshotStreamHandler.m | 11 +++-------- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/FlutterFirebaseFirestorePlugin.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/FlutterFirebaseFirestorePlugin.java index 763bb36facb6..e377f2737121 100644 --- a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/FlutterFirebaseFirestorePlugin.java +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/FlutterFirebaseFirestorePlugin.java @@ -1013,8 +1013,7 @@ public void documentReferenceSnapshot( includeMetadataChanges, PigeonParser.parsePigeonServerTimestampBehavior( parameters.getServerTimestampBehavior()), - PigeonParser.parseListenSource(source), - cachedThreadPool))); + PigeonParser.parseListenSource(source)))); } @Override diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/DocumentSnapshotsStreamHandler.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/DocumentSnapshotsStreamHandler.java index c7949d96fece..b2e1245d2e87 100644 --- a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/DocumentSnapshotsStreamHandler.java +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/DocumentSnapshotsStreamHandler.java @@ -20,7 +20,6 @@ import io.flutter.plugins.firebase.firestore.utils.ExceptionConverter; import io.flutter.plugins.firebase.firestore.utils.PigeonParser; import java.util.Map; -import java.util.concurrent.Executor; public class DocumentSnapshotsStreamHandler implements StreamHandler { @@ -31,22 +30,19 @@ public class DocumentSnapshotsStreamHandler implements StreamHandler { DocumentSnapshot.ServerTimestampBehavior serverTimestampBehavior; ListenSource source; - Executor snapshotExecutor; public DocumentSnapshotsStreamHandler( FirebaseFirestore firestore, DocumentReference documentReference, Boolean includeMetadataChanges, DocumentSnapshot.ServerTimestampBehavior serverTimestampBehavior, - ListenSource source, - Executor snapshotExecutor) { + ListenSource source) { this.firestore = firestore; this.documentReference = documentReference; this.metadataChanges = includeMetadataChanges ? MetadataChanges.INCLUDE : MetadataChanges.EXCLUDE; this.serverTimestampBehavior = serverTimestampBehavior; this.source = source; - this.snapshotExecutor = snapshotExecutor; } @Override @@ -54,7 +50,6 @@ public void onListen(Object arguments, EventSink events) { SnapshotListenOptions.Builder optionsBuilder = new SnapshotListenOptions.Builder(); optionsBuilder.setMetadataChanges(metadataChanges); optionsBuilder.setSource(source); - optionsBuilder.setExecutor(snapshotExecutor); listenerRegistration = documentReference.addSnapshotListener( @@ -71,10 +66,9 @@ public void onListen(Object arguments, EventSink events) { // MessageChannel serializes it end-to-end. Pigeon 26 no longer flattens // nested types via `.toList()`, so calling `.toList()` here would send a // raw list that the Dart side can no longer decode. - Object pigeonSnapshot = + events.success( PigeonParser.toPigeonDocumentSnapshot( - documentSnapshot, serverTimestampBehavior); - events.success(pigeonSnapshot); + documentSnapshot, serverTimestampBehavior)); } }); } diff --git a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTDocumentSnapshotStreamHandler.m b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTDocumentSnapshotStreamHandler.m index 87744b74b083..e8119c70d761 100644 --- a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTDocumentSnapshotStreamHandler.m +++ b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTDocumentSnapshotStreamHandler.m @@ -16,7 +16,6 @@ @interface FLTDocumentSnapshotStreamHandler () @property(readwrite, strong) id listenerRegistration; -@property(nonatomic) dispatch_queue_t snapshotQueue; @end @implementation FLTDocumentSnapshotStreamHandler @@ -33,8 +32,6 @@ - (nonnull instancetype)initWithFirestore:(nonnull FIRFirestore *)firestore self.includeMetadataChanges = includeMetadataChanges; self.serverTimestampBehavior = serverTimestampBehavior; self.source = source; - self.snapshotQueue = dispatch_queue_create( - "io.flutter.plugins.firebase.firestore.document_snapshot", DISPATCH_QUEUE_SERIAL); } return self; } @@ -57,14 +54,12 @@ - (FlutterError *_Nullable)onListenWithArguments:(id _Nullable)arguments andOptionalNSError:error]); }); } else { - dispatch_async(self.snapshotQueue, ^{ + dispatch_async(dispatch_get_main_queue(), ^{ // Emit the Pigeon object directly; the Pigeon-aware codec on the // MessageChannel serializes it end-to-end. Pigeon 26 no longer flattens // nested types via `toList`. - InternalDocumentSnapshot *pigeonSnapshot = - [FirestorePigeonParser toPigeonDocumentSnapshot:snapshot - serverTimestampBehavior:self.serverTimestampBehavior]; - events(pigeonSnapshot); + events([FirestorePigeonParser toPigeonDocumentSnapshot:snapshot + serverTimestampBehavior:self.serverTimestampBehavior]); }); } };