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

How to Ensure FutureProvider computes and returns the initial value when I await, even if invalidated #3905

Open
stephane-archer opened this issue Jan 6, 2025 · 14 comments
Assignees
Labels
documentation Improvements or additions to documentation needs triage

Comments

@stephane-archer
Copy link

stephane-archer commented Jan 6, 2025

String? highQualityPreviewPath =
              await ref.read(highQualityPreviewPathProvider.future);

If the user changes the highQualityPreviewPathProvider value while we await the computed value, this will return the user's last value.

But I want in this particular case to await the current value of highQualityPreviewPathProvider. If the user invalidates highQualityPreviewPathProvider, I still want the original value returned. How can I do that with Riverport and Dart?

@stephane-archer stephane-archer added documentation Improvements or additions to documentation needs triage labels Jan 6, 2025
@rrousselGit
Copy link
Owner

Do

final sub = widgetRef.listenManual(p, (a,b){});
try {
  await sub.read();
} finally
  sub.close();
}

@stephane-archer
Copy link
Author

stephane-archer commented Jan 6, 2025

          final sub = ref.listenManual(highQualityPreviewPathProvider.future,
              (previous, next) {
            print("highQualityPreviewPathProvider change!");
          });
          try {
            var highQualityPreviewPath = await sub.read();
          } finally {
            sub.close();
          }

@rrousselGit this did not work
highQualityPreviewPathProvider is an AsyncNotifierProvider

if I use a debugger here is what I can observe.

ref.listenManual is called while the correct highQualityPreviewPath is been computed (not yet finished).
then await sub.read.

now the user can change the highQualityPreviewPathProvider value by updating a provider that highQualityPreviewPathProvider watch.

I don't see any call for print("highQualityPreviewPathProvider change!"); at this moment.

the await sub.read(); is still not returning while the computing should not take more than 10 seconds.

the new value of highQualityPreviewPathProvider has been computed.
await sub.read(); return the user updated value. so I observed the same behavior as if I was using ref.read

any idea what is going on?

I think print("highQualityPreviewPathProvider change!"); is not called because the value has been updated before the original future has been finished.

@rrousselGit
Copy link
Owner

Sorry, I misunderstood your question. I thought you asked something else

What you're asking is not possible. The "initial/current value", as you call it, was discarded. From the moment the provider got invalidated, any previous work is no-longer revelant and Riverpod will stop using it.

@stephane-archer
Copy link
Author

stephane-archer commented Jan 6, 2025

@rrousselGit no worries. Because I await the value before it is invalidated, can I ask Riverpod at this moment to give me the actual future of that value so when Riverpod discarded it I'm still awaiting the correct value? Because I'm interested in reading the "current value" not the last value in this specific case.

Let me give you some context, you might think of a better solution.
My users edit a photo they selected. Then can export the result. This export takes quite some time.
If my users change the photo they selected while it's exporting. It going to export the new picture instead of the one used at the time of the export. Because the export functionality reads an AsyncNotifierProvider.future that changes when a new photo is imported.

@rrousselGit
Copy link
Owner

As I said, there's no such thing.

In any case, an export is a side-effect. You probably shouldn't be obtaining the value from reading the provider, but instead by using a Notfiier and calling a method on it.
And your notifier's method has full control over what it wants to return

@stephane-archer
Copy link
Author

Hi @rrousselGit I followed your recommendation to move the export functionality the the notifier to have more control, but I didn't find a way to make it work.

Here is my code:

class HighQualityPreviewPathNotifer extends AsyncNotifier<String?> {
  @override
  FutureOr<String?> build() async {
    ...
    return outputFile.path;
  }

  Future<void> export(String pathOutput) async {
    String? highQualityPreviewPath = await future;
    ....
  }

We can see in the code the following:

  /// This future will not necessarily wait for [AsyncNotifier.build] to complete.
  /// If [state] is modified before [AsyncNotifier.build] completes, then [future]
  /// will resolve with that new [state] value.

if the future getter is inappropriate here. What should I use?

@rrousselGit
Copy link
Owner

What's wrong here? You can use future

@stephane-archer
Copy link
Author

If I use “future” and the user modifies a notifier what my notifier watches before the future is returned, the returned value would be the value my users just updated, not the “current value computed” when I started “await” that future.

Here if my user exports an image then update the imported image while it is exporting. The exported image will use the last imported image, not the one selected at the start of the export.

How can I have “futureThaDontChangeIfTheUserUpdateSomething”?

@rrousselGit
Copy link
Owner

I've already said before that what you're asking is not doable and that you should be using a different approach.

Why does that distinction matters? What are you trying to achieve exactly?

@stephane-archer
Copy link
Author

@rrousselGit, I misunderstood you. Why did you advise me to move the code inside the notifier rather than having it in an onTap and do "provider.future"? I did not observe a difference in behavior. I thought I would have more tools to do what I wanted inside the provider.

I tried to give you some context here: #3905 (comment). How can I make it clearer to you what the issue is?

let's take a simple app, you can select a photo to see it in black and white.
you have one provider for the selected photo and two other providers, that do the black-and-white conversion.
One at full resolution, and one at low resolution to be displayed faster to the user while the second one is computed. The user selects to save the high-resolution black and white version that is still computed. the app displays "exporting" and waits for the high-resolution provider to finish. the user selects a new image while it's exporting and tada, the new image is exported rather than the one used when the user clicked.

Do you have a different approach in mind?

Don't you think it would be useful to introduce something similar to future inside the notifier that doesn't update its value when the notifier has to rebuild? maybe lockedFuture?

@rrousselGit
Copy link
Owner

Don't you think it would be useful to introduce something similar to future inside the notifier that doesn't update its value when the notifier has to rebuild? maybe lockedFuture?

No. In your scenario, that "black and white version" never truly existed. Your provider was told to cancel its work on the b/w version to start a new one.
Your b/w version is likely still saved in the file system, but that's only because your provider didn't react to the cancel event and still produced the b/w version anyway ; even though it shouldn't have.

If anything, your issue is that you cancelled your b/w conversion, but didn't mean to cancel it.

Sounds like you're using one provider for multiple states. You likely should split the "current image being transformed" from "an image is transformed with x parameters".

This is where family is typically used.

You could have:

final imageProvider = FutureProvider.family<Image, ({String path, bool asBlackAndWhite})>((ref, options) async {
  // TODO load image at path, optionally transforming it to b/w if the flag is true.
});

Given the same arguments, the image obtained will be the same.

And you could then transform this FutureProvider into an AsyncNotifierProvider (while still using family) to add your export method. So;

final imageProvider = AsyncNotifierProvider.autoDsipose.family<ImageNotifier, Image, ({String path, bool asBlackAndWhite})>(ImageNotifier.new);

class ImageNotifier extends AutoDisposeFamilyAsyncNotifier<Image,  ({String path, bool asBlackAndWhite})> {
  @override
  Future<Image> build(({String path, bool asBlackAndWhite}) op) {
     // TODO load image at path, optionally transforming it to b/w if the flag is true.
  }

  Future<void> export() {
    final image = await future;
    // TODO publish the image
  }
}

Changing the path/transformation in the UI would have no impact here.

@stephane-archer
Copy link
Author

Success:

@rrousselGit I was able to make it work using family, I would like to thank you for pointing out a solution.

Question:

Your b/w version is likely still saved in the file system, but that's only because your provider didn't react to the cancel event and still produced the b/w version anyway ; even though it shouldn't have.

Is it possible to detest that our job and been cancel inside the build function or member function inside our notifer to avoid useless work and network request? How to make my provider react to the cancel event?

Improvements:

I would like to point out a few things so you can make riverpod more user-friendly:

the documenation about familly do not memtion AsyncNotifierProvider without your comment above I wound't have find a solution. #3905 (comment)


Sounds like you're using one provider for multiple states. You likely should split the "current image being transformed" from "an image is transformed with x parameters".
This is where family is typically used.

maybe you should add this in "Some common use-cases for family" in the doc, readding it, I didn't saw much how these common cases where close to mine.


this syntax is a bit strange compare to the rest of the package:

final imageProvider = AsyncNotifierProvider.autoDsipose.family<ImageNotifier, Image, ({String path, bool asBlackAndWhite})>(ImageNotifier.new);

I would have expected:

final imageProvider = AsyncNotifierProvider.autoDsipose.family<ImageNotifier, Image, ({String path, bool asBlackAndWhite})>(arg) {return ImageNotifier(arg);}

@rrousselGit
Copy link
Owner

How to make my provider react to the cancel event?

Ref.onDispose

the documenation about familly do not memtion AsyncNotifierProvider without your comment above I wound't have find a solution. #3905 (comment)

All providers have access to family. Listing all providers would be of little value.

I would have expected:

final imageProvider = AsyncNotifierProvider.autoDsipose.family<ImageNotifier, Image, ({String path, bool asBlackAndWhite})>(arg) {return ImageNotifier(arg);}

That just adds unnecessary work when creating a Notifier.

@stephane-archer
Copy link
Author

All providers have access to the family modifier, so listing all possible providers explicitly would add little value. That said, I understand how someone might feel uncertain about how to use certain combinations without clear examples.

Documentation Examples:

Looking at the examples in the documentation, we see the family modifier used consistently across different provider types:

final messagesFamily = FutureProvider.family<Message, String>((ref, id) async {
  return dio.get('http://my_api.dev/messages/$id');
});
final characters = FutureProvider.autoDispose.family<List<Character>, String>((ref, filter) async {
  return fetchCharacters(filter: filter);
});
final exampleProvider = Provider.autoDispose.family<Something, MyParameter>((ref, myParameter) {
  print(myParameter.userId);
  print(myParameter.locale);
  // Do something with userId/locale
});

The shape of these examples is consistent and intuitive. However, regarding AsyncNotifierProvider.autoDispose.family, things get less clear without explicit mention in the documentation. For instance, I wouldn't have known that I should use .new and extends AutoDisposeFamilyAsyncNotifier without your example in this comment. #3905 (comment)

Here’s what the definition looks like:

final imageProvider = AsyncNotifierProvider.autoDispose.family<ImageNotifier, Image, ({String path, bool asBlackAndWhite})>(
  ImageNotifier.new,
);

This is what I needed to understand how to use AsyncNotifierProvider effectively. Without that example, I wouldn’t have figured it out intuitively.

On the Use of .new

using .new feels a bit strange and unintuitive at first. It deviates slightly from the style of other examples in the documentation. While it's a minor detail, it does add a layer of cognitive overhead, especially for someone new to this pattern.

Importance of Mentioning Notifier

I believe the Notifier class is key in Riverpod. A mention of Notifier and its connection to families would help bridge the gap for users who might not immediately grasp the relationship. This could save a lot of trial and error for users trying to figure out the right approach.

On "Unnecessary Work"

final imageProvider = AsyncNotifierProvider.autoDsipose.family<ImageNotifier, Image, ({String path, bool asBlackAndWhite})>(arg) {return ImageNotifier(arg);}

The structure of this provider type may feel like additional work, but it aligns with the general pattern seen across the documentation for consistency. However, clearer examples, especially for AsyncNotifierProvider.autoDispose.family, would significantly reduce the guesswork and make this feel more approachable.

I hope you got a better sense of my perspective. I'm not saying I'm right; you are the ultimate judge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
documentation Improvements or additions to documentation needs triage
Projects
None yet
Development

No branches or pull requests

2 participants