From 893e946c4e4ebc7008950bbc44199d54b1e89520 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Mon, 10 Aug 2026 03:47:48 +0000 Subject: [PATCH] fix(firestore, android): defer transaction cleanup until stream cancellation Keep the transaction event channel registered until Dart cancels its subscription so successful transactions do not report MissingPluginException. --- .../FlutterFirebaseFirestorePlugin.java | 25 +++++------ .../TransactionStreamHandler.java | 18 ++++---- .../integration_test/transaction_e2e.dart | 42 +++++++++++++++++++ 3 files changed, 63 insertions(+), 22 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 71fadce1fc40..5b21de64a1dd 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 @@ -277,26 +277,21 @@ private String registerEventChannel(String prefix, String identifier, StreamHand return identifier; } - private void removeEventListener(String identifier) { + private void removeTransaction(String transactionId) { + transactions.remove(transactionId); + transactionHandlers.remove(transactionId); + + // onCancel invokes this method, so remove the handler without cancelling it again. + synchronized (streamHandlers) { + streamHandlers.remove(transactionId); + } + synchronized (eventChannels) { - EventChannel eventChannel = eventChannels.remove(identifier); + EventChannel eventChannel = eventChannels.remove(transactionId); if (eventChannel != null) { eventChannel.setStreamHandler(null); } } - - synchronized (streamHandlers) { - StreamHandler streamHandler = streamHandlers.remove(identifier); - if (streamHandler != null) { - streamHandler.onCancel(null); - } - } - } - - private void removeTransaction(String transactionId) { - transactions.remove(transactionId); - removeEventListener(transactionId); - transactionHandlers.remove(transactionId); } private void removeEventListeners() { diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/TransactionStreamHandler.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/TransactionStreamHandler.java index 193c8d9b51ba..1344b2ddb4ca 100644 --- a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/TransactionStreamHandler.java +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/TransactionStreamHandler.java @@ -38,13 +38,13 @@ public interface OnTransactionStartedListener { void onStarted(Transaction transaction); } - /** Callback when the transaction has reached a terminal state. */ - public interface OnTransactionCompleteListener { - void onComplete(String transactionId); + /** Callback when Dart cancels the transaction event stream. */ + public interface OnTransactionCancelledListener { + void onCancelled(String transactionId); } final OnTransactionStartedListener onTransactionStartedListener; - final OnTransactionCompleteListener onTransactionCompleteListener; + final OnTransactionCancelledListener onTransactionCancelledListener; final FirebaseFirestore firestore; final String transactionId; final Long timeout; @@ -53,13 +53,13 @@ public interface OnTransactionCompleteListener { public TransactionStreamHandler( OnTransactionStartedListener onTransactionStartedListener, - OnTransactionCompleteListener onTransactionCompleteListener, + OnTransactionCancelledListener onTransactionCancelledListener, FirebaseFirestore firestore, String transactionId, Long timeout, Long maxAttempts) { this.onTransactionStartedListener = onTransactionStartedListener; - this.onTransactionCompleteListener = onTransactionCompleteListener; + this.onTransactionCancelledListener = onTransactionCancelledListener; this.firestore = firestore; this.transactionId = transactionId; this.timeout = timeout; @@ -186,7 +186,6 @@ public void onListen(Object arguments, EventSink events) { () -> { events.success(map); events.endOfStream(); - onTransactionCompleteListener.onComplete(transactionId); }); }); } @@ -194,6 +193,11 @@ public void onListen(Object arguments, EventSink events) { @Override public void onCancel(Object arguments) { semaphore.release(); + // FlutterFirebaseFirestorePlugin passes null when disposing all listeners and clears the + // listener maps itself. A non-null value identifies Dart's EventChannel cancellation. + if (arguments != null) { + onTransactionCancelledListener.onCancelled(transactionId); + } } @Override diff --git a/packages/cloud_firestore/cloud_firestore/example/integration_test/transaction_e2e.dart b/packages/cloud_firestore/cloud_firestore/example/integration_test/transaction_e2e.dart index c027dc6d68cc..d0fe0e78b66f 100644 --- a/packages/cloud_firestore/cloud_firestore/example/integration_test/transaction_e2e.dart +++ b/packages/cloud_firestore/cloud_firestore/example/integration_test/transaction_e2e.dart @@ -6,6 +6,7 @@ import 'dart:math'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void runTransactionTests() { @@ -69,6 +70,47 @@ void runTransactionTests() { expect(response, equals(randomValue)); }); + test( + 'does not report an error when the transaction stream is cancelled', + () async { + final List reportedErrors = []; + final FlutterExceptionHandler? previousOnError = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + reportedErrors.add(details.exception); + }; + addTearDown(() { + FlutterError.onError = previousOnError; + }); + + final DocumentReference> doc = + await initializeTest('transaction-cancel-cleanup'); + + await firestore.runTransaction((Transaction transaction) async { + transaction.set(doc, { + 'updatedAt': DateTime.now().toIso8601String(), + }); + }); + + await Future.delayed(const Duration(milliseconds: 100)); + + final Iterable transactionCancelErrors = + reportedErrors.where((Object error) { + final String text = error.toString(); + return error is MissingPluginException && + text.contains('firebase_firestore/transaction'); + }); + + expect( + transactionCancelErrors, + isEmpty, + reason: 'Unexpected FlutterError(s): $reportedErrors', + ); + }, + skip: kIsWeb || defaultTargetPlatform != TargetPlatform.android + ? 'Android-only EventChannel teardown race' + : false, + ); + test( 'runs after reading a document', () async {