Skip to content

ProviderObserver incorrectly notified of post-dispose exceptions #4706

@saibotma

Description

@saibotma

When an autoDispose async provider is disposed before it emits its first
value, Riverpod reports:

StateError: The provider ... was disposed during loading state, yet no value could be emitted.

I understand that Riverpod may want to complete pending awaiters with an error
instead of leaving them unresolved forever.

What I do not understand is why this is modeled as a StateError instead of a
dedicated catchable cancellation/disposal exception.

This is difficult to reason about, especially for Riverpod newcomers, because:

  • StateError is an Error, not an Exception
  • in Dart, Errors usually represent programmer mistakes or broken invariants
  • this specific case can happen during normal navigation/lifecycle teardown
  • it makes it unclear whether the app has a real bug or whether this is
    expected
  • it makes benign disposal harder to handle cleanly in error reporting

Minimal repro

main.dart
import 'dart:async';

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

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

final delayedStreamProvider = StreamProvider.autoDispose<int>((ref) async* {
  debugPrint('delayedStreamProvider: started');
  ref.onDispose(() {
    debugPrint('delayedStreamProvider: disposed');
  });

  await Future<void>.delayed(const Duration(seconds: 3));
  debugPrint('delayedStreamProvider: emitting first value');
  yield 1;
}, name: 'delayedStreamProvider');

final dependentProvider = FutureProvider.autoDispose<String>((ref) async {
  debugPrint('dependentProvider: awaiting delayedStreamProvider.future');
  final value = await ref.watch(delayedStreamProvider.future);
  return 'Resolved with $value';
}, name: 'dependentProvider');

final class DisposeObserver extends ProviderObserver {
  @override
  void providerDidFail(
    ProviderObserverContext context,
    Object error,
    StackTrace stackTrace,
  ) {
    debugPrint(
      'providerDidFail: ${context.provider.name} ${context.provider.argument}',
    );
    debugPrint('error: $error');
    debugPrint('stackTrace: $stackTrace');
  }
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: const ReproScreen(),
    );
  }
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(dependentProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Riverpod Dispose Repro')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          spacing: 16,
          children: [
            Text('Current state: $state'),
            FilledButton(
              onPressed: () {
                Navigator.of(context).pushReplacement(
                  MaterialPageRoute<void>(
                    builder: (_) => const FinishedScreen(),
                  ),
                );
              },
              child: const Text('Replace screen before first value'),
            ),
          ],
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('After dispose')),
      body: const Center(
        child: Text('Check the console output from ProviderObserver.'),
      ),
    );
  }
}

Repro steps

  1. Run the app.
  2. Tap Replace screen before first value within 3 seconds.
  3. Observe the provider failure in the console.

Questions

  1. Is this behavior intentional?
  2. What is the recommended official way to handle this case?
  3. Why is this modeled as StateError instead of a dedicated
    cancellation/disposal exception?
  4. Is there an official pattern for treating this as benign lifecycle teardown
    without brittle message matching?

Possible improvement

If this behavior is intentional, would Riverpod consider using a dedicated
catchable exception type for this case instead of StateError?

That would make it easier to distinguish:

  • real application bugs / broken invariants
  • expected disposal/cancellation caused by normal navigation or lifecycle
    changes

and would likely make this behavior much easier to understand for newcomers.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions