Closed
Description
Describe the bug
In a common login/logout scenario where data is fetched if there is a user and a logged out screen is shown where there's not, I found a bug where the state of a provider is AsyncError for a moment even though its not watched anymore in the widget tree.
Basically the widget tree is as follows:
MainScreen
watchesuserProvider<User?>
to eagerly initialize it. When complete, we show theHomeScreen
.HomeScreen
based on the state ofuserProvider
we either show aFavoritesScreen
or aLoggedOutScreen
- In the
FavoritesScreen
, we watchfavoritesProvider
and fetch a list of favorites. When logging out, we clearuserProvider
- When cleared,
HomeScreen
returnsLoggedOutScreen
.favoritesProvider
is no longer watched. - This is where the error happen, favoritesProvider is "retriggered" and since there is no user, an exception is thrown
I've written a test to verify this behavior.
Moreover, if we add an observer to the container, or ref.onDispose
in the favoritesProvider
we can see that the provider is disposed, then updated, then disposed again.
To Reproduce
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
final authProvider = AsyncNotifierProvider<AuthNotifier, bool>(
AuthNotifier.new,
);
class AuthNotifier extends AsyncNotifier<bool> {
@override
FutureOr<bool> build() async {
await Future.delayed(const Duration(seconds: 1));
return true;
}
Future<void> logOut() async {
await Future.delayed(const Duration(seconds: 1));
ref.read(userProvider.notifier).logOut();
state = const AsyncData(false);
}
}
typedef User = ({String userID, String userName});
final userProvider = AsyncNotifierProvider<UserNotifier, User?>(
UserNotifier.new,
);
class UserNotifier extends AsyncNotifier<User?> {
@override
FutureOr<User?> build() async {
await Future.delayed(const Duration(seconds: 1));
return (userID: "1", userName: "John Doe");
}
void logOut() {
state = const AsyncData(null);
}
}
final favoritesProvider =
AsyncNotifierProvider.autoDispose<UserFavoritesNotifier, List<String>>(
UserFavoritesNotifier.new,
);
class UserFavoritesNotifier extends AutoDisposeAsyncNotifier<List<String>> {
@override
FutureOr<List<String>> build() async {
//requireValue is used since this provider is eagerly initialized
//This is where the error comes from, null check used on a null value.
//But it should not happen since favoritesProvider is no longer watched
final userID = ref.watch(userProvider).requireValue!.userID;
//Use userID to fetch user's favorites
await Future.delayed(const Duration(seconds: 1));
return ["favorite 1", "favorite 2", "favorite 3"];
}
}
class TestApp extends ConsumerWidget {
const TestApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncUser = ref.watch(userProvider);
return switch (asyncUser) {
AsyncData<User?>() => const MaterialApp(home: Home()),
AsyncError() => const MaterialApp(home: Text("ERROR")),
_ => const MaterialApp(home: CircularProgressIndicator()),
};
}
}
class Home extends ConsumerWidget {
const Home({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isGuestMode = ref.watch(userProvider).requireValue == null;
if (isGuestMode) return const LoggedOutView();
return const LoggedInView();
}
}
class LoggedInView extends ConsumerWidget {
const LoggedInView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncFavorites = ref.watch(favoritesProvider);
return switch (asyncFavorites) {
AsyncData(:final value) => Column(
children: [
...value.map((e) => Text(e)),
IconButton(
onPressed: () async {
await ref.read(authProvider.notifier).logOut();
},
icon: const Icon(Icons.logout),
),
],
),
AsyncError() => const Text("ERROR"),
_ => const CircularProgressIndicator(),
};
}
}
class LoggedOutView extends StatelessWidget {
const LoggedOutView({super.key});
@override
Widget build(BuildContext context) {
return const Center(child: Text("Logged Out"));
}
}
void main() {
testWidgets('Log out/Log in test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const ProviderScope(child: TestApp()));
//Wait for the fetching of user and user favorites
await tester.pumpAndSettle(const Duration(seconds: 2));
final element = tester.element(find.byType(TestApp));
ProviderScope.containerOf(element).listen(favoritesProvider, (prev, next) {
if (next is AsyncError) {
print("Error: ${next.error}, ${next.stackTrace}");
throw Exception("Should not happen");
}
});
// Verify that there are favorites
expect(find.text('favorite 1'), findsOneWidget);
// Tap the logout icon
await tester.tap(find.byIcon(Icons.logout));
//Wait for the log out to complete
await tester.pumpAndSettle(const Duration(seconds: 1));
// Verify that we're logged out.
expect(find.text('Logged Out'), findsOneWidget);
});
}
Expected behavior
I expect when I stop watching a provider in the widgets should dispose it immediately