Skip to content

Provider still triggers when not watched #3973

Closed
@jamilsaadeh97

Description

@jamilsaadeh97

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:

  1. MainScreen watches userProvider<User?> to eagerly initialize it. When complete, we show the HomeScreen.
  2. HomeScreen based on the state of userProvider we either show a FavoritesScreen or a LoggedOutScreen
  3. In the FavoritesScreen, we watch favoritesProvider and fetch a list of favorites. When logging out, we clear userProvider
  4. When cleared, HomeScreen returns LoggedOutScreen. favoritesProvider is no longer watched.
  5. 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

Metadata

Metadata

Assignees

Labels

questionFurther information is requested

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions