riverpod icon indicating copy to clipboard operation
riverpod copied to clipboard

Watching selectAsync within a provider chain never completes

Open mahmuto opened this issue 1 year ago • 3 comments

This one is a little difficult to put into words. I have a scenario where a widget watches a provider that uses a chain of providers. Within on of these providers, a selectAsync is being watched but never produces a value. However, when reading the selectAsync or when reading/watching the future, the provider produces a value. This isn't desirable in all cases though.

Any help would be appreciated, thank you!

lib/providers.dart in the attached sample contains the gist of the issue:

import 'package:flutter/foundation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'providers.g.dart';

@riverpod
Future<int> value0(Value0Ref ref) async {
  debugPrint('value0');
  await Future.delayed(const Duration(milliseconds: 1500));
  return 0;
}

@riverpod
Future<int> value1(Value1Ref ref) async {
  debugPrint('value1');

  // ISSUE: When watching 'selectAsync', the future never completes.
  return await ref.watch(value0Provider.selectAsync((data) => data));

  // However, if we read instead of watch, the future does complete.
  // return await ref.read(value0Provider.selectAsync((data) => data));

  // Also, if we 'read' or 'watch' 'future' instead of using 'selectAsync', the future also completes.
  // return await ref.read(value0Provider.future);
  // return await ref.watch(value0Provider.future);
}

@riverpod
Future<int> value2(Value2Ref ref) async {
  debugPrint('value2');
  return await ref.read(value1Provider.future);
}

@riverpod
Future<int> value3(Value3Ref ref) async {
  debugPrint('value3');
  return await ref.read(value2Provider.future);
}

To Reproduce Run the following sample flutter project. riverpod_issue.zip

Expected behavior The expectation is the widget should update after loading is complete, but never does. See the comments in lib/provider.dart for what does make it work.

mahmuto avatar May 11 '23 20:05 mahmuto

I'm currently in the process of investigating this. Thanks for the reproducible example!

So far I haven't found what exactly is causing this. But somehow in your example, value1Provider gets disposed even though it shouldn't and it enters a bad state. From there on, it's considered as not watching anything (due to listeners being cleared since the provider was disposed). So "selectAsync" never receives the updated value

It's a tricky one. I'll need a bit more time to investigate.

rrousselGit avatar Nov 26 '23 22:11 rrousselGit

Hi,

I've also bumped into this odd behavior. In my case, I was trying to call await ref.read(value1Provider.future); to get a value from inside a button's onPressed method. That value1Provider watches another provider using .selectAsync inside its build method. Oddly enough, this seems to work when using .watch instead of .read (but that is a bad practice). It also seems to affect stream providers.

Difficult to put into words indeed.

I'm sharing a bit of code in hope it helps:

Code

Future Providers:

@Riverpod(keepAlive: false)
Future<String> futureValue1(FutureValue1Ref ref) async {
  ref.onDispose(() {
    debugPrint('Disposed futureValue1Provider  ${ref.state}');
  });
  await Future.delayed(const Duration(seconds: 2));
  final value = 'futureValue1 calculated (${DateTime.now()})';
  debugPrint(value);
  return value;
}

@Riverpod(keepAlive: false)
Future<String> futureValue2(FutureValue2Ref ref) async {
  ref.onDispose(() {
    debugPrint('Disposed futureValue2Provider  ${ref.state}');
  });
  await Future.delayed(const Duration(seconds: 3));
  final value = 'futureValue2 calculated (${DateTime.now()})';
  debugPrint(value);
  return value;
}

@Riverpod(keepAlive: false)
FutureOr<String> combineFutureValues(CombineFutureValuesRef ref) async {
  ref.onDispose(() {
    debugPrint('Disposed combineFutureValuesProvider ${ref.state}');
  });

  // ISSUE: This does not work. (works when using .watch(.future) instead of .read(.future) inside button.onPressed)
  final value1 =
      await ref.watch(futureValue1Provider.selectAsync((data) => data));
  final value2 =
      await ref.watch(futureValue2Provider.selectAsync((data) => data));

  // This seems to work, but combineFutureValues is not disposed after .read call ends (is this normal?).
  // final value1 = await ref.watch(futureValue1Provider.future);
  // final value2 =
  //     await ref.watch(futureValue2Provider.selectAsync((data) => data));

  // This also works, but combineFutureValues is not disposed after .read call ends (is this normal?).
  // final value1 = await ref.watch(futureValue1Provider.future);
  // final value2 = await ref.watch(futureValue2Provider.future);

  return '$value1 + $value2';
}

Stream Providers:

@Riverpod(keepAlive: false)
Stream<String> streamValue1(StreamValue1Ref ref) async* {
  ref.onDispose(() {
    debugPrint('Disposed streamValue1Provider  ${ref.state}');
  });
  String value;
  await Future.delayed(const Duration(seconds: 2));
  value = 'streamValue1.1 calculated ${DateTime.now()}';
  debugPrint(value);
  yield value;

  await Future.delayed(const Duration(seconds: 5));
  value = 'streamValue1.2 calculated ${DateTime.now()}';
  debugPrint(value);
  yield value;
}

@Riverpod(keepAlive: false)
Stream<String> streamValue2(StreamValue2Ref ref) async* {
  ref.onDispose(() {
    debugPrint('Disposed streamValue2Provider');
  });
  String value;
  await Future.delayed(const Duration(seconds: 4));
  value = 'streamValue2.1 calculated ${DateTime.now()}';
  debugPrint(value);
  yield value;
}

@Riverpod(keepAlive: false)
FutureOr<String> combineStreamValues(CombineStreamValuesRef ref) async {
  ref.onDispose(() {
    debugPrint('Disposed combineStreamValues ${ref.state}');
  });

  // ISSUE: This does not work.
  final value1 =
      await ref.watch(streamValue1Provider.selectAsync((data) => data));
  final value2 =
      await ref.watch(streamValue2Provider.selectAsync((data) => data));

  // This works.
  // final value1 = await ref.watch(value1Provider.future);
  // final value2 = await ref.watch(value2Provider.future);

  return '$value1 + $value2';
}

Widgets:

Consumer(
  builder: (context, ref, child) {
    return Column(
      children: [
        TextButton(
          child: Text("Combine stream values"),
          onPressed: () async {
            // ISSUE: This never gets called when using .selectAsync
            final String combineStreamValues = await ref
                .read(combineStreamValuesProvider.future);
            print(
                'combineStreamValuesProvider.future result: $combineStreamValues | (${DateTime.now()})');
          },
        ),
        TextButton(
          child: Text("Combine stream values with .watch"),
          onPressed: () async {
            // Using .watch works when using .selectAsync, but shouldn't be used here.
            final String combineStreamValues = await ref
                .watch(combineStreamValuesProvider.future);
            print(
                'combineStreamValuesProvider.future result: $combineStreamValues | (${DateTime.now()})');
          },
        ),
        TextButton(
          child: Text("Combine future values with .read"),
          onPressed: () async {
            final String combineFutureValues = await ref
                .read(combineFutureValuesProvider.future);
            // ISSUE: This never gets called when using .selectAsync
            print(
                'combineFutureValuesProvider.future result: $combineFutureValues | (${DateTime.now()})');
          },
        ),
        TextButton(
          child: Text("Combine future values with .watch"),
          onPressed: () async {
            // Using .watch works when using .selectAsync, but shouldn't be used here
            final String combineFutureValues = await ref
                .watch(combineFutureValuesProvider.future);
            print(
                'combineFutureValuesProvider.future result: $combineFutureValues | (${DateTime.now()})');
          },
        ),
      ],
    );
  },
)
Configuration as tested
dependencies:
flutter_riverpod: ^3.0.0-dev.3
riverpod_annotation: ^3.0.0-dev.3

dev_dependencies:
build_runner: ^2.4.7
riverpod_generator: ^3.0.0-dev.11
riverpod_lint: ^3.0.0-dev.4
custom_lint: ^0.5.7

Others:

Flutter (Channel stable, 3.16.5, on macOS 14.2 23C64 darwin-arm64, locale en-BR)
Android toolchain - develop for Android devices (Android SDK version 34.0.0)
Xcode - develop for iOS and macOS (Xcode 15.0.1)
Android Studio (version 2022.3)
VS Code (version 1.85.1)

Thanks!

Andre-lbc avatar Dec 27 '23 15:12 Andre-lbc

Is there any workaround for this? Watching future providers seems like very basic functionality that will be present in many projects so it's a real pain if it's broken. Currently, it seems like the only reliable way to watch a future provider is to use ref.watch(someProvider.future) but this causes rebuilds when the value doesn't change.

On a side note, I feel like the default behavior of ref.watch(someProvider.future) should be ref.watch(someProvider.selectAsync((data) => data)). In most cases. when something watches a future provider it wants to watch the value, not the future. Typically, it wants to await for the first value, then receive updates when the value changes. As an alternative, I wonder if there could be a method added like ref.watch(someProvider.selectValue) which is just a shortcut for ref.watch(someProvider.selectAsync((data) => data)). This way you don't have to add that anonymous function every time.

Colman avatar Feb 18 '24 19:02 Colman

Hi everyone, Came across this issue yesterday and made a simple reproduction example of my usecase.

Code
import 'dart:developer';

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

part 'main.g.dart';

void main() {
  runApp(const App());
}

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

  @override
  Widget build(BuildContext context) {
    return const ProviderScope(
      child: MaterialApp(
        home: MainPage(title: 'Riverpod test'),
      ),
    );
  }
}

class MainPage extends ConsumerWidget {
  const MainPage({required this.title, super.key});

  final String title;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          mainAxisSize: MainAxisSize.min,
          children: [
            ElevatedButton(
              onPressed: () async {
                final a = await ref.read(aProvider.future);
                log(a, name: 'A');
              },
              child: const Text('A'),
            ),
            ElevatedButton(
              onPressed: () async {
                final b = await ref.read(bProvider.future);
                log(b, name: 'B');
              },
              child: const Text('B'),
            ),
            ElevatedButton(
              onPressed: () async {
                final c = await ref.read(watchDatabaseProvider.selectAsync((id) => '$id+456'));
                log(c, name: 'C');
              },
              child: const Text('C'),
            ),
          ],
        ),
      ),
    );
  }
}

// Provider using `.selectAsync`
@riverpod
Future<String> a(ARef ref) async {
  return ref.watch(watchDatabaseProvider.selectAsync((id) => '$id+456'));
}

// Provider using `.future`
@riverpod
Future<String> b(BRef ref) async {
  final id = await ref.watch(watchDatabaseProvider.future);
  return '$id+456';
}

@riverpod
Stream<String> watchDatabase(WatchDatabaseRef ref) => databaseStream;

Stream<String> get databaseStream async* {
  // Mock DB query delay
  await Future.delayed(const Duration(milliseconds: 100));

  yield '123';
}
Config
dependencies:
  flutter_riverpod: ^2.4.10
  riverpod_annotation: ^2.3.4

dev_dependencies:
  build_runner: ^2.4.8
  riverpod_generator: ^2.3.11

All cases are using the data exposed by watchDatabaseProvider, which is a StreamProvider.

Cases A and B are almost similar, but differ in the way they read watchDatabaseProvider:

  • A use .selectAsync on an intermediate FutureProvider and never resolves
  • B use .future on an intermediate FutureProvider and resolve as expected
  • C directly read the watchDatabaseProvider using a .selectAsync and resolves as expected

When playing with the mock delay on watchDatabaseProvider, when going under ~10ms, case A starts to resolve correctly (but not every time).

Note: The results are exactly the same when using non-generated providers.

It looks like the provider get disposed too early when using .selectAsync and then never returns.

ndelanou avatar Mar 08 '24 09:03 ndelanou

I've tracked down the issue and will submit a fix shortly.

rrousselGit avatar Mar 08 '24 13:03 rrousselGit