Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ref.watch returns a value other than ref.read #3889

Open
darkstarx opened this issue Dec 23, 2024 · 29 comments
Open

ref.watch returns a value other than ref.read #3889

darkstarx opened this issue Dec 23, 2024 · 29 comments
Assignees
Labels
bug Something isn't working with-repro

Comments

@darkstarx
Copy link

darkstarx commented Dec 23, 2024

Full repro here.

Describe the bug
I'm not sure, but it looks like a bug, and I wonder to know when it can happen, just to understand how to approach this problem.

To Reproduce

    final readPricePlan = ref.read(myPricePlanProvider).valueOrNull;
    final myPricePlan = ref.watch(myPricePlanProvider).valueOrNull;
    if (readPricePlan != myPricePlan) {
      print('!!!=== Oooops!');
    }

Expected behavior
The values are the same.

@darkstarx darkstarx added bug Something isn't working needs triage labels Dec 23, 2024
@rrousselGit
Copy link
Owner

I'd need a complete example. As is, this doesn't make sense to me

@rrousselGit rrousselGit added question Further information is requested and removed needs triage labels Dec 23, 2024
@darkstarx
Copy link
Author

Hm.. If I start monitoring the value by setting the listener in the initState (ref.listenManual), ref.watch starts returning correct value in my ConsumerState. Looks like something wrong with dependencies in the WidgetRef.watch.

@rrousselGit
Copy link
Owner

My guess is more that your provider got disposed after read, and therefore watch created a new value

@darkstarx
Copy link
Author

My guess is more that your provider got disposed after read, and therefore watch created a new value

That's the point that both ref.read and ref.watch are called synchronously, and ref.red returns the correct value and ref.watch returns the old value (that was before the notifier action that had been invoked before ref.read and ref.watch). Tricky behavior.

I'd need a complete example. As is, this doesn't make sense to me

Yes, I understand, unfortunately I can't realize how to simplify the logic to fit the problem into a simple example. Was hoping for any hints.. Anyway, thanks for the quick response.
Ok, I'll try to write an example with reproducible problem.

@darkstarx
Copy link
Author

Ok, it seems watching is not working properly if the side effect is called before watching. I'm still not sure whether it is a bug or some unevident feature.

Flutter example
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'main.g.dart';


@riverpod
class My extends _$My
{
  @override
  Future<int> build() async
  {
    final value = await Future.delayed(
      const Duration(milliseconds: 100),
      () => _value,
    );
    return value;
  }

  Future<void> inc() async
  {
    final value = await future;
    await Future.delayed(
      const Duration(milliseconds: 280),
      () => _value = value + 1,
    );
    ref.invalidateSelf();
    await future;
  }

  static int _value = 0;
}


void main()
{
  runApp(const ProviderScope(
    observers: [ DebugProviderObserver() ],
    child: MyApp(),
  ));
}


class MyApp extends StatelessWidget
{
  const MyApp({ super.key });

  @override
  Widget build(final BuildContext context)
  {
    return MaterialApp(
      theme: ThemeData.light(useMaterial3: true),
      darkTheme: ThemeData.dark(useMaterial3: true),
      themeMode: ThemeMode.system,
      debugShowCheckedModeBanner: false,
      home: const Scaffold(
        body: MyWidget(),
      ),
    );
  }
}


class MyWidget extends ConsumerStatefulWidget
{
  const MyWidget({ super.key });

  @override
  ConsumerState<MyWidget> createState() => _MyWidgetState();
}


class _MyWidgetState extends ConsumerState<MyWidget>
{
  final deferredValue = Future.delayed(
    const Duration(milliseconds: 140),
    () => 'initialized',
  );

  @override
  void initState()
  {
    super.initState();
    // Calling the side effect before watching starts.
    _change();
  }

  @override
  Widget build(final BuildContext context)
  {
    return FutureBuilder(
      initialData: 'initializing...',
      future: deferredValue,
      builder: (context, snapshot) => Center(child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(snapshot.data!),
          if (snapshot.connectionState == ConnectionState.done) ...[
            _buildProvider(),
            ElevatedButton(
              onPressed: _changing ? null : _change,
              child: const Text('Change'),
            ),
          ],
        ],
      )),
    );
  }

  Widget _buildProvider()
  {
    final value = ref.watch(myProvider).valueOrNull;
    print('[ui] Showing $value');
    return Text('value: $value');
  }

  Future<void> _change() async
  {
    setState(() => _changing = true);
    try {
      print('[ui] Changing');
      await ref.read(myProvider.notifier).inc();
    } finally {
      setState(() => _changing = false);
    }
  }

  var _changing = false;
}


class DebugProviderObserver extends ProviderObserver
{
  const DebugProviderObserver();

  @override
  void didAddProvider(
    final ProviderBase<Object?> provider,
    final Object? value,
    final ProviderContainer container,
  )
  {
    logMessage('Provider ${provider.name} was initialized with $value');
  }

  @override
  void didDisposeProvider(
    final ProviderBase<Object?> provider,
    final ProviderContainer container,
  )
  {
    logMessage('Provider ${provider.name} was disposed');
  }

  @override
  void didUpdateProvider(
    final ProviderBase<Object?> provider,
    final Object? previousValue,
    final Object? newValue,
    final ProviderContainer container,
  )
  {
    logMessage(
      'Provider ${provider.name} updated from $previousValue to $newValue'
    );
  }

  @override
  void providerDidFail(
    final ProviderBase<Object?> provider,
    final Object error,
    final StackTrace stackTrace,
    final ProviderContainer container,
  )
  {
    logMessage('Provider ${provider.name} threw $error at $stackTrace');
  }

  void logMessage(final String message)
  {
    print('[riverpod] $message');
  }
}

Log

flutter: [ui] Changing
flutter: [riverpod] Provider myProvider was initialized with AsyncLoading<int>()
flutter: [riverpod] Provider myProvider was disposed
flutter: [riverpod] Provider myProvider was initialized with AsyncLoading<int>()
flutter: [ui] Showing null
flutter: [riverpod] Provider myProvider updated from AsyncLoading<int>() to AsyncData<int>(value: 0)
flutter: [ui] Showing 0
flutter: [riverpod] Provider myProvider updated from AsyncLoading<int>() to AsyncData<int>(value: 1)
flutter: [riverpod] Provider myProvider was disposed
flutter: [riverpod] Provider myProvider was initialized with AsyncLoading<int>()
flutter: [ui] Showing null
flutter: [riverpod] Provider myProvider was disposed

@rrousselGit
Copy link
Owner

What's wrong here exactly? Your provider wasn't listener so it got disposed.

@darkstarx
Copy link
Author

darkstarx commented Dec 24, 2024

As I understand it, ref.watch is supposed to rebuild the widget every time the watched provider changes. But the widget doesn't rebuild when the provider changes in this example. Also I suppose the ref.watch adds a dependency which prevents the disposing of the provider, but it disposes.

@darkstarx
Copy link
Author

darkstarx commented Dec 24, 2024

If I remove the side effect from initState, so ref.watch is called earlier than this side effect, the widget starts being rebuilt every time the provider changes.

  @override
  void initState()
  {
    super.initState();
    // _change();
  }

So, the problem is ref.watch doesn't work properly if ref.read(provider.notifier).makeSideEffect() is called earlier than starting watching the provider.

@rrousselGit
Copy link
Owner

You don't watch the provider until 140ms elapse. So it can get disposed before then.
And your Inc method calls ref.invalidateSelf, which also causes the provider to be disposed.

Your UI did update, as we see various logs with different values

@darkstarx
Copy link
Author

You don't watch the provider until 140ms elapse. So it can get disposed before then.

Absolutely right. But after this delay shouldn't the ref.watch start watching the provider making it alive all the time the widget exists in the widget tree?

@rrousselGit
Copy link
Owner

It does. Hence why there's no dispose between the second AsyncLoading() and aAsyncData(1)

The second dispose and reset to null is because you called invalidateSelf

@darkstarx
Copy link
Author

Right, but ref.watch didn't go anywhere after that, and the widget is still waiting for new data from the provider after its rebuild. However, the widget does not display the new value (1), and only displays the intermediate loading state after invalidateSelf. It seems strange for me.

@darkstarx
Copy link
Author

Listening the provider works correctly when started after calling the provider's side effect, so there is no problem in riverpod. As I can see, there is some bug in consumer.dart of flutter_riverpod with dependencies.

@darkstarx
Copy link
Author

darkstarx commented Jan 9, 2025

Hi @rrousselGit, what can you say?
This example shows there is some bug of watching providers. If you push the button Change, nothing changes in UI, but there should be shown a new myProvider's value.

When watching starts during the side effect (before updating the provider's state), something breaks, two provider states are created there. Eventually, the provider's watching element becomes disposed, and ref.watch stops updating the UI.

@rrousselGit
Copy link
Owner

I can run your example. I just need a bit of time to investigate the matter in depth.

For now I'm on other features

@RepliedSage11
Copy link

RepliedSage11 commented Feb 22, 2025

@darkstarx

I am not sure that I understood your issue correctly, but this is how I modified your notifier and it seems to me that it now does what you want it to do.

The build() method is unchanged (I have only renamed the _value to _initialValue). The inc() method still awaits the current value, but then sets the internal state property of the notifier to the incremented value. This means that we no longer need to manually invalidate the notifier. I have also removed the second await future since it is not needed.

@riverpod
class My extends _$My {
  @override
  Future<int> build() async {
    final value = await Future.delayed(
      const Duration(milliseconds: 100),
      () => _initialValue,
    );
    return value;
  }

  Future<void> inc() async {
    final value = await future;
    await Future.delayed(
      const Duration(milliseconds: 280),
      () => state = AsyncData(value + 1),
    );
  }

  static final int _initialValue = 0;
}

The value in the UI now starts as null, goes to 0 after the initial build of myProvider and then stays at 0. When tapping the change button the value is incremented and correctly shown in the UI.

Is this how you expected this to work?

@darkstarx
Copy link
Author

darkstarx commented Feb 23, 2025

Hi @RepliedSage11 and thanks for paying attention to the problem.

The _value here emulates a remote data, in the real project this provider fetches data from the server. The only thing you'v made by changing _value to _initialValue is that when the provider stops being watched, the internal state will destroy and when it starts being watched again, the value will reset to 0. That is wrong, the value should be retrieved from the remote storage (which is emulated here using the _value and await Future.delayed in the build method of the provider). The method inc is intended to be a side effect, i.e. sending a new value to the remote storage (on the server) and invalidating the internal state of the provider.

So, thus you've just broken the example that shows the existence of internal bug in the flutter_riverpod.

Anyway, thank you for trying to understand the problem.

@RepliedSage11
Copy link

Sorry I wasn't able to help.

I think what you are try to do is not supported by Riverpod and is mentioned in the docs. So I guess this is "expected behaviour" in a way.

AVOID initializing providers in a widget

Providers should initialize themselves.
They should not be initialized by an external element such as a widget.

Failing to do so could cause possible race conditions and unexpected behaviors.

DON'T

class WidgetState extends State<MyWidget> {
  @override
  void initState() {
    super.initState();
    // Bad: the provider should initialize itself
    ref.read(provider).init();
  }
}

FWIW I think there are better and less brittle ways to do what you are trying to do, like the example I mentioned in my original comment.

@darkstarx
Copy link
Author

The code you metioned is about initializing the provider's state. It says use build to initialize the state and don't use any other methods. I use build to initialize the state, and the inc method doesn't initialize anything, it just makes side effect and invalidates the provider. That's ok by riverpod docs.

Side effects are ment to happen at any time, that's a real life of real projects. The state of the app should work properly even when nothing in UI is watching the state at the time.

Just imagine one widget is watching the provider, then this widget go away from the tree for a while (it can happen at any time during the user interaction, this is unpredictable). Now no widget is watching the provider, its state is disposing. Concurrently side effects can happen at any time. Then the widget returns to the tree and starts watching the provider again, and this widget is ment to rebuild due to the watching, but it doesn't because another state of the provider arises - that's the problem, and this code example demonstrates the problem.

There is something wrong in the flutter_riverpod package, listening works incorrectly somewhere there. I tried to reproduce the problem using ProviderContainer.listen from riverpod package only (not using the flutter_riverpod), and everything works perfectly. That's why I think the problem is in the flutter_riverpod package.

@darkstarx
Copy link
Author

Here is the example demonstrating that riverpod works fine in the situation when inc fires before listening the provider.

Dart example
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:uuid/v4.dart';

part 'main_riverpod.g.dart';


@riverpod
class My extends _$My
{
  @override
  Future<int> build() async
  {
    final id = const UuidV4().generate();
    print('[provider] <$id> building');
    ref.onDispose(() {
      print('[provider] <$id> disposing');
    });
    final value = await Future.delayed(
      const Duration(milliseconds: 100),
      () => _value,
    );
    return value;
  }

  Future<void> inc() async
  {
    final value = await future;
    await Future.delayed(
      const Duration(milliseconds: 280),
      () => _value = value + 1,
    );
    ref.invalidateSelf();
    final newValue = await future;
    print('[provider] changed to $newValue');
  }

  static int _value = 0;
}


Future<void> main() async
{
  final container = ProviderContainer();
  print('[ui]          inc...');
  await container.read(myProvider.notifier).inc();
  print('[ui]          inc done');

  int? value;
  print('[ui]          listening...');
  container.listen(myProvider, (prev, next) {
    print('[ui]       $prev -> $next');
    value = next.valueOrNull;
  });
  Future.delayed(
    const Duration(seconds: 3),
    () => container.read(myProvider.notifier).inc(),
  );
  print('[ui]          listening started');

  while (value == null || value! < 2) {
    await Future.delayed(const Duration(seconds: 1));
    print('[ui]       value is $value');
  }

  print('[ui]          done');
}

Log

[ui]          inc...
[provider] <3756fbf7-423d-49d1-98cd-d77592fcd321> building
[provider] <3756fbf7-423d-49d1-98cd-d77592fcd321> disposing
[provider] <4b2f0c96-0926-47e1-86f8-a61d06b8fe00> building
[provider] changed to 1
[ui]          inc done
[ui]          listening...
[provider] <5be5af19-6e84-4414-8628-9b488208fe80> building
[ui]          listening started
[ui]       AsyncLoading<int>() -> AsyncData<int>(value: 1)
[ui]       value is 1
[ui]       value is 1
[ui]       value is 1
[provider] <5be5af19-6e84-4414-8628-9b488208fe80> disposing
[provider] <032414cd-0328-4968-bbba-b8659b889168> building
[ui]       AsyncData<int>(value: 1) -> AsyncData<int>(isLoading: true, value: 1)
[ui]       AsyncData<int>(isLoading: true, value: 1) -> AsyncData<int>(value: 2)
[provider] changed to 2
[ui]       value is 2
[ui]          done

Exited.

@lucavenir
Copy link
Collaborator

lucavenir commented Feb 25, 2025

Hello there @darkstarx!

I put your code down below, trying to reproduce your example.

Full repro, no tests (yet!)
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:talker_riverpod_logger/talker_riverpod_logger_observer.dart';
import 'package:talker_riverpod_logger/talker_riverpod_logger_settings.dart';

final myProvider = AsyncNotifierProvider<My, int>(My.new);

class My extends AsyncNotifier<int> {
  @override
  FutureOr<int> build() async {
    final value = await Future.delayed(
      const Duration(milliseconds: 100),
      () => _value,
    );
    return value;
  }

  Future<void> inc() async {
    final value = await future;
    await Future.delayed(
      const Duration(milliseconds: 280),
      () => _value = value + 1,
    );
    ref.invalidateSelf();
    await future;
  }

  static int _value = 0;
}

void main() {
  runApp(
    ProviderScope(
      observers: [
        TalkerRiverpodObserver(
          settings: TalkerRiverpodLoggerSettings(
            printProviderDisposed: true,
            printFailFullData: true,
            printProviderAdded: true,
            printProviderFailed: true,
            printProviderUpdated: true,
            printStateFullData: true,
          ),
        )
      ],
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(final BuildContext context) {
    return MaterialApp(
      theme: ThemeData.light(useMaterial3: true),
      darkTheme: ThemeData.dark(useMaterial3: true),
      themeMode: ThemeMode.system,
      debugShowCheckedModeBanner: false,
      home: const Scaffold(
        body: MyWidget(),
      ),
    );
  }
}

class MyWidget extends ConsumerStatefulWidget {
  const MyWidget({super.key});

  @override
  ConsumerState<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends ConsumerState<MyWidget> {
  final deferredValue = Future.delayed(
    const Duration(milliseconds: 140),
    () => 'initialized',
  );

  @override
  void initState() {
    super.initState();
    _change();
  }

  @override
  Widget build(final BuildContext context) {
    return FutureBuilder(
      initialData: 'initializing...',
      future: deferredValue,
      builder: (context, snapshot) => Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(snapshot.data!),
            if (snapshot.connectionState == ConnectionState.done) ...[
              _buildProvider(),
              ElevatedButton(
                onPressed: _changing ? null : _change,
                child: const Text('Change'),
              ),
            ],
          ],
        ),
      ),
    );
  }

  Widget _buildProvider() {
    final value = ref.watch(myProvider).valueOrNull;
    print('[ui] Showing $value');
    return Text('value: $value');
  }

  Future<void> _change() async {
    setState(() => _changing = true);
    try {
      print('[ui] Changing');
      await ref.read(myProvider.notifier).inc();
    } finally {
      setState(() => _changing = false);
    }
  }

  var _changing = false;
}

Please note TalkerRiverpodObserver and its settings, I highly reccomend it if you want to actually understand what's going on with your providers lifecycles.

Let's address your questions!

As I understand it, ref.watch is supposed to rebuild the widget every time the watched provider changes.

Yes.

But the widget doesn't rebuild when the provider changes in this example.

Nah, it does (see down below).

Also I suppose the ref.watch adds a dependency which prevents the disposing of the provider, but it disposes.

Nah, this is a common misconception.
A watcher keeps a provider alive, but all providers can and will dispose in their lifecycle, e.g. when their dependency change, or when they're invalidated - which is what you do in your side effect.

Check my output.

Output
[ui] Changing
┌───────────────────────────────────────────────────────
│ [riverpod-add] | 15:29:48 741ms | 
│ AsyncNotifierProviderImpl<My, int> initialized
│ INITIAL state: 
│ AsyncLoading<int>()
└───────────────────────────────────────────────────────
┌───────────────────────────────────────────────────────
│ [riverpod-update] | 15:29:48 844ms | 
│ AsyncNotifierProviderImpl<My, int> updated
│ PREVIOUS state: 
│ AsyncLoading<int>()
│ NEW state: 
│ AsyncData<int>(value: 0)
└───────────────────────────────────────────────────────
[ui] Showing 0
┌───────────────────────────────────────────────────────
│ [riverpod-dispose] | 15:29:49 127ms | 
│ AsyncNotifierProviderImpl<My, int> disposed
└───────────────────────────────────────────────────────
┌───────────────────────────────────────────────────────
│ [riverpod-update] | 15:29:49 129ms | 
│ AsyncNotifierProviderImpl<My, int> updated
│ PREVIOUS state: 
│ AsyncData<int>(value: 0)
│ NEW state: 
│ AsyncData<int>(isLoading: true, value: 0)
└───────────────────────────────────────────────────────
[ui] Showing 0
┌───────────────────────────────────────────────────────
│ [riverpod-update] | 15:29:49 233ms | 
│ AsyncNotifierProviderImpl<My, int> updated
│ PREVIOUS state: 
│ AsyncData<int>(isLoading: true, value: 0)
│ NEW state: 
│ AsyncData<int>(value: 1)
└───────────────────────────────────────────────────────
[ui] Showing 1

All I see is expected behavior.
As you can see the UI does update whenever your provider updates.
On startup, it goes from loading to 0, from 0 to 0 (disposed and loading, still waiting for the next one), and then from 0 to 1.

Triggering the side effect will print the following:

Output
[ui] Changing
[ui] Showing 1
┌───────────────────────────────────────────────
│ [riverpod-dispose] | 15:39:19 868ms | 
│ AsyncNotifierProviderImpl<My, int> disposed
└───────────────────────────────────────────────
┌───────────────────────────────────────────────
│ [riverpod-update] | 15:39:19 869ms | 
│ AsyncNotifierProviderImpl<My, int> updated
│ PREVIOUS state: 
│ AsyncData<int>(value: 1)
│ NEW state: 
│ AsyncData<int>(isLoading: true, value: 1)
└───────────────────────────────────────────────
[ui] Showing 1
┌───────────────────────────────────────────────
│ [riverpod-update] | 15:39:19 970ms | 
│ AsyncNotifierProviderImpl<My, int> updated
│ PREVIOUS state: 
│ AsyncData<int>(isLoading: true, value: 1)
│ NEW state: 
│ AsyncData<int>(value: 2)
└───────────────────────────────────────────────
[ui] Showing 2

Again, that's expected behavior. The changes triggers, you set ephimeral state to signal such action, thus another "showing 1" occurs. Then, the provider disposes and updates, and the logs follow accordingly.

Side effects are ment to happen at any time, that's a real life of real projects.

Of course. Our responsibility as devs, tho, is put these "streams of uncontrolled events" into something we can control. Riverpod does this with uni-directional data flow and reactive programming.

The state of the app should work properly even when nothing in UI is watching the state at the time.

I'd rather make the opposite statement: your UI should work properly and independently of incoming data.

@lucavenir
Copy link
Collaborator

lucavenir commented Feb 25, 2025

To summarize, the core issue is the following, right?

Now no widget is watching the provider, its state is disposing. Concurrently side effects can happen at any time. Then the widget returns to the tree and starts watching the provider again, and this widget is ment to rebuild due to the watching

You might have hit a edge case in Flutter's peculiar algorithm. In summary, if Flutter evaluates that a Widget is still clean - its element won't disappear in the tree.

This is clearly shown in my logs above. I've essentially hit a race condition in which a provider is being initialized via a ref.read. 140ms later, it's watched. But as you can see the provider gets disposed just once (instead of twice).

AFAIK this is not a flutter_riverpod issue. But in case it is, we've got a fully reproducible error above, and I can easily move that code into a test.

@darkstarx
Copy link
Author

Hello @lucavenir and thanks for joining the discussion.

In your example the provider is not auto-disposable, so it works fine. But the issue specifically concerns an auto-disposable one. Please, change the provider to this one and try again, and you'll see the problem.

final myProvider = AutoDisposeAsyncNotifierProvider<My, int>(My.new);

class My extends AutoDisposeAsyncNotifier<int> {
  @override
  FutureOr<int> build() async {
    final value = await Future.delayed(
      const Duration(milliseconds: 100),
      () => _value,
    );
    return value;
  }

  Future<void> inc() async {
    final value = await future;
    await Future.delayed(
      const Duration(milliseconds: 280),
      () => _value = value + 1,
    );
    ref.invalidateSelf();
    await future;
  }

  static int _value = 0;
}

@lucavenir
Copy link
Collaborator

lucavenir commented Feb 25, 2025

Woops. Sorry bout that. I'm way too accustomed to @riverpod that I forget non-generated versions aren't autodispose by default in v2.

Also don't mind me wrongly assuming it was about a race condition 😜 (it happened to me in the past, it's just personal bias I guess).

The actual example is the following.

Full example
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:talker_riverpod_logger/talker_riverpod_logger_observer.dart';
import 'package:talker_riverpod_logger/talker_riverpod_logger_settings.dart';

final myProvider = AutoDisposeAsyncNotifierProvider<My, int>(My.new);

class My extends AutoDisposeAsyncNotifier<int> {
  @override
  FutureOr<int> build() async {
    final value = await Future.delayed(
      const Duration(milliseconds: 100),
      () => _value,
    );
    return value;
  }

  Future<void> inc() async {
    final value = await future;
    await Future.delayed(
      const Duration(milliseconds: 280),
      () => _value = value + 1,
    );
    ref.invalidateSelf();
    await future;
  }

  static int _value = 0;
}

void main() {
  runApp(
    ProviderScope(
      observers: [
        TalkerRiverpodObserver(
          settings: TalkerRiverpodLoggerSettings(
            printProviderDisposed: true,
            printFailFullData: true,
            printProviderAdded: true,
            printProviderFailed: true,
            printProviderUpdated: true,
            printStateFullData: true,
          ),
        )
      ],
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(final BuildContext context) {
    return MaterialApp(
      theme: ThemeData.light(useMaterial3: true),
      darkTheme: ThemeData.dark(useMaterial3: true),
      themeMode: ThemeMode.system,
      debugShowCheckedModeBanner: false,
      home: const Scaffold(
        body: MyWidget(),
      ),
    );
  }
}

class MyWidget extends ConsumerStatefulWidget {
  const MyWidget({super.key});

  @override
  ConsumerState<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends ConsumerState<MyWidget> {
  final deferredValue = Future.delayed(
    const Duration(milliseconds: 140),
    () => 'initialized',
  );

  @override
  void initState() {
    super.initState();
    _change();
  }

  @override
  Widget build(final BuildContext context) {
    return FutureBuilder(
      initialData: 'initializing...',
      future: deferredValue,
      builder: (context, snapshot) => Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(snapshot.data!),
            if (snapshot.connectionState == ConnectionState.done) ...[
              _buildProvider(),
              ElevatedButton(
                onPressed: _changing ? null : _change,
                child: const Text('Change'),
              ),
            ],
          ],
        ),
      ),
    );
  }

  Widget _buildProvider() {
    final value = ref.watch(myProvider).valueOrNull;
    print('[ui] Showing $value');
    return Text('value: $value');
  }

  Future<void> _change() async {
    setState(() => _changing = true);
    try {
      print('[ui] Changing');
      await ref.read(myProvider.notifier).inc();
    } finally {
      setState(() => _changing = false);
    }
  }

  var _changing = false;
}

And the actual output (without interaction) is the following:

Details
[ui] Changing
┌───────────────────────────────────────────────────────────
│ [riverpod-add] | 23:59:18 215ms | 
│ AutoDisposeAsyncNotifierProviderImpl<My, int> initialized
│ INITIAL state: 
│ AsyncLoading<int>()
└───────────────────────────────────────────────────────────
┌───────────────────────────────────────────────────────────
│ [riverpod-dispose] | 23:59:18 240ms | 
│ AutoDisposeAsyncNotifierProviderImpl<My, int> disposed
└───────────────────────────────────────────────────────────
┌───────────────────────────────────────────────────────────
│ [riverpod-add] | 23:59:18 356ms | 
│ AutoDisposeAsyncNotifierProviderImpl<My, int> initialized
│ INITIAL state: 
│ AsyncLoading<int>()
└───────────────────────────────────────────────────────────
[ui] Showing null
┌───────────────────────────────────────────────────────────
│ [riverpod-update] | 23:59:18 463ms | 
│ AutoDisposeAsyncNotifierProviderImpl<My, int> updated
│ PREVIOUS state: 
│ AsyncLoading<int>()
│ NEW state: 
│ AsyncData<int>(value: 0)
└───────────────────────────────────────────────────────────
[ui] Showing 0
┌───────────────────────────────────────────────────────────
│ [riverpod-dispose] | 23:59:18 605ms | 
│ AutoDisposeAsyncNotifierProviderImpl<My, int> disposed
└───────────────────────────────────────────────────────────
┌───────────────────────────────────────────────────────────
│ [riverpod-update] | 23:59:18 703ms | 
│ AutoDisposeAsyncNotifierProviderImpl<My, int> updated
│ PREVIOUS state: 
│ AsyncLoading<int>()
│ NEW state: 
│ AsyncData<int>(value: 1)
└───────────────────────────────────────────────────────────
┌───────────────────────────────────────────────────────────
│ [riverpod-add] | 23:59:18 705ms | 
│ AutoDisposeAsyncNotifierProviderImpl<My, int> initialized
│ INITIAL state: 
│ AsyncLoading<int>()
└───────────────────────────────────────────────────────────
[ui] Showing null
┌───────────────────────────────────────────────────────────
│ [riverpod-dispose] | 23:59:18 714ms | 
│ AutoDisposeAsyncNotifierProviderImpl<My, int> disposed
└───────────────────────────────────────────────────────────

The problem as you can see is that adding your reactive dependency (ref.watch) like that won't keep the provider alive, therefore it disposes.

With "like that" I mean "in a function of your consumer stateful widget". Indeed if you write a Consumer in place of that function, it works as you'd expect (we get to read 1 in the UI).

I need to figure out if this is intended behavior or not, I'll get back here when I do.

@darkstarx
Copy link
Author

darkstarx commented Feb 26, 2025

The problem as you can see is that adding your reactive dependency (ref.watch) like that won't keep the provider alive, therefore it disposes.

Yes, this is what I wrote about earlier, and that's why I told the problem seems to be in the flutter_riverpod.
When I dug deeper inside it and experimented a little, I found the provider generated two states living at the same time - one for ref.watch and the other one during the side effect AFAIR. It was strange and I tried to reproduce the problem on the riverpod level (using listeners only) - with no success. That made me think the problem lies in the ref.watch (WidgetRef or ConsumerStatefulElement or somewhere there) which is in the flutter_riverpod. Unfortunately I didn't have enough time to manage with quite complicated code inside of that package at that time.
Hope you can help.

@lucavenir
Copy link
Collaborator

lucavenir commented Feb 26, 2025

Oh boy this was a hard one.

Hope you can help.

I'm here just to replicate bugs; then, if I'm able to replicate, summarize them, and write a self-containing failing test for 'em.


Summarizing this issue to help @rrousselGit narrow down bug squashing before the 3.0 release.

Self-containing failing test
import 'dart:async';

import 'package:riverpod/riverpod.dart';
// import 'package:talker_riverpod_logger/talker_riverpod_logger_observer.dart';
import 'package:test/test.dart';
import 'package:mocktail/mocktail.dart';

void main() {
  test("artifically waiting a side effect is allowed", () async {
    final container = ProviderContainer(
      // observers: [TalkerRiverpodObserver()], // add this for further debug info
    );
    addTearDown(container.dispose);

    final future = container.read(fancyNotifierProvider.notifier).inc();
    expectLater(future, completion(equals(1)));

    expect(container.exists(fancyNotifierProvider), isTrue);

    // "artificially" wait for side effect's future to complete
    await Future.delayed(FancyNotifier._incDuration);
    expect(container.exists(fancyNotifierProvider), isFalse);

    final tester = container.testTransitionsOn(fancyNotifierProvider);
    expect(container.exists(fancyNotifierProvider), isTrue);
    final value = container.read(fancyNotifierProvider.future);
    expectLater(value, completion(equals(1)));

    // wait for the notifier's future to complete
    await value;
    // try adding the following with an observer on, and see what happens to your logs:
    // await container.read(fancyNotifierProvider.future);
    // await container.read(fancyNotifierProvider.future);
    // await container.read(fancyNotifierProvider.future);

    verifyInOrder([
      () => tester(null, AsyncLoading()),
      () => tester(AsyncLoading(), AsyncData(1)),  // this doesn't happen at all!
    ]);
    verifyNoMoreInteractions(tester);
  });
}

final fancyNotifierProvider =
    AutoDisposeAsyncNotifierProvider<FancyNotifier, int>(FancyNotifier.new);

class FancyNotifier extends AutoDisposeAsyncNotifier<int> {
  @override
  FutureOr<int> build() {
    return Future.delayed(
      _buildDuration,
      () => _value,
    );
  }

  Future<int> inc() async {
    var value = await future;
    await Future.delayed(
      _incDuration,
      () => _value = value + 1,
    );
    ref.invalidateSelf();
    value = await future;
    return value;
  }

  static int _value = 0;
  static const Duration _incDuration = Duration(milliseconds: 200);
  static const Duration _buildDuration = Duration(milliseconds: 100);
}

class ProviderTransitionsTestHelper<T> extends Mock {
  void call(T? previous, T value);
}

extension ProviderTransitionListenerSetupExt on ProviderContainer {
  ProviderTransitionsTestHelper<T> testTransitionsOn<T>(
    ProviderListenable<T> provider, {
    bool fireImmediately = true,
  }) {
    final listener = ProviderTransitionsTestHelper<T>();
    final subscription = listen(
      provider,
      listener.call,
      fireImmediately: fireImmediately,
    );
    addTearDown(subscription.close);
    return listener;
  }
}

What happens here is that a .read.(provider.notifier).sideEffect (with a ref.selfInvalidate in it!), combined with a subsequent .listen(provider) (added before the side effect completes, or even shortly after), triggers some sort of race condition that enables Riverpod to update a disposed provider (apparently?).

To help visualize this behavior, I've left a handy logger for you to use, you can de-comment those lines if you want to see that as well. Below, the output, as-is.

Logs
┌─────────────────────────────────────────────────────────────────────
│ [riverpod-add] | 0:51:51 594ms | 
│ AutoDisposeAsyncNotifierProviderImpl<FancyNotifier, int> initialized
│ INITIAL state: 
│ AsyncLoading<int>()
└─────────────────────────────────────────────────────────────────────
┌─────────────────────────────────────────────────────────────────────
│ [riverpod-dispose] | 0:51:51 600ms | 
│ AutoDisposeAsyncNotifierProviderImpl<FancyNotifier, int> disposed
└─────────────────────────────────────────────────────────────────────
┌─────────────────────────────────────────────────────────────────────
│ [riverpod-add] | 0:51:51 803ms | 
│ AutoDisposeAsyncNotifierProviderImpl<FancyNotifier, int> initialized
│ INITIAL state: 
│ AsyncLoading<int>()
└─────────────────────────────────────────────────────────────────────
┌─────────────────────────────────────────────────────────────────────
│ [riverpod-dispose] | 0:51:51 899ms | 
│ AutoDisposeAsyncNotifierProviderImpl<FancyNotifier, int> disposed
└─────────────────────────────────────────────────────────────────────
Matching call #1 not found. All calls: ProviderTransitionsTestHelper<AsyncValue<int>>.call(null, AsyncLoading<int>())
package:matcher                           fail
package:mocktail/src/mocktail.dart 430:9  verifyInOrder.<fn>
test/riverpod_bugs_test.dart 42:18        main.<fn>
┌─────────────────────────────────────────────────────────────────────
│ [riverpod-update] | 0:51:52 1ms | 
│ AutoDisposeAsyncNotifierProviderImpl<FancyNotifier, int> updated
│ PREVIOUS state: 
│ AsyncLoading<int>()
│ NEW state: 
│ AsyncData<int>(value: 1)
└─────────────────────────────────────────────────────────────────────

You can see the two "add" events. Adding (de-commenting) an extra container.read will add a new "add" event to the pile, and then a "dispose" one, even though the provider is clearly being listened.
No subsequent "update" events are ever emitted, and indeed the test fails.

The key points are:

  • we do not await the side effect's future
  • we instead await the exact amount of time the side effects need to complete (you can play with it and change it slightly if you want), aka Future.delayed(someTime)
  • the side effect must trigger a .selfInvalidate for this scenario to work

To double and triple check this, I've wrote two more passing tests.

First passing test
import 'dart:async';

import 'package:riverpod/riverpod.dart';
import 'package:talker_riverpod_logger/talker_riverpod_logger_observer.dart';
import 'package:test/test.dart';
import 'package:mocktail/mocktail.dart';

void main() {
  test('waiting for the side effect to finish its computation', () async {
    final container = ProviderContainer(
      observers: [TalkerRiverpodObserver()], // add this for further debug info
    );
    addTearDown(container.dispose);

    final future = container.read(fancyNotifierProvider.notifier).inc();
    expectLater(future, completion(equals(1)));

    expect(container.exists(fancyNotifierProvider), isTrue);

    // "artificially" wait for side effect's future to complete
    await future;
    expect(container.exists(fancyNotifierProvider), isFalse);

    final tester = container.testTransitionsOn(fancyNotifierProvider);
    expect(container.exists(fancyNotifierProvider), isTrue);
    final value = container.read(fancyNotifierProvider.future);
    expectLater(value, completes);

    // wait for the notifier's future to complete
    await value;

    verifyInOrder([
      () => tester(null, AsyncLoading()),
      () => tester(AsyncLoading(), AsyncData(1)),
    ]);
    verifyNoMoreInteractions(tester);
  });
}

final fancyNotifierProvider =
    AutoDisposeAsyncNotifierProvider<FancyNotifier, int>(FancyNotifier.new);

class FancyNotifier extends AutoDisposeAsyncNotifier<int> {
  @override
  FutureOr<int> build() {
    return Future.delayed(
      _buildDuration,
      () => _value,
    );
  }

  Future<int> inc() async {
    var value = await future;
    await Future.delayed(
      _incDuration,
      () => _value = value + 1,
    );
    ref.invalidateSelf();
    value = await future;
    return value;
  }

  static int _value = 0;
  static const Duration _incDuration = Duration(milliseconds: 200);
  static const Duration _buildDuration = Duration(milliseconds: 100);
}

class ProviderTransitionsTestHelper<T> extends Mock {
  void call(T? previous, T value);
}

extension ProviderTransitionListenerSetupExt on ProviderContainer {
  ProviderTransitionsTestHelper<T> testTransitionsOn<T>(
    ProviderListenable<T> provider, {
    bool fireImmediately = true,
  }) {
    final listener = ProviderTransitionsTestHelper<T>();
    final subscription = listen(
      provider,
      listener.call,
      fireImmediately: fireImmediately,
    );
    addTearDown(subscription.close);
    return listener;
  }
}

As you can see this test takes the same notifier, awaits for the side effect's future to complete and tries the same assertions; everything goes smoothly (add event -> update event, add event -> update event). The test passes.

Second passing test
import 'dart:async';

import 'package:riverpod/riverpod.dart';

import 'package:test/test.dart';
import 'package:mocktail/mocktail.dart';
// import 'package:talker_riverpod_logger/talker_riverpod_logger_observer.dart';

import 'helpers.dart';

void main() {
  test("double-like state shouldn't be a thing", () async {
    final container = ProviderContainer(
        // observers: [TalkerRiverpodObserver()], // add this for further debug info
        );
    addTearDown(container.dispose);

    final future = container.read(standardNotifierProvider.notifier).inc();
    expectLater(future, completion(equals(1)));

    // "artificially" wait for side effect's future to complete
    await Future.delayed(StandardNotifier._incDuration);

    final tester = container.testTransitionsOn(standardNotifierProvider);
    final value = container.read(standardNotifierProvider.future);
    expectLater(value, completion(equals(0)));

    // wait for the notifier's future to complete
    await value;

    verifyInOrder([
      () => tester(null, AsyncLoading()),
      () => tester(AsyncLoading(), AsyncData(0)),
    ]);
    verifyNoMoreInteractions(tester);
  });
}

final standardNotifierProvider =
    AutoDisposeAsyncNotifierProvider<StandardNotifier, int>(
  StandardNotifier.new,
);

class StandardNotifier extends AutoDisposeAsyncNotifier<int> {
  @override
  FutureOr<int> build() {
    return Future.delayed(
      _buildDuration,
      () => 0,
    );
  }

  Future<int> inc() {
    return update((state) {
      return Future.delayed(_incDuration, () => state + 1);
    });
  }

  static const Duration _incDuration = Duration(milliseconds: 200);
  static const Duration _buildDuration = Duration(milliseconds: 100);
}

The second passing test here uses update in place of .selfInvalidate, and doesn't wait for that side effect's await.

@lucavenir
Copy link
Collaborator

@darkstarx I want to add this comment: one way to quickly solve this, is not to use an internal mutable static variable to handle state.
Be aware that from Riverpod 3.0 and onwards, all Notifier classes will dispose along with their provider; this means effectively losing all of your internal state.
You could say that's an anti-pattern, these days.

@lucavenir lucavenir added with-repro and removed question Further information is requested labels Feb 26, 2025
@lucavenir
Copy link
Collaborator

lucavenir commented Feb 26, 2025

I've gone deep into this bug and experimented some more, I've edited the summary above.

@darkstarx
Copy link
Author

@darkstarx I want to add this comment: one way to quickly solve this, is not to use an internal mutable static variable to handle state. Be aware that from Riverpod 3.0 and onwards, all Notifier classes will dispose along with their provider; this means effectively losing all of your internal state. You could say that's an anti-pattern, these days.

Sure, that's absolutely correct behavior. Note that the variable in the example is static and won't be reset after disposing the notifier, it's there just to simulate storing the data outside (on some server) - just for simplification the example.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working with-repro
Projects
None yet
Development

No branches or pull requests

4 participants