From c255ebea12614f7468799b2aa791953d3c0e1ba7 Mon Sep 17 00:00:00 2001 From: Milen Marinov Date: Tue, 13 Oct 2026 00:00:09 +0300 Subject: [PATCH 1/2] fix(ui_auth, ui_storage): don't call setState after dispose in ProfileScreen, DeleteAccountButton and UploadButton Several widgets await a Firebase call or a dialog and then call `setState` (often in a `finally` block) without checking `mounted`. If the widget is removed from the tree while the await is pending, e.g. the screen is popped or the parent rebuilds without it, the `setState` runs on a defunct State: "setState() called after dispose()" in debug builds and "Null check operator used on a null value" from `State.setState` in release builds. - DeleteAccountButton: `finally` after deleting the account. The existing `if (!mounted) return;` in the `try` block does not skip `finally`. Also the `setState` after the confirmation dialog and after re-authentication. - ProfileScreen, linked providers: `setState` after unlinking a provider, in its `catch` and in its `finally`. - ProfileScreen, MFA toggle: after loading enrolled factors, after unenrolling (plus re-authenticating with a defunct context), after enrolling, and after phone verification returns. - UploadButton: `finally` after the upload completes. After each await, bail out with `if (!mounted) return;` before touching the State. Wrap `setState` in `catch`/`finally` blocks in `if (mounted)`. Each guard has a regression test that disposes the widget while the await is pending. --- .../lib/src/screens/profile_screen.dart | 59 ++- .../src/widgets/delete_account_button.dart | 10 +- .../test/screens/profile_screen_test.dart | 375 ++++++++++++++++++ .../widgets/delete_account_button_test.dart | 121 ++++++ .../lib/src/widgets/upload_button.dart | 8 +- .../test/upload_button_test.dart | 103 +++++ 6 files changed, 650 insertions(+), 26 deletions(-) create mode 100644 packages/firebase_ui_auth/test/screens/profile_screen_test.dart create mode 100644 packages/firebase_ui_auth/test/widgets/delete_account_button_test.dart create mode 100644 packages/firebase_ui_storage/test/upload_button_test.dart diff --git a/packages/firebase_ui_auth/lib/src/screens/profile_screen.dart b/packages/firebase_ui_auth/lib/src/screens/profile_screen.dart index 86720783..973dcceb 100644 --- a/packages/firebase_ui_auth/lib/src/screens/profile_screen.dart +++ b/packages/firebase_ui_auth/lib/src/screens/profile_screen.dart @@ -224,18 +224,24 @@ class _LinkedProvidersRowState extends State<_LinkedProvidersRow> { await user.unlink(providerId); await user.reload(); + if (!mounted) return; + setState(() { widget.onProviderUnlinked(); isEditing = false; }); } on fba.FirebaseAuthException catch (e) { - setState(() { - error = e; - }); + if (mounted) { + setState(() { + error = e; + }); + } } finally { - setState(() { - unlinkingProvider = null; - }); + if (mounted) { + setState(() { + unlinkingProvider = null; + }); + } } } @@ -564,6 +570,8 @@ class _MFAToggleState extends State<_MFAToggle> { final mfa = widget.auth.currentUser!.multiFactor; final factors = await mfa.getEnrolledFactors(); + if (!mounted) return; + if (factors.isEmpty) { setState(() { isLoading = false; @@ -573,23 +581,29 @@ class _MFAToggleState extends State<_MFAToggle> { try { await mfa.unenroll(multiFactorInfo: factors.first); + if (!mounted) return; widget.onToggled?.call(); } on PlatformException catch (e) { if (e.code == 'FirebaseAuthRecentLoginRequiredException') { - if (await _reauthenticate()) { + if (!mounted) return; + if (await _reauthenticate() && mounted) { await _disable(); } } else { rethrow; } } on Exception catch (e) { - setState(() { - exception = e; - }); + if (mounted) { + setState(() { + exception = e; + }); + } } finally { - setState(() { - isLoading = false; - }); + if (mounted) { + setState(() { + isLoading = false; + }); + } } } @@ -618,15 +632,20 @@ class _MFAToggleState extends State<_MFAToggle> { try { await mfa.enroll(assertion); + if (!mounted) return; widget.onToggled?.call(); } on Exception catch (e) { - setState(() { - exception = e; - }); + if (mounted) { + setState(() { + exception = e; + }); + } } finally { - setState(() { - isLoading = false; - }); + if (mounted) { + setState(() { + isLoading = false; + }); + } if (context.mounted) { Navigator.of(context).popUntil((route) => route == currentRoute); @@ -636,6 +655,8 @@ class _MFAToggleState extends State<_MFAToggle> { ], ); + if (!mounted) return; + setState(() { isLoading = false; }); diff --git a/packages/firebase_ui_auth/lib/src/widgets/delete_account_button.dart b/packages/firebase_ui_auth/lib/src/widgets/delete_account_button.dart index 9634404b..9040f3e5 100644 --- a/packages/firebase_ui_auth/lib/src/widgets/delete_account_button.dart +++ b/packages/firebase_ui_auth/lib/src/widgets/delete_account_button.dart @@ -116,7 +116,7 @@ class _DeleteAccountButtonState extends State { } } - if (!(confirmed ?? false)) return; + if (!(confirmed ?? false) || !mounted) return; setState(() { _isLoading = true; @@ -144,9 +144,11 @@ class _DeleteAccountButtonState extends State { } on Exception catch (e) { widget.onDeleteFailed?.call(e); } finally { - setState(() { - _isLoading = false; - }); + if (mounted) { + setState(() { + _isLoading = false; + }); + } } } diff --git a/packages/firebase_ui_auth/test/screens/profile_screen_test.dart b/packages/firebase_ui_auth/test/screens/profile_screen_test.dart new file mode 100644 index 00000000..ab9a68f2 --- /dev/null +++ b/packages/firebase_ui_auth/test/screens/profile_screen_test.dart @@ -0,0 +1,375 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:firebase_auth/firebase_auth.dart' as fba; +import 'package:firebase_ui_auth/firebase_ui_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils.dart'; + +class _FakeUserInfo extends Fake implements fba.UserInfo { + _FakeUserInfo(this.providerId); + + @override + final String providerId; +} + +class _FakeMultiFactorInfo extends Fake implements fba.MultiFactorInfo {} + +class _FakeMultiFactor extends Fake implements fba.MultiFactor { + Future> Function() enrolledFactors = () async => [ + _FakeMultiFactorInfo(), + ]; + final unenrollCompleter = Completer(); + int unenrollCalls = 0; + final enrollCompleter = Completer(); + + @override + Future> getEnrolledFactors() => enrolledFactors(); + + @override + Future unenroll({ + String? factorUid, + fba.MultiFactorInfo? multiFactorInfo, + }) { + unenrollCalls++; + return unenrollCompleter.future; + } + + @override + Future getSession() async => + _FakeMultiFactorSession(); + + @override + Future enroll( + fba.MultiFactorAssertion assertion, { + String? displayName, + }) => enrollCompleter.future; +} + +class _FakeMultiFactorSession extends Fake implements fba.MultiFactorSession {} + +class _FakeUser extends Fake implements fba.User { + final unlinkCompleter = Completer(); + + @override + final _FakeMultiFactor multiFactor = _FakeMultiFactor(); + + @override + bool get emailVerified => true; + + @override + String? get displayName => 'Test User'; + + @override + String? get photoURL => null; + + @override + List get providerData => [ + _FakeUserInfo('password'), + _FakeUserInfo('phone'), + ]; + + @override + Future unlink(String providerId) => unlinkCompleter.future; + + @override + Future reload() async {} +} + +class _FakeAuth extends Fake implements fba.FirebaseAuth { + _FakeAuth(this.currentUser); + + @override + final fba.User? currentUser; + + @override + final app = MockApp(); +} + +void main() { + group('$ProfileScreen', () { + setUpAll(() { + setFirebaseUiIsTestMode(true); + }); + + Future pumpProfileScreen( + WidgetTester tester, + fba.FirebaseAuth auth, { + bool showMFATile = false, + List? providers, + }) async { + await tester.pumpWidget( + MaterialApp( + home: ProfileScreen( + auth: auth, + providers: providers ?? [EmailAuthProvider(), PhoneAuthProvider()], + showMFATile: showMFATile, + ), + ), + ); + await tester.pumpAndSettle(); + } + + Future removeProfileScreen(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox()); + expect(find.byType(ProfileScreen), findsNothing); + } + + group('unlinking a provider', () { + Future startUnlinking(WidgetTester tester) async { + final linkedProviders = find + .ancestor( + of: find.text('Sign-in methods'), + matching: find.byType(Column), + ) + .first; + await tester.tap( + find.descendant( + of: linkedProviders, + matching: find.byIcon(Icons.edit), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.remove_circle).first); + await tester.pump(); + } + + testWidgets("doesn't call setState if disposed before it succeeds", ( + tester, + ) async { + final user = _FakeUser(); + await pumpProfileScreen(tester, _FakeAuth(user)); + await startUnlinking(tester); + + await removeProfileScreen(tester); + + user.unlinkCompleter.complete(user); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); + + testWidgets("doesn't call setState if disposed before it fails", ( + tester, + ) async { + final user = _FakeUser(); + await pumpProfileScreen(tester, _FakeAuth(user)); + await startUnlinking(tester); + + await removeProfileScreen(tester); + + user.unlinkCompleter.completeError( + fba.FirebaseAuthException(code: 'no-such-provider'), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); + }); + + group('disabling MFA', () { + testWidgets( + "doesn't call setState or unenroll if disposed while loading the " + 'enrolled factors', + (tester) async { + final user = _FakeUser(); + await pumpProfileScreen(tester, _FakeAuth(user), showMFATile: true); + + final factors = Completer>(); + user.multiFactor.enrolledFactors = () => factors.future; + await tester.tap(find.text('Disable')); + await tester.pump(); + + await removeProfileScreen(tester); + + factors.complete([_FakeMultiFactorInfo()]); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(user.multiFactor.unenrollCalls, 0); + }, + ); + + testWidgets( + "doesn't call setState or onToggled if disposed before unenrolling " + 'succeeds', + (tester) async { + final user = _FakeUser(); + await pumpProfileScreen(tester, _FakeAuth(user), showMFATile: true); + await tester.tap(find.text('Disable')); + await tester.pump(); + + await removeProfileScreen(tester); + + user.multiFactor.unenrollCompleter.complete(); + await tester.pump(); + + expect(tester.takeException(), isNull); + }, + ); + + testWidgets("doesn't call setState or re-authenticate if disposed before " + 'unenrolling fails with a recent login error', (tester) async { + final user = _FakeUser(); + await pumpProfileScreen(tester, _FakeAuth(user), showMFATile: true); + await tester.tap(find.text('Disable')); + await tester.pump(); + + await removeProfileScreen(tester); + + user.multiFactor.unenrollCompleter.completeError( + PlatformException(code: 'FirebaseAuthRecentLoginRequiredException'), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); + + testWidgets( + "doesn't call setState if disposed before unenrolling fails", + (tester) async { + final user = _FakeUser(); + await pumpProfileScreen(tester, _FakeAuth(user), showMFATile: true); + await tester.tap(find.text('Disable')); + await tester.pump(); + + await removeProfileScreen(tester); + + user.multiFactor.unenrollCompleter.completeError( + fba.FirebaseAuthException(code: 'network-request-failed'), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + "doesn't call setState or retry if disposed while re-authenticating", + (tester) async { + final user = _FakeUser(); + final auth = _FakeAuth(user); + // The email form in the re-authentication dialog needs a real + // Firebase app, so only offer phone re-authentication. + final providers = [PhoneAuthProvider()]; + await pumpProfileScreen( + tester, + auth, + showMFATile: true, + providers: providers, + ); + await tester.tap(find.text('Disable')); + await tester.pump(); + + user.multiFactor.unenrollCompleter.completeError( + PlatformException(code: 'FirebaseAuthRecentLoginRequiredException'), + ); + // The toggle's loading indicator never settles, so pump through + // the dialog's entrance transition instead. + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.byType(ReauthenticateDialog), findsOneWidget); + + // Rebuilding without the MFA tile disposes it under the dialog. + await pumpProfileScreen(tester, auth, providers: providers); + + Navigator.of( + tester.element(find.byType(ReauthenticateDialog)), + ).pop(true); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(user.multiFactor.unenrollCalls, 1); + }, + ); + }); + + group('enabling MFA', () { + Future startEnabling( + WidgetTester tester, + fba.FirebaseAuth auth, + ) async { + await pumpProfileScreen(tester, auth, showMFATile: true); + await tester.tap(find.text('Enable')); + await tester.pumpAndSettle(); + expect(find.byType(PhoneInputScreen), findsOneWidget); + + // Rebuilding without the MFA tile disposes it under the phone + // verification screen. + await pumpProfileScreen(tester, auth); + } + + Future receiveCredential(WidgetTester tester) async { + final context = tester.element(find.byType(PhoneInputScreen)); + FirebaseUIAction.ofType>( + context, + )!.invoke( + context, + CredentialReceived( + fba.PhoneAuthProvider.credential( + verificationId: 'verification-id', + smsCode: '123456', + ), + ), + ); + await tester.pump(); + } + + testWidgets( + "doesn't call setState if disposed before phone verification is " + 'closed', + (tester) async { + final user = _FakeUser(); + user.multiFactor.enrolledFactors = () async => []; + await startEnabling(tester, _FakeAuth(user)); + + Navigator.of(tester.element(find.byType(PhoneInputScreen))).pop(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + "doesn't call setState or onToggled if disposed before enrolling " + 'completes', + (tester) async { + final user = _FakeUser(); + user.multiFactor.enrolledFactors = () async => []; + await startEnabling(tester, _FakeAuth(user)); + + await receiveCredential(tester); + + user.multiFactor.enrollCompleter.complete(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(PhoneInputScreen), findsNothing); + }, + ); + + testWidgets("doesn't call setState if disposed before enrolling fails", ( + tester, + ) async { + final user = _FakeUser(); + user.multiFactor.enrolledFactors = () async => []; + await startEnabling(tester, _FakeAuth(user)); + await receiveCredential(tester); + + user.multiFactor.enrollCompleter.completeError( + fba.FirebaseAuthException(code: 'invalid-verification-code'), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(PhoneInputScreen), findsNothing); + }); + }); + }); +} diff --git a/packages/firebase_ui_auth/test/widgets/delete_account_button_test.dart b/packages/firebase_ui_auth/test/widgets/delete_account_button_test.dart new file mode 100644 index 00000000..b628f05c --- /dev/null +++ b/packages/firebase_ui_auth/test/widgets/delete_account_button_test.dart @@ -0,0 +1,121 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:firebase_auth/firebase_auth.dart' as fba; +import 'package:firebase_ui_auth/firebase_ui_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils.dart'; + +class _FakeUser extends Fake implements fba.User { + var deleteCompleter = Completer(); + int deleteCalls = 0; + + @override + Future delete() { + deleteCalls++; + return deleteCompleter.future; + } +} + +class _FakeAuth extends Fake implements fba.FirebaseAuth { + _FakeAuth(this.currentUser); + + @override + final fba.User? currentUser; +} + +void main() { + group('$DeleteAccountButton', () { + Future removeButton(WidgetTester tester) async { + await tester.pumpWidget(const TestMaterialApp(child: SizedBox())); + expect(find.byType(DeleteAccountButton), findsNothing); + } + + testWidgets( + "doesn't call setState if disposed while the account is being deleted", + (tester) async { + final user = _FakeUser(); + final auth = _FakeAuth(user); + + await tester.pumpWidget( + TestMaterialApp(child: DeleteAccountButton(auth: auth)), + ); + await tester.tap(find.byType(DeleteAccountButton)); + await tester.pump(); + + await removeButton(tester); + + user.deleteCompleter.complete(); + await tester.pump(); + + expect(tester.takeException(), isNull); + }, + ); + + testWidgets("doesn't call setState or delete the account if disposed while " + 'waiting for confirmation', (tester) async { + final user = _FakeUser(); + final auth = _FakeAuth(user); + final confirmation = Completer(); + + await tester.pumpWidget( + TestMaterialApp( + child: DeleteAccountButton( + auth: auth, + showDeleteConfirmationDialog: true, + deleteConfirmation: (_) => confirmation.future, + ), + ), + ); + await tester.tap(find.byType(DeleteAccountButton)); + await tester.pump(); + + await removeButton(tester); + + confirmation.complete(true); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(user.deleteCalls, 0); + }); + + testWidgets( + "doesn't call setState or retry if disposed while re-authenticating", + (tester) async { + final user = _FakeUser(); + final auth = _FakeAuth(user); + final signedIn = Completer(); + + await tester.pumpWidget( + TestMaterialApp( + child: DeleteAccountButton( + auth: auth, + onSignInRequired: () => signedIn.future, + ), + ), + ); + await tester.tap(find.byType(DeleteAccountButton)); + await tester.pump(); + + user.deleteCompleter.completeError( + fba.FirebaseAuthException(code: 'requires-recent-login'), + ); + await tester.pump(); + + await removeButton(tester); + + user.deleteCompleter = Completer(); + signedIn.complete(true); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(user.deleteCalls, 1); + }, + ); + }); +} diff --git a/packages/firebase_ui_storage/lib/src/widgets/upload_button.dart b/packages/firebase_ui_storage/lib/src/widgets/upload_button.dart index eabd3b87..b9fa4705 100644 --- a/packages/firebase_ui_storage/lib/src/widgets/upload_button.dart +++ b/packages/firebase_ui_storage/lib/src/widgets/upload_button.dart @@ -96,9 +96,11 @@ class _UploadButtonState extends State { } catch (e, stackTrace) { widget.onError(e, stackTrace); } finally { - setState(() { - isLoading = false; - }); + if (mounted) { + setState(() { + isLoading = false; + }); + } } } diff --git a/packages/firebase_ui_storage/test/upload_button_test.dart b/packages/firebase_ui_storage/test/upload_button_test.dart new file mode 100644 index 00000000..5b59f8a7 --- /dev/null +++ b/packages/firebase_ui_storage/test/upload_button_test.dart @@ -0,0 +1,103 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:firebase_ui_storage/firebase_ui_storage.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeTaskSnapshot extends Fake implements TaskSnapshot {} + +class _FakeUploadTask extends Fake implements UploadTask { + final _completer = Completer(); + + void complete() => _completer.complete(_FakeTaskSnapshot()); + + @override + Future then( + FutureOr Function(TaskSnapshot value) onValue, { + Function? onError, + }) => _completer.future.then(onValue, onError: onError); +} + +class _FakeReference extends Fake implements Reference { + _FakeReference(this.task); + + final UploadTask task; + + @override + Reference child(String path) => this; + + @override + UploadTask putFile(File file, [SettableMetadata? metadata]) => task; +} + +class _FakeStorage extends Fake implements FirebaseStorage {} + +void main() { + group('$UploadButton', () { + const fileSelectorChannel = MethodChannel( + 'plugins.flutter.io/file_selector', + ); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(fileSelectorChannel, (call) async { + if (call.method == 'openFile') return ['/path/to/file.txt']; + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(fileSelectorChannel, null); + }); + + testWidgets( + "doesn't call setState if disposed while the upload is in progress", + (tester) async { + final storage = _FakeStorage(); + final task = _FakeUploadTask(); + Reference? uploaded; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FirebaseUIStorageConfigOverride( + config: FirebaseUIStorageConfiguration( + storage: storage, + uploadRoot: _FakeReference(task), + ), + child: UploadButton( + storage: storage, + onError: (error, stackTrace) {}, + onUploadComplete: (ref) => uploaded = ref, + ), + ), + ), + ), + ); + await tester.tap(find.byType(UploadButton)); + await tester.pump(); + + await tester.pumpWidget(const SizedBox()); + expect(find.byType(UploadButton), findsNothing); + + task.complete(); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(uploaded, isNotNull); + }, + // On the web the picked file is read with XFile.readAsBytes, which + // can't be faked through the file_selector method channel. + skip: kIsWeb, + ); + }); +} From dfaa2bc22d499045da429a5f83b88abddfadafb8 Mon Sep 17 00:00:00 2001 From: Milen Marinov Date: Tue, 13 Oct 2026 13:35:37 +0300 Subject: [PATCH 2/2] fix(ui_auth): don't re-authenticate or pop a removed route after dispose DeleteAccountButton called onSignInRequired after delete() failed with requires-recent-login even if it had been disposed in the meantime. ProfileScreen's onSignInRequired then looked up localizations with its own defunct context and threw. The MFA enrolment finally block popped until the profile screen's route. If that route had been removed under the phone verification screen, popUntil never found it and popped every route. --- .../lib/src/screens/profile_screen.dart | 4 +- .../src/widgets/delete_account_button.dart | 1 + .../test/screens/profile_screen_test.dart | 62 +++++++++++++++++++ .../widgets/delete_account_button_test.dart | 35 +++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) diff --git a/packages/firebase_ui_auth/lib/src/screens/profile_screen.dart b/packages/firebase_ui_auth/lib/src/screens/profile_screen.dart index 973dcceb..049ae417 100644 --- a/packages/firebase_ui_auth/lib/src/screens/profile_screen.dart +++ b/packages/firebase_ui_auth/lib/src/screens/profile_screen.dart @@ -647,7 +647,9 @@ class _MFAToggleState extends State<_MFAToggle> { }); } - if (context.mounted) { + // If currentRoute was removed, popUntil would never find it and + // would pop every route. + if (context.mounted && (currentRoute?.isActive ?? false)) { Navigator.of(context).popUntil((route) => route == currentRoute); } } diff --git a/packages/firebase_ui_auth/lib/src/widgets/delete_account_button.dart b/packages/firebase_ui_auth/lib/src/widgets/delete_account_button.dart index 9040f3e5..3121d488 100644 --- a/packages/firebase_ui_auth/lib/src/widgets/delete_account_button.dart +++ b/packages/firebase_ui_auth/lib/src/widgets/delete_account_button.dart @@ -134,6 +134,7 @@ class _DeleteAccountButtonState extends State { await FirebaseUIAuth.signOut(context: context, auth: auth); } on fba.FirebaseAuthException catch (err) { if (err.code == 'requires-recent-login') { + if (!mounted) return; if (widget.onSignInRequired != null) { final signedIn = await widget.onSignInRequired!(); if (signedIn) { diff --git a/packages/firebase_ui_auth/test/screens/profile_screen_test.dart b/packages/firebase_ui_auth/test/screens/profile_screen_test.dart index ab9a68f2..496e2812 100644 --- a/packages/firebase_ui_auth/test/screens/profile_screen_test.dart +++ b/packages/firebase_ui_auth/test/screens/profile_screen_test.dart @@ -56,6 +56,7 @@ class _FakeMultiFactorSession extends Fake implements fba.MultiFactorSession {} class _FakeUser extends Fake implements fba.User { final unlinkCompleter = Completer(); + final deleteCompleter = Completer(); @override final _FakeMultiFactor multiFactor = _FakeMultiFactor(); @@ -80,6 +81,9 @@ class _FakeUser extends Fake implements fba.User { @override Future reload() async {} + + @override + Future delete() => deleteCompleter.future; } class _FakeAuth extends Fake implements fba.FirebaseAuth { @@ -173,6 +177,28 @@ void main() { }); }); + testWidgets( + "doesn't show the re-authentication dialog if disposed before deleting " + 'the account fails with a recent login error', + (tester) async { + final user = _FakeUser(); + await pumpProfileScreen(tester, _FakeAuth(user)); + await tester.ensureVisible(find.byType(DeleteAccountButton)); + await tester.tap(find.byType(DeleteAccountButton)); + await tester.pump(); + + await removeProfileScreen(tester); + + user.deleteCompleter.completeError( + fba.FirebaseAuthException(code: 'requires-recent-login'), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.byType(ReauthenticateDialog), findsNothing); + }, + ); + group('disabling MFA', () { testWidgets( "doesn't call setState or unenroll if disposed while loading the " @@ -354,6 +380,42 @@ void main() { }, ); + testWidgets( + "doesn't pop past the profile screen's route once it's been removed", + (tester) async { + final user = _FakeUser(); + user.multiFactor.enrolledFactors = () async => []; + final auth = _FakeAuth(user); + final navigatorKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp(navigatorKey: navigatorKey, home: const Text('Home')), + ); + final profileRoute = MaterialPageRoute( + builder: (_) => ProfileScreen( + auth: auth, + providers: [EmailAuthProvider(), PhoneAuthProvider()], + showMFATile: true, + ), + ); + navigatorKey.currentState!.push(profileRoute); + await tester.pumpAndSettle(); + await tester.tap(find.text('Enable')); + await tester.pumpAndSettle(); + expect(find.byType(PhoneInputScreen), findsOneWidget); + + navigatorKey.currentState!.removeRoute(profileRoute); + await tester.pump(); + + await receiveCredential(tester); + user.multiFactor.enrollCompleter.complete(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Home', skipOffstage: false), findsOneWidget); + }, + ); + testWidgets("doesn't call setState if disposed before enrolling fails", ( tester, ) async { diff --git a/packages/firebase_ui_auth/test/widgets/delete_account_button_test.dart b/packages/firebase_ui_auth/test/widgets/delete_account_button_test.dart index b628f05c..0aa033aa 100644 --- a/packages/firebase_ui_auth/test/widgets/delete_account_button_test.dart +++ b/packages/firebase_ui_auth/test/widgets/delete_account_button_test.dart @@ -84,6 +84,41 @@ void main() { expect(user.deleteCalls, 0); }); + testWidgets( + "doesn't ask to re-authenticate if disposed before deleting fails with " + 'a recent login error', + (tester) async { + final user = _FakeUser(); + final auth = _FakeAuth(user); + var signInRequiredCalls = 0; + + await tester.pumpWidget( + TestMaterialApp( + child: DeleteAccountButton( + auth: auth, + onSignInRequired: () async { + signInRequiredCalls++; + return true; + }, + ), + ), + ); + await tester.tap(find.byType(DeleteAccountButton)); + await tester.pump(); + + await removeButton(tester); + + user.deleteCompleter.completeError( + fba.FirebaseAuthException(code: 'requires-recent-login'), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(signInRequiredCalls, 0); + expect(user.deleteCalls, 1); + }, + ); + testWidgets( "doesn't call setState or retry if disposed while re-authenticating", (tester) async {