Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/listen/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 1.0.0-beta.3

- Updates `ErrorCallback` to pass the raw error object and original `StackTrace`.
- Adds an optional `ErrorContext` parameter to `ErrorCallback` to distinguish listener errors from assertion errors.

## 1.0.0-beta.2

- Fixes dart doc example directive.
Expand Down
33 changes: 23 additions & 10 deletions packages/listen/lib/src/listen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,18 @@ import 'package:meta/meta.dart';
/// Signature of callbacks that have no arguments and return no data.
typedef VoidCallback = void Function();

/// Signature of callbacks that are called when an error is thrown by a listener.
typedef ErrorCallback = void Function(String message, StackTrace? stackTrace);
/// Identifies the context in which an error was reported to [Listenable.onError].
enum ErrorContext {
/// The error was thrown by a listener callback during [Listenable] notification.
listener,

/// The error was reported by a debug assertion or lifecycle check (e.g. using a disposed notifier).
assertion,
}

/// Signature of callbacks that are called when an error is reported by a listener or assertion.
typedef ErrorCallback =
void Function(Object error, StackTrace? stackTrace, {ErrorContext? context});

/// Signature of callbacks that are called when an object is created.
///
Expand Down Expand Up @@ -58,11 +68,11 @@ abstract class Listenable {
/// {@example /example/lib/listenable_merge.dart}
factory Listenable.merge(Iterable<Listenable?> listenables) = _MergingListenable;

/// Error callback that is called when an error is thrown by a listener.
/// Error callback that is called when an error is thrown by a listener or assertion.
///
/// By default, errors are thrown as [StateError].
static ErrorCallback onError = (String message, StackTrace? stackTrace) {
throw StateError(message);
/// By default, errors are rethrown preserving their original [StackTrace].
static ErrorCallback onError = (Object error, StackTrace? stackTrace, {ErrorContext? context}) {
Error.throwWithStackTrace(error, stackTrace ?? StackTrace.current);
};

/// Called when a new object is created.
Expand Down Expand Up @@ -171,10 +181,13 @@ mixin class ChangeNotifier implements Listenable {
assert(() {
if (notifier._debugDisposed) {
Listenable.onError(
'A ${notifier.runtimeType} was used after being disposed.\n'
'Once you have called dispose() on a ${notifier.runtimeType}, it '
'can no longer be used.',
StateError(
'A ${notifier.runtimeType} was used after being disposed.\n'
'Once you have called dispose() on a ${notifier.runtimeType}, it '
'can no longer be used.',
),
StackTrace.current,
context: ErrorContext.assertion,
);
}
return true;
Expand Down Expand Up @@ -424,7 +437,7 @@ mixin class ChangeNotifier implements Listenable {
try {
_listeners[i]?.call();
} catch (exception, stack) {
Listenable.onError(exception.toString(), stack);
Listenable.onError(exception, stack, context: ErrorContext.listener);
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/listen/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ name: listen
description: A package to notify state changes to interested listeners in pure Dart.
repository: https://github.com/flutter/core-packages/tree/main/packages/listen
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+listen%22
version: 1.0.0-beta.2
version: 1.0.0-beta.3

environment:
sdk: ^3.10.0
Expand Down
119 changes: 96 additions & 23 deletions packages/listen/test/listen_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,21 @@ class Counter with ChangeNotifier {

void main() {
final ErrorCallback originalOnError = Listenable.onError;
String? lastErrorMessage;
Object? lastError;
ErrorContext? lastContext;

setUp(() {
lastErrorMessage = null;
Listenable.onError = (String message, StackTrace? stackTrace) {
lastErrorMessage = message;
lastError = null;
lastContext = null;
Listenable.onError = (Object error, StackTrace? stackTrace, {ErrorContext? context}) {
lastError = error;
lastContext = context;
};
});
tearDown(() {
Listenable.onError = originalOnError;
if (lastErrorMessage != null) {
throw StateError('Unexpected error in test: $lastErrorMessage');
if (lastError != null) {
throw StateError('Unexpected error in test: $lastError');
}
});

Expand All @@ -81,11 +84,13 @@ void main() {

test.notify();

expect(lastErrorMessage, contains('dispose()'));
expect(lastError, isA<AssertionError>());
expect(lastContext, ErrorContext.listener);
expect(lastError.toString(), contains('dispose()'));
// Make sure it crashes during dispose call.
expect(callbackDidFinish, isFalse);
test.dispose();
lastErrorMessage = null;
lastError = null;
});

test('ChangeNotifier', () {
Expand All @@ -110,9 +115,9 @@ void main() {
final test = TestNotifier();

final ErrorCallback original = Listenable.onError;
final List<String> errorMessages = [];
Listenable.onError = (String message, StackTrace? stackTrace) {
errorMessages.add(message);
final List<Object> errors = [];
Listenable.onError = (Object error, StackTrace? stackTrace, {ErrorContext? context}) {
errors.add(error);
};
addTearDown(() {
Listenable.onError = original;
Expand Down Expand Up @@ -167,7 +172,7 @@ void main() {
test.addListener(badListener);
test.notify();
expect(log, <String>['listener', 'listener2', 'listener1', 'badListener']);
expect(errorMessages.removeAt(0), contains('Invalid argument'));
expect(errors.removeAt(0), isA<ArgumentError>());
log.clear();

test.addListener(listener1);
Expand All @@ -177,11 +182,79 @@ void main() {
test.addListener(listener2);
test.notify();
expect(log, <String>['badListener', 'listener1', 'listener2']);
expect(errorMessages.removeAt(0), contains('Invalid argument'));
expect(errors.removeAt(0), isA<ArgumentError>());
log.clear();
test.dispose();
});

test('Listenable.onError preserves runtime exception types', () {
final List<Object> errors = [];
final ErrorCallback original = Listenable.onError;
Listenable.onError = (Object error, StackTrace? stackTrace, {ErrorContext? context}) {
errors.add(error);
};
addTearDown(() {
Listenable.onError = original;
});

final notifier = TestNotifier();
notifier.addListener(() {
throw const FormatException('custom exception');
});
notifier.notify();

expect(errors.single, isA<FormatException>());
expect((errors.single as FormatException).message, 'custom exception');
});

test('Listenable.onError default implementation rethrows and preserves StackTrace', () {
final stack = StackTrace.fromString('test_stack_trace_marker');
try {
originalOnError(const FormatException('bad format'), stack);
fail('Expected originalOnError to throw');
} catch (e, s) {
expect(e, isA<FormatException>());
expect(s.toString(), 'test_stack_trace_marker');
}
});

test('Listenable.onError reports correct ErrorContext across all failure types', () {
final List<ErrorContext?> contexts = [];
final ErrorCallback original = Listenable.onError;
Listenable.onError = (Object error, StackTrace? stackTrace, {ErrorContext? context}) {
contexts.add(context);
};
addTearDown(() {
Listenable.onError = original;
});

final notifier = TestNotifier();
notifier.addListener(() {
throw Exception('listener exception');
});
notifier.notify();
expect(contexts.single, ErrorContext.listener);
contexts.clear();

notifier.dispose();

notifier.dispose();
expect(contexts.single, ErrorContext.assertion);
contexts.clear();

notifier.notify();
expect(contexts.single, ErrorContext.assertion);
contexts.clear();

notifier.addListener(() {});
expect(contexts.single, ErrorContext.assertion);
contexts.clear();

ChangeNotifier.debugAssertNotDisposed(notifier);
expect(contexts.single, ErrorContext.assertion);
contexts.clear();
Comment thread
chunhtai marked this conversation as resolved.
});

test('ChangeNotifier with mutating listener', () {
final test = TestNotifier();
final log = <String>[];
Expand Down Expand Up @@ -388,16 +461,16 @@ void main() {
source.dispose();

source.addListener(() {});
expect(lastErrorMessage, contains('TestNotifier was used after being disposed.'));
lastErrorMessage = null;
expect(lastError.toString(), contains('TestNotifier was used after being disposed.'));
lastError = null;

source.dispose();
expect(lastErrorMessage, contains('TestNotifier was used after being disposed.'));
lastErrorMessage = null;
expect(lastError.toString(), contains('TestNotifier was used after being disposed.'));
lastError = null;

source.notify();
expect(lastErrorMessage, contains('TestNotifier was used after being disposed.'));
lastErrorMessage = null;
expect(lastError.toString(), contains('TestNotifier was used after being disposed.'));
lastError = null;
});

test('Can remove listener on a disposed ChangeNotifier', () {
Expand Down Expand Up @@ -517,8 +590,8 @@ void main() {

testNotifier.dispose();

expect(lastErrorMessage, contains('TestNotifier was used after being disposed.'));
lastErrorMessage = null;
expect(lastError.toString(), contains('TestNotifier was used after being disposed.'));
lastError = null;
});

test('Calling debugAssertNotDisposed works as intended', () {
Expand All @@ -528,8 +601,8 @@ void main() {

ChangeNotifier.debugAssertNotDisposed(testNotifier);

expect(lastErrorMessage, contains('TestNotifier was used after being disposed.'));
lastErrorMessage = null;
expect(lastError.toString(), contains('TestNotifier was used after being disposed.'));
lastError = null;
});

test('notifyListener can be called recursively', () {
Expand Down
Loading