lucavenir / go_router_riverpod

An example on how to use Riverpod + GoRouter
460 stars 68 forks source link

Question about implementing of `ref.listenSelf` in auth.dart #14

Closed EthanHipps closed 1 year ago

EthanHipps commented 1 year ago

Hello,

I have a dumb question and apologize in advance if this isn't the correct place to ask it.

In auth.dart, the asynchronous build() calls _persistenceRefreshLogic() which then calls ref.listenSelf(). According to the Riverpod docs, ref.listen() shouldn't be called asynchronously.

Does that guidance not specifically apply to ref.listenSelf() or this usage in general (i.e. if I wanted to call ref.listen in a similar method would it be okay)?

lucavenir commented 1 year ago

Hi there,

there are no dumb questions.

ref.listen shouldn't be called asynchronously

This is true. And it's also true for ref.watch.

Why tho?

That's because registering a callback which reactively listens to other providers (i.e. events) might be dangerous if performed asynchronously. Imagine a scenario in which we await for a "relatively long" time before registering such callback. By that time, that provider might have been disposed (runtime error) or - worse - it could have changed (silent bug).

This is why the docs state this shouldn't be done in a (e.g.) onPressed async event or in other State - lifecycles functions.

Luckily, when using ref.listenSelf inside our own build method this danger disappears; after the asynchronous gap, we might have the following scenarios:

  1. The async operation failed (somehow). The function interrupts (throws) and the Notifier initializes with an AsyncError
  2. The async operation succeeds: the following lines behaves just as expected
  3. While we wait for the async operation to complete, someone else tries to read our internal state. This is ok: the AsyncNotifier emits a loading value, so nothing bad can really happen
  4. While we wait for the async operation to complete, someone else tries to modify our internal state. This would be bad as much as the example above, but this can't happen: Riverpod would raise a StateError since you can't change state while initializing (I am sure you've received an error message like this before)
  5. While we wait for the async operation to complete, our Notifier gets disposed. This won't happen as authentication is basically lisened at the root of our application. Nonetheless, it can be easily investigated if listenSelf is safe with respect of this scenario with a simple reproducible example.

Nonetheless, chances are I will edit that line out: obtaining SharedPreferences is commonly done in a separated, synchronous Provider, so thanks for question. See, you can always help somebody.

EthanHipps commented 1 year ago

Thank you very much for the response and detailed explanation. I really appreciate it!

lucavenir commented 1 year ago

I wanted to further explore this behavior. Here's a full reproducible example:

import 'dart:math';

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

part 'main.g.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

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

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Riverpod Demo',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const MyHomePage(title: 'Riverpod demo here we go!'),
      routes: {'new-route': (context) => const MyWidget()},
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: const <Widget>[
            Text(
                "Press the button to initialize the other screen and providers!")
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Navigator.of(context).pushNamed('new-route');
        },
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final a = ref.watch(stateProvider).when(
          data: (data) => "$data",
          error: (error, stackTrace) => "Error",
          loading: () => "loading...",
        );

    return Scaffold(
      body: Center(
        child: InkWell(
          onTap: () async {
            Navigator.of(context).pop();
            await Future.delayed(const Duration(seconds: 3));
            try {
              // won't work (remove try catch to see what happens)
              print("Reading 1... ${ref.read(otherProvider)}");
            } catch (e) {}
            try {
              // won't work (remove try catch to see what happens)
              print("Reading 2... ${ref.read(stateProvider)}");
            } catch (e) {}
          },
          child: Container(
            color: Colors.amberAccent,
            padding: const EdgeInsets.all(32),
            child: Text(a),
          ),
        ),
      ),
    );
  }
}

@riverpod
Future<int> state(StateRef ref) async {
  ref.onResume(() {
    print("I am alive again!");
  });
  ref.onCancel(() {
    print("I have no more listeners, I'm gonna be disposed!");
  });
  ref.onDispose(() {
    print("I have been disposed!");
  });
  final result = await Future.delayed(
    const Duration(seconds: 2),
    () => Random().nextInt(10),
  );

  ref.listenSelf((previous, next) {
    print("I have listened to myself: $next");
  });

  ref.listen(otherProvider, (previous, next) {
    print("I have listened to other: $next");
  });

  ref.keepAlive();

  return result;
}

@riverpod
Future<int> other(OtherRef ref) async {
  ref.onResume(() {
    print("[other] I am alive again!");
  });
  ref.onCancel(() {
    print("[other] I have no more listeners, I'm gonna be disposed!");
  });
  ref.onDispose(() {
    print("[other] I have been disposed!");
  });
  final result = await Future.delayed(
    const Duration(seconds: 2),
    () => Random().nextInt(10),
  );

  ref.listenSelf((previous, next) {
    print("[other] I have listened to myself");
  });

  ref.keepAlive();

  return result;
}

I invite you to test this out, and to go in and out the pushed route (press the button and then the rectangle) - and read the logs.

There's a few takeaways:

  1. Riverpod can't magically interrupt functions, so whatever happens inside the build functions or inside providers, those instructions will be executed. So listen and listenSelf are executed anyways.
  2. In this example an asynchronous ref.listen won't crash anything, but will temporarily "awaken" other and trigger the callback, which might be undesired. Then, the provider will be disposed (and its listeners removed). I might have said something incorrect about the state error above, but still, don't use listen asynchronously.
  3. As you can see ref.listenSelf isn't ever triggered in the first Provider, which is desired. So it's safe to use.