From 0205ada0a11bb19028e9a2315a8da8fef87d0248 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Fri, 12 Dec 2025 16:34:35 +0100 Subject: [PATCH 01/14] add error logging --- lib/src/base_notifier.dart | 25 +++++++++++++++++---- lib/src/catching_executor.dart | 7 ++++++ lib/src/operation_error_logger.dart | 5 +++++ lib/tapped_riverpod.dart | 1 + test/catching_executor_test.dart | 35 +++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 lib/src/operation_error_logger.dart create mode 100644 test/catching_executor_test.dart diff --git a/lib/src/base_notifier.dart b/lib/src/base_notifier.dart index e7448a1..b55ee7a 100644 --- a/lib/src/base_notifier.dart +++ b/lib/src/base_notifier.dart @@ -2,16 +2,22 @@ import 'dart:async'; import 'package:async/async.dart' show CancelableOperation; import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:riverpod/riverpod.dart'; -import 'package:tapped_riverpod/src/catching_executor.dart'; -import 'package:tapped_riverpod/src/result.dart'; +import 'package:tapped_riverpod/tapped_riverpod.dart'; /// Base class for custom Notifiers that provides: /// - cancelable async operations /// - standardized error handling /// - helper for loading/success/failure Result states abstract class BaseNotifier extends Notifier { - final _catchingExecutor = CatchingExecutor(); + late final CatchingExecutor _catchingExecutor; + + /// The error logger that is used from [CatchingExecutor]. + /// This can be overridden in: + /// ProviderScope( + /// overrides: BaseNotifier.errorLogger.overrideWithValue(myNewLogger), + /// ... + /// ) + static Provider get errorLogger => _errorLogger; @visibleForTesting Map> get operations => @@ -20,6 +26,8 @@ abstract class BaseNotifier extends Notifier { @mustCallSuper @override T build() { + _catchingExecutor = CatchingExecutor(errorLogger: ref.read(_errorLogger)); + // register cleanup when provider is disposed ref.onDispose(() { _catchingExecutor.cancelAllOperations(); @@ -76,3 +84,12 @@ abstract class BaseNotifier extends Notifier { void onDispose() {} } + +final _errorLogger = Provider((ref) { + return _DummyOperationErrorLogger(); +}); + +class _DummyOperationErrorLogger implements OperationErrorLogger { + @override + void logError(DisplayableError error) {} +} diff --git a/lib/src/catching_executor.dart b/lib/src/catching_executor.dart index a472495..8c42d7e 100644 --- a/lib/src/catching_executor.dart +++ b/lib/src/catching_executor.dart @@ -13,8 +13,13 @@ import 'package:uuid/uuid.dart'; /// - provides callbacks for loading/success/error/cancel /// - reusable in UI, Services, Repositories, Controllers class CatchingExecutor { + final OperationErrorLogger _errorLogger; + final Map _activeOperations = {}; + CatchingExecutor({required OperationErrorLogger errorLogger}) + : _errorLogger = errorLogger; + Map> get operations => _activeOperations; void cancelOperationBy({required String identifier}) { @@ -74,6 +79,8 @@ class CatchingExecutor { stackTrace: stacktrace, ); + _errorLogger.logError(displayableError); + setState(ResultFailure(displayableError)); } diff --git a/lib/src/operation_error_logger.dart b/lib/src/operation_error_logger.dart new file mode 100644 index 0000000..a9e2eab --- /dev/null +++ b/lib/src/operation_error_logger.dart @@ -0,0 +1,5 @@ +import 'package:tapped_riverpod/src/error/displayble_error.dart'; + +abstract class OperationErrorLogger { + void logError(DisplayableError error); +} diff --git a/lib/tapped_riverpod.dart b/lib/tapped_riverpod.dart index 3e6e055..cfb4092 100644 --- a/lib/tapped_riverpod.dart +++ b/lib/tapped_riverpod.dart @@ -6,6 +6,7 @@ export 'src/base_notifier.dart'; export 'src/result_filter_not_null_notifier.dart'; export 'src/result.dart'; export 'src/catching_executor.dart'; +export 'src/operation_error_logger.dart'; // endregion diff --git a/test/catching_executor_test.dart b/test/catching_executor_test.dart new file mode 100644 index 0000000..0006615 --- /dev/null +++ b/test/catching_executor_test.dart @@ -0,0 +1,35 @@ +import 'package:tapped_riverpod/tapped_riverpod.dart'; +import 'package:test/test.dart'; + +void main() { + test("OperationErrorLogger.logError should be called", () async { + bool errorLogged = false; + + final logger = _CallbackErrorLogger(onLog: (err) => errorLogged = true); + + final executor = CatchingExecutor(errorLogger: logger); + + await executor.execute( + identifier: "my-task", + task: () async { + await Future.delayed(const Duration(milliseconds: 23)); + + throw Exception("Expected error"); + }, + setState: (result) {}, + ); + + expect(errorLogged, true); + }); +} + +class _CallbackErrorLogger extends OperationErrorLogger { + final void Function(DisplayableError error) onLog; + + _CallbackErrorLogger({required this.onLog}); + + @override + void logError(DisplayableError error) { + onLog(error); + } +} From f27c6e02b53d9c8dff3588a3040dc3dc6b308722 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Fri, 12 Dec 2025 17:07:02 +0100 Subject: [PATCH 02/14] add changes --- lib/src/base_notifier.dart | 8 ++++++-- lib/src/catching_executor.dart | 10 +++++++--- lib/src/operation_error_logger.dart | 2 +- test/catching_executor_test.dart | 7 +++++-- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/lib/src/base_notifier.dart b/lib/src/base_notifier.dart index b55ee7a..6465fc4 100644 --- a/lib/src/base_notifier.dart +++ b/lib/src/base_notifier.dart @@ -26,7 +26,11 @@ abstract class BaseNotifier extends Notifier { @mustCallSuper @override T build() { - _catchingExecutor = CatchingExecutor(errorLogger: ref.read(_errorLogger)); + //TODO + _catchingExecutor = CatchingExecutor( + errorLogger: ref.read(_errorLogger), + type: runtimeType, + ); // register cleanup when provider is disposed ref.onDispose(() { @@ -91,5 +95,5 @@ final _errorLogger = Provider((ref) { class _DummyOperationErrorLogger implements OperationErrorLogger { @override - void logError(DisplayableError error) {} + void logError(DisplayableError error, Type runtimeType, String identifier) {} } diff --git a/lib/src/catching_executor.dart b/lib/src/catching_executor.dart index 8c42d7e..d9f22f9 100644 --- a/lib/src/catching_executor.dart +++ b/lib/src/catching_executor.dart @@ -14,11 +14,15 @@ import 'package:uuid/uuid.dart'; /// - reusable in UI, Services, Repositories, Controllers class CatchingExecutor { final OperationErrorLogger _errorLogger; + final Type _type; final Map _activeOperations = {}; - CatchingExecutor({required OperationErrorLogger errorLogger}) - : _errorLogger = errorLogger; + CatchingExecutor({ + required OperationErrorLogger errorLogger, + required Type type, + }) : _errorLogger = errorLogger, + _type = type; Map> get operations => _activeOperations; @@ -79,7 +83,7 @@ class CatchingExecutor { stackTrace: stacktrace, ); - _errorLogger.logError(displayableError); + _errorLogger.logError(displayableError, _type, identifier); setState(ResultFailure(displayableError)); } diff --git a/lib/src/operation_error_logger.dart b/lib/src/operation_error_logger.dart index a9e2eab..434b25a 100644 --- a/lib/src/operation_error_logger.dart +++ b/lib/src/operation_error_logger.dart @@ -1,5 +1,5 @@ import 'package:tapped_riverpod/src/error/displayble_error.dart'; abstract class OperationErrorLogger { - void logError(DisplayableError error); + void logError(DisplayableError error, Type runtimeType, String identifier); } diff --git a/test/catching_executor_test.dart b/test/catching_executor_test.dart index 0006615..5ea04d4 100644 --- a/test/catching_executor_test.dart +++ b/test/catching_executor_test.dart @@ -7,7 +7,8 @@ void main() { final logger = _CallbackErrorLogger(onLog: (err) => errorLogged = true); - final executor = CatchingExecutor(errorLogger: logger); + //TODO + final executor = CatchingExecutor(errorLogger: logger, type:); await executor.execute( identifier: "my-task", @@ -29,7 +30,9 @@ class _CallbackErrorLogger extends OperationErrorLogger { _CallbackErrorLogger({required this.onLog}); @override - void logError(DisplayableError error) { + void logError(DisplayableError error, Type runtimeType, String identifier) { onLog(error); } + + } From 85fca9bc9e56ca9cd7ca5992dd80930ab29c960d Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Sat, 13 Dec 2025 08:47:35 +0100 Subject: [PATCH 03/14] add draft for fix --- lib/src/base_notifier.dart | 55 +++++++++++++++++++++++++++++--- test/catching_executor_test.dart | 8 ++--- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/lib/src/base_notifier.dart b/lib/src/base_notifier.dart index 6465fc4..f567f15 100644 --- a/lib/src/base_notifier.dart +++ b/lib/src/base_notifier.dart @@ -11,6 +11,20 @@ import 'package:tapped_riverpod/tapped_riverpod.dart'; abstract class BaseNotifier extends Notifier { late final CatchingExecutor _catchingExecutor; + /// Whether this notifier has already been initialized. + /// + /// Riverpod may call [build] multiple times during the lifetime when, + /// for example when using [Ref.watch]) changes, then [build] will be re-executed. + /// + /// However, certain setup logic (like creating the [CatchingExecutor] or + /// registering one-time resources) must only run once per notifier instance. + /// + /// [_didBuild] ensures that: + /// - [CatchingExecutor] is created exactly once + /// - [onCreate] is called exactly once + /// - while [init] may still run on every build to create the initial state + bool _didBuild = false; + /// The error logger that is used from [CatchingExecutor]. /// This can be overridden in: /// ProviderScope( @@ -26,11 +40,16 @@ abstract class BaseNotifier extends Notifier { @mustCallSuper @override T build() { - //TODO - _catchingExecutor = CatchingExecutor( - errorLogger: ref.read(_errorLogger), - type: runtimeType, - ); + if (!_didBuild) { + _catchingExecutor = CatchingExecutor( + errorLogger: ref.read(_errorLogger), + type: runtimeType, + ); + + onCreate(); + } + + _didBuild = true; // register cleanup when provider is disposed ref.onDispose(() { @@ -42,9 +61,35 @@ abstract class BaseNotifier extends Notifier { return init(); } + /// Initializes and returns the notifier state. + /// + /// Typical override: + /// ```dart + /// @override + /// MyState init() => MyState(count: 0, result: InitialResult()); + /// ``` + /// + /// This method is usually called once when the provider is first created. + /// However, ⚠️ **Riverpod may call [build] (and therefore [init]) multiple times** + /// during the lifetime of the notifier, for example when: + /// - a dependency used with [Ref.watch] changes + /// - the provider is refreshed or invalidated + /// + /// Because of this, [init] must be **idempotent** and must not contain + /// one-time initialization logic. + /// + /// It is safe to use [ref.watch] and [ref.listen] here, but be aware that + /// changes to watched providers will cause [build] and therefor also [init] to re-run. + /// + /// For one-time setup logic, use [onCreate] instead. + /// See also the documentation of [Notifier.build]. @protected T init(); + // document me + @protected + void onCreate() {} + /// Runs the code in [call]. /// This method returns null if the operation failed. @protected diff --git a/test/catching_executor_test.dart b/test/catching_executor_test.dart index 5ea04d4..e5a9397 100644 --- a/test/catching_executor_test.dart +++ b/test/catching_executor_test.dart @@ -7,8 +7,10 @@ void main() { final logger = _CallbackErrorLogger(onLog: (err) => errorLogged = true); - //TODO - final executor = CatchingExecutor(errorLogger: logger, type:); + final executor = CatchingExecutor( + errorLogger: logger, + type: logger.runtimeType, + ); await executor.execute( identifier: "my-task", @@ -33,6 +35,4 @@ class _CallbackErrorLogger extends OperationErrorLogger { void logError(DisplayableError error, Type runtimeType, String identifier) { onLog(error); } - - } From 03658639c757d9badd012eaf322cdc8ca02ccbcc Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Sat, 13 Dec 2025 17:42:08 +0100 Subject: [PATCH 04/14] restructur tests --- lib/src/base_notifier.dart | 2 +- test/base_notifier_test.dart | 51 -------------------------- test/catching_executor_test.dart | 63 ++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 52 deletions(-) diff --git a/lib/src/base_notifier.dart b/lib/src/base_notifier.dart index f567f15..5ac7a50 100644 --- a/lib/src/base_notifier.dart +++ b/lib/src/base_notifier.dart @@ -73,7 +73,7 @@ abstract class BaseNotifier extends Notifier { /// However, ⚠️ **Riverpod may call [build] (and therefore [init]) multiple times** /// during the lifetime of the notifier, for example when: /// - a dependency used with [Ref.watch] changes - /// - the provider is refreshed or invalidated + /// - the provider is refreshed or invalidated -> todo check that... /// /// Because of this, [init] must be **idempotent** and must not contain /// one-time initialization logic. diff --git a/test/base_notifier_test.dart b/test/base_notifier_test.dart index fa94186..9e57807 100644 --- a/test/base_notifier_test.dart +++ b/test/base_notifier_test.dart @@ -22,57 +22,6 @@ void main() { expect(notifier.operations.isEmpty, true); }); - test("runCatching should cancel the previous actions", () async { - final container = ProviderContainer(); - - final notifier = container.read(_testNotifierProvider.notifier); - - Result actualResult = const ResultInitial(); - - bool initialSuccessCalled = false; - - unawaited( - notifier.runCatching( - () async { - await Future.delayed(const Duration(milliseconds: 500)); - - return 1; - }, - identifier: "test", - setState: (result) { - if (result.isSuccess) { - // ⚠️ This should never be called ! - initialSuccessCalled = true; - } - - actualResult = result; - }, - ), - ); - - await Future.delayed(const Duration(milliseconds: 50)); - - expect(actualResult, const ResultLoading()); - - await notifier.runCatching( - () async { - await Future.delayed(const Duration(milliseconds: 100)); - - return 2; - }, - identifier: "test", - setState: (result) { - actualResult = result; - }, - ); - - await Future.delayed(const Duration(seconds: 1)); - - expect(initialSuccessCalled, false); - - expect(actualResult, const ResultSuccess(2)); - }); - test( "a canceled runCatching should never call setState or onSuccess", () async { diff --git a/test/catching_executor_test.dart b/test/catching_executor_test.dart index e5a9397..4fdff9e 100644 --- a/test/catching_executor_test.dart +++ b/test/catching_executor_test.dart @@ -1,7 +1,61 @@ +import 'dart:async'; + +import 'package:async/async.dart' hide Result; import 'package:tapped_riverpod/tapped_riverpod.dart'; import 'package:test/test.dart'; void main() { + test("runCatching should cancel the previous actions", () async { + final container = ProviderContainer(); + + final notifier = container.read(_testNotifierProvider.notifier); + + Result actualResult = const ResultInitial(); + + bool initialSuccessCalled = false; + + unawaited( + notifier.runCatching( + () async { + await Future.delayed(const Duration(milliseconds: 500)); + + return 1; + }, + identifier: "test", + setState: (result) { + if (result.isSuccess) { + // ⚠️ This should never be called ! + initialSuccessCalled = true; + } + + actualResult = result; + }, + ), + ); + + await Future.delayed(const Duration(milliseconds: 50)); + + expect(actualResult, const ResultLoading()); + + await notifier.runCatching( + () async { + await Future.delayed(const Duration(milliseconds: 100)); + + return 2; + }, + identifier: "test", + setState: (result) { + actualResult = result; + }, + ); + + await Future.delayed(const Duration(seconds: 1)); + + expect(initialSuccessCalled, false); + + expect(actualResult, const ResultSuccess(2)); + }); + test("OperationErrorLogger.logError should be called", () async { bool errorLogged = false; @@ -36,3 +90,12 @@ class _CallbackErrorLogger extends OperationErrorLogger { onLog(error); } } + +final _testNotifierProvider = NotifierProvider<_BaseTestNotifier, String>( + () => _BaseTestNotifier(), +); + +class _BaseTestNotifier extends BaseNotifier { + @override + String init() => ""; +} From 178eee7a76a9685161e64e3a03977b18381efbc9 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Sun, 14 Dec 2025 11:54:09 +0100 Subject: [PATCH 05/14] restructur tests --- lib/src/base_notifier.dart | 11 ++++------- test/base_notifier_test.dart | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/lib/src/base_notifier.dart b/lib/src/base_notifier.dart index 5ac7a50..19c0291 100644 --- a/lib/src/base_notifier.dart +++ b/lib/src/base_notifier.dart @@ -9,6 +9,9 @@ import 'package:tapped_riverpod/tapped_riverpod.dart'; /// - standardized error handling /// - helper for loading/success/failure Result states abstract class BaseNotifier extends Notifier { + + //TODO remove me + @deprecated late final CatchingExecutor _catchingExecutor; /// Whether this notifier has already been initialized. @@ -45,8 +48,6 @@ abstract class BaseNotifier extends Notifier { errorLogger: ref.read(_errorLogger), type: runtimeType, ); - - onCreate(); } _didBuild = true; @@ -73,7 +74,7 @@ abstract class BaseNotifier extends Notifier { /// However, ⚠️ **Riverpod may call [build] (and therefore [init]) multiple times** /// during the lifetime of the notifier, for example when: /// - a dependency used with [Ref.watch] changes - /// - the provider is refreshed or invalidated -> todo check that... + /// - the provider is refreshed or invalidated /// /// Because of this, [init] must be **idempotent** and must not contain /// one-time initialization logic. @@ -86,10 +87,6 @@ abstract class BaseNotifier extends Notifier { @protected T init(); - // document me - @protected - void onCreate() {} - /// Runs the code in [call]. /// This method returns null if the operation failed. @protected diff --git a/test/base_notifier_test.dart b/test/base_notifier_test.dart index 9e57807..37c5482 100644 --- a/test/base_notifier_test.dart +++ b/test/base_notifier_test.dart @@ -6,7 +6,7 @@ import 'package:tapped_riverpod/tapped_riverpod.dart'; import 'package:test/test.dart'; void main() { - test("dispose should cancel all active operations", () async { + test("invalidate/dispose should cancel all active operations", () async { final container = ProviderContainer(); final notifier = container.read(_testNotifierProvider.notifier); @@ -60,6 +60,22 @@ void main() { expect(notifier.operations, >{}); }, ); + + test("invalidate/dispose should cancel all active operations", () async { + final container = ProviderContainer(); + + final notifier = container.read(_testNotifierProvider.notifier); + + notifier.doAsyncOperation(); + + expect(notifier.operations.values.single.isCanceled, false); + + container.invalidate(_testNotifierProvider); + + await Future.delayed(Duration.zero); + + expect(notifier.operations.isEmpty, true); + }); } final _testNotifierProvider = NotifierProvider<_BaseTestNotifier, String>( From 3c6ca6a2bbda8a5dbdf35f61a726e8d67e2349d2 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Mon, 15 Dec 2025 08:03:39 +0100 Subject: [PATCH 06/14] remove catching executor --- lib/src/base_notifier.dart | 140 ++++++++++++++++++++----------- lib/src/catching_executor.dart | 129 ---------------------------- lib/tapped_riverpod.dart | 1 - test/base_notifier_test.dart | 91 +++++++++++++++++++- test/catching_executor_test.dart | 101 ---------------------- 5 files changed, 179 insertions(+), 283 deletions(-) delete mode 100644 lib/src/catching_executor.dart delete mode 100644 test/catching_executor_test.dart diff --git a/lib/src/base_notifier.dart b/lib/src/base_notifier.dart index 19c0291..fc259b7 100644 --- a/lib/src/base_notifier.dart +++ b/lib/src/base_notifier.dart @@ -3,30 +3,14 @@ import 'dart:async'; import 'package:async/async.dart' show CancelableOperation; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:tapped_riverpod/tapped_riverpod.dart'; +import 'package:uuid/uuid.dart'; /// Base class for custom Notifiers that provides: /// - cancelable async operations /// - standardized error handling /// - helper for loading/success/failure Result states abstract class BaseNotifier extends Notifier { - - //TODO remove me - @deprecated - late final CatchingExecutor _catchingExecutor; - - /// Whether this notifier has already been initialized. - /// - /// Riverpod may call [build] multiple times during the lifetime when, - /// for example when using [Ref.watch]) changes, then [build] will be re-executed. - /// - /// However, certain setup logic (like creating the [CatchingExecutor] or - /// registering one-time resources) must only run once per notifier instance. - /// - /// [_didBuild] ensures that: - /// - [CatchingExecutor] is created exactly once - /// - [onCreate] is called exactly once - /// - while [init] may still run on every build to create the initial state - bool _didBuild = false; + // region public /// The error logger that is used from [CatchingExecutor]. /// This can be overridden in: @@ -36,25 +20,18 @@ abstract class BaseNotifier extends Notifier { /// ) static Provider get errorLogger => _errorLogger; - @visibleForTesting - Map> get operations => - _catchingExecutor.operations; + Map> get operations => _activeOperations; + + // endregion + + final Map _activeOperations = {}; @mustCallSuper @override T build() { - if (!_didBuild) { - _catchingExecutor = CatchingExecutor( - errorLogger: ref.read(_errorLogger), - type: runtimeType, - ); - } - - _didBuild = true; - // register cleanup when provider is disposed ref.onDispose(() { - _catchingExecutor.cancelAllOperations(); + cancelAllOperations(); onDispose(); }); @@ -82,7 +59,6 @@ abstract class BaseNotifier extends Notifier { /// It is safe to use [ref.watch] and [ref.listen] here, but be aware that /// changes to watched providers will cause [build] and therefor also [init] to re-run. /// - /// For one-time setup logic, use [onCreate] instead. /// See also the documentation of [Notifier.build]. @protected T init(); @@ -97,38 +73,102 @@ abstract class BaseNotifier extends Notifier { required void Function(Result result) setState, void Function()? onCancel, }) async { - return _catchingExecutor.execute( - identifier: identifier, - task: call, - setState: (result) { - if (!ref.mounted) return; - - setState(result); - }, - onCancel: onCancel, - ); + void setStateWhenMounted(Result result) { + if (!ref.mounted) return; + + setState(result); + } + + final errorLogger = ref.read(BaseNotifier.errorLogger); + + // cancel existing operation with same identifier + unawaited(_activeOperations[identifier]?.cancel()); + + // notify loading + setStateWhenMounted(ResultLoading()); + + R? result; + + try { + final operation = _createOperation( + call(), + onCancel: onCancel, + identifier: identifier, + ); + + // We have this small extra variable, because if directly do: + // result = await call(); + // and we force-unwrap the result (since call returns a NOT null value) + // a null-pointer exception will be thrown in the case that the generic type is "void". + // here is a small description: https://medium.com/flutter-community/the-curious-case-of-void-in-dart-f0535705e529 + final callResult = await operation.value; + + result = callResult; + + setStateWhenMounted(ResultSuccess(callResult)); + } catch (error, stacktrace) { + final displayableError = DisplayableError( + exception: error, + stackTrace: stacktrace, + ); + + errorLogger.logError(displayableError, runtimeType, identifier); + + setStateWhenMounted(ResultFailure(displayableError)); + } + + return result; + } + + /// Cancel and clean up **all** running operations. + /// ⚠️ This need to handled by the creator of the instance + Future cancelAllOperations() async { + final list = List.from(_activeOperations.values); + + _activeOperations.clear(); + + for (final op in list) { + await op.cancel(); + } } - void cancelRunCatchingBy({required String identifier}) { - _catchingExecutor.cancelOperationBy(identifier: identifier); + void cancelOperationBy({required String identifier}) { + _activeOperations.remove(identifier)?.cancel(); } /// Create a [CancelableOperation] that will automatically canceled when [cancelAllOperations] is called. /// [O] represents the generic type of the operation. - @protected CancelableOperation createOperation( Future result, { FutureOr Function()? onCancel, String? overrideIdentifier, }) { - return _catchingExecutor.createOperation( - result, - overrideIdentifier: overrideIdentifier, - onCancel: onCancel, - ); + final identifier = overrideIdentifier ?? const Uuid().v1(); + + return _createOperation(result, identifier: identifier, onCancel: onCancel); } void onDispose() {} + + CancelableOperation _createOperation( + Future result, { + required String identifier, + FutureOr Function()? onCancel, + }) { + final operation = CancelableOperation.fromFuture( + result, + onCancel: () => onCancel?.call(), + ); + + operation.then( + (_) => _activeOperations.remove(identifier), + onCancel: () => _activeOperations.remove(identifier), + onError: (_, _) => _activeOperations.remove(identifier), + ); + + _activeOperations[identifier] = operation; + return operation; + } } final _errorLogger = Provider((ref) { diff --git a/lib/src/catching_executor.dart b/lib/src/catching_executor.dart deleted file mode 100644 index d9f22f9..0000000 --- a/lib/src/catching_executor.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'dart:async'; -import 'package:async/async.dart' show CancelableOperation; -import 'package:tapped_riverpod/tapped_riverpod.dart'; -import 'package:uuid/uuid.dart'; - -/// A standalone utility for executing async tasks with automatic -/// error capturing, cancellation support and lifecycle hooks. -/// -/// Designed to replace runCatching inside Notifiers/Services without -/// depending on Riverpod or any state management. -/// -/// - supports CancelableOperation -/// - provides callbacks for loading/success/error/cancel -/// - reusable in UI, Services, Repositories, Controllers -class CatchingExecutor { - final OperationErrorLogger _errorLogger; - final Type _type; - - final Map _activeOperations = {}; - - CatchingExecutor({ - required OperationErrorLogger errorLogger, - required Type type, - }) : _errorLogger = errorLogger, - _type = type; - - Map> get operations => _activeOperations; - - void cancelOperationBy({required String identifier}) { - _activeOperations.remove(identifier)?.cancel(); - } - - /// Cancel and clean up **all** running operations. - /// ⚠️ This need to handled by the creator of the instance - Future cancelAllOperations() async { - final list = List.from(_activeOperations.values); - - _activeOperations.clear(); - - for (final op in list) { - await op.cancel(); - } - } - - /// Execute an async operation with catching behavior. - /// - /// Returns the result or `null` if failed or canceled. - /// ⚠️ Checking for mounted in [setState] need to happen in the implementation. - Future execute({ - required String identifier, - required Future Function() task, - required void Function(Result result) setState, - void Function()? onCancel, - }) async { - // cancel existing operation with same identifier - unawaited(_activeOperations[identifier]?.cancel()); - - // notify loading - setState(ResultLoading()); - - R? result; - - try { - final operation = _createOperation( - task(), - onCancel: onCancel, - identifier: identifier, - ); - - // We have this small extra variable, because if directly do: - // result = await call(); - // and we force-unwrap the result (since call returns a NOT null value) - // a null-pointer exception will be thrown in the case that the generic type is "void". - // here is a small description: https://medium.com/flutter-community/the-curious-case-of-void-in-dart-f0535705e529 - final callResult = await operation.value; - - result = callResult; - - setState(ResultSuccess(callResult)); - } catch (error, stacktrace) { - final displayableError = DisplayableError( - exception: error, - stackTrace: stacktrace, - ); - - _errorLogger.logError(displayableError, _type, identifier); - - setState(ResultFailure(displayableError)); - } - - return result; - } - - /// Create a [CancelableOperation] that will automatically canceled when [cancelAllOperations] is called. - /// [O] represents the generic type of the operation. - CancelableOperation createOperation( - Future result, { - FutureOr Function()? onCancel, - String? overrideIdentifier, - }) { - final identifier = overrideIdentifier ?? const Uuid().v1(); - - return _createOperation(result, identifier: identifier, onCancel: onCancel); - } - - // region helper - - CancelableOperation _createOperation( - Future result, { - required String identifier, - FutureOr Function()? onCancel, - }) { - final operation = CancelableOperation.fromFuture( - result, - onCancel: () => onCancel?.call(), - ); - - operation.then( - (_) => _activeOperations.remove(identifier), - onCancel: () => _activeOperations.remove(identifier), - onError: (_, _) => _activeOperations.remove(identifier), - ); - - _activeOperations[identifier] = operation; - return operation; - } - - // endregion -} diff --git a/lib/tapped_riverpod.dart b/lib/tapped_riverpod.dart index cfb4092..74f1b03 100644 --- a/lib/tapped_riverpod.dart +++ b/lib/tapped_riverpod.dart @@ -5,7 +5,6 @@ export 'src/error/displayble_error.dart'; export 'src/base_notifier.dart'; export 'src/result_filter_not_null_notifier.dart'; export 'src/result.dart'; -export 'src/catching_executor.dart'; export 'src/operation_error_logger.dart'; // endregion diff --git a/test/base_notifier_test.dart b/test/base_notifier_test.dart index 37c5482..850e2e6 100644 --- a/test/base_notifier_test.dart +++ b/test/base_notifier_test.dart @@ -34,7 +34,6 @@ void main() { unawaited( notifier.runCatching( () async { - print("run catching"); await Future.delayed(const Duration(seconds: 2)); return 1; @@ -52,7 +51,7 @@ void main() { await Future.delayed(const Duration(milliseconds: 50)); - notifier.cancelRunCatchingBy(identifier: "test"); + notifier.cancelOperationBy(identifier: "test"); await Future.delayed(const Duration(milliseconds: 50)); @@ -76,6 +75,83 @@ void main() { expect(notifier.operations.isEmpty, true); }); + + test("runCatching should cancel the previous actions", () async { + final container = ProviderContainer(); + + final notifier = container.read(_testNotifierProvider.notifier); + + Result actualResult = const ResultInitial(); + + bool initialSuccessCalled = false; + + unawaited( + notifier.runCatching( + () async { + await Future.delayed(const Duration(milliseconds: 500)); + + return 1; + }, + identifier: "test", + setState: (result) { + if (result.isSuccess) { + // ⚠️ This should never be called ! + initialSuccessCalled = true; + } + + actualResult = result; + }, + ), + ); + + await Future.delayed(const Duration(milliseconds: 50)); + + expect(actualResult, const ResultLoading()); + + await notifier.runCatching( + () async { + await Future.delayed(const Duration(milliseconds: 100)); + + return 2; + }, + identifier: "test", + setState: (result) { + actualResult = result; + }, + ); + + await Future.delayed(const Duration(seconds: 1)); + + expect(initialSuccessCalled, false); + + expect(actualResult, const ResultSuccess(2)); + }); + + test("OperationErrorLogger.logError should be called", () async { + bool errorLogged = false; + + final container = ProviderContainer( + overrides: [ + BaseNotifier.errorLogger.overrideWithValue( + _CallbackErrorLogger(onLog: (err) => errorLogged = true), + ), + ], + ); + + final notifier = container.read(_testNotifierProvider.notifier); + + await notifier.runCatching( + () async { + await Future.delayed(const Duration(milliseconds: 23)); + + throw Exception("Expected error"); + }, + setState: (result) {}, + identifier: "my-task", + ); + + expect(errorLogged, true); + }); } final _testNotifierProvider = NotifierProvider<_BaseTestNotifier, String>( @@ -92,3 +168,14 @@ class _BaseTestNotifier extends BaseNotifier { asyncOperation = createOperation(Completer().future); } } + +class _CallbackErrorLogger extends OperationErrorLogger { + final void Function(DisplayableError error) onLog; + + _CallbackErrorLogger({required this.onLog}); + + @override + void logError(DisplayableError error, Type runtimeType, String identifier) { + onLog(error); + } +} diff --git a/test/catching_executor_test.dart b/test/catching_executor_test.dart deleted file mode 100644 index 4fdff9e..0000000 --- a/test/catching_executor_test.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'dart:async'; - -import 'package:async/async.dart' hide Result; -import 'package:tapped_riverpod/tapped_riverpod.dart'; -import 'package:test/test.dart'; - -void main() { - test("runCatching should cancel the previous actions", () async { - final container = ProviderContainer(); - - final notifier = container.read(_testNotifierProvider.notifier); - - Result actualResult = const ResultInitial(); - - bool initialSuccessCalled = false; - - unawaited( - notifier.runCatching( - () async { - await Future.delayed(const Duration(milliseconds: 500)); - - return 1; - }, - identifier: "test", - setState: (result) { - if (result.isSuccess) { - // ⚠️ This should never be called ! - initialSuccessCalled = true; - } - - actualResult = result; - }, - ), - ); - - await Future.delayed(const Duration(milliseconds: 50)); - - expect(actualResult, const ResultLoading()); - - await notifier.runCatching( - () async { - await Future.delayed(const Duration(milliseconds: 100)); - - return 2; - }, - identifier: "test", - setState: (result) { - actualResult = result; - }, - ); - - await Future.delayed(const Duration(seconds: 1)); - - expect(initialSuccessCalled, false); - - expect(actualResult, const ResultSuccess(2)); - }); - - test("OperationErrorLogger.logError should be called", () async { - bool errorLogged = false; - - final logger = _CallbackErrorLogger(onLog: (err) => errorLogged = true); - - final executor = CatchingExecutor( - errorLogger: logger, - type: logger.runtimeType, - ); - - await executor.execute( - identifier: "my-task", - task: () async { - await Future.delayed(const Duration(milliseconds: 23)); - - throw Exception("Expected error"); - }, - setState: (result) {}, - ); - - expect(errorLogged, true); - }); -} - -class _CallbackErrorLogger extends OperationErrorLogger { - final void Function(DisplayableError error) onLog; - - _CallbackErrorLogger({required this.onLog}); - - @override - void logError(DisplayableError error, Type runtimeType, String identifier) { - onLog(error); - } -} - -final _testNotifierProvider = NotifierProvider<_BaseTestNotifier, String>( - () => _BaseTestNotifier(), -); - -class _BaseTestNotifier extends BaseNotifier { - @override - String init() => ""; -} From f4d14ed3d6183ddb0721ffa3f76e5a7e29f4ce81 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Mon, 15 Dec 2025 08:07:57 +0100 Subject: [PATCH 07/14] add more tests --- test/base_notifier_test.dart | 72 ++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/test/base_notifier_test.dart b/test/base_notifier_test.dart index 850e2e6..5b6acf5 100644 --- a/test/base_notifier_test.dart +++ b/test/base_notifier_test.dart @@ -127,31 +127,63 @@ void main() { expect(actualResult, const ResultSuccess(2)); }); - test("OperationErrorLogger.logError should be called", () async { - bool errorLogged = false; + test( + "OperationErrorLogger.logError should be called when error occur", + () async { + bool errorLogged = false; + + final container = ProviderContainer( + overrides: [ + BaseNotifier.errorLogger.overrideWithValue( + _CallbackErrorLogger(onLog: (err) => errorLogged = true), + ), + ], + ); - final container = ProviderContainer( - overrides: [ - BaseNotifier.errorLogger.overrideWithValue( - _CallbackErrorLogger(onLog: (err) => errorLogged = true), - ), - ], - ); + final notifier = container.read(_testNotifierProvider.notifier); - final notifier = container.read(_testNotifierProvider.notifier); + await notifier.runCatching( + () async { + await Future.delayed(const Duration(milliseconds: 23)); - await notifier.runCatching( - () async { - await Future.delayed(const Duration(milliseconds: 23)); + throw Exception("Expected error"); + }, + setState: (result) {}, + identifier: "my-task", + ); - throw Exception("Expected error"); - }, - setState: (result) {}, - identifier: "my-task", - ); + expect(errorLogged, true); + }, + ); - expect(errorLogged, true); - }); + test( + "OperationErrorLogger.logError should not be called when no error occur", + () async { + bool errorLogged = false; + + final container = ProviderContainer( + overrides: [ + BaseNotifier.errorLogger.overrideWithValue( + _CallbackErrorLogger(onLog: (err) => errorLogged = true), + ), + ], + ); + + final notifier = container.read(_testNotifierProvider.notifier); + + await notifier.runCatching( + () async { + await Future.delayed(const Duration(milliseconds: 23)); + + return 0; + }, + setState: (result) {}, + identifier: "my-task", + ); + + expect(errorLogged, false); + }, + ); } final _testNotifierProvider = NotifierProvider<_BaseTestNotifier, String>( From e6ecdd7c2ab28838048735d0e490a2cf63e38fe2 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Mon, 15 Dec 2025 08:17:53 +0100 Subject: [PATCH 08/14] add lifecycle test --- test/base_notifier_test.dart | 1 - test/lifecycle_base_notifier_test.dart | 66 ++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 test/lifecycle_base_notifier_test.dart diff --git a/test/base_notifier_test.dart b/test/base_notifier_test.dart index 5b6acf5..d162990 100644 --- a/test/base_notifier_test.dart +++ b/test/base_notifier_test.dart @@ -1,7 +1,6 @@ import 'dart:async' show Completer, unawaited; import 'package:async/async.dart' show CancelableOperation; -import 'package:riverpod/riverpod.dart'; import 'package:tapped_riverpod/tapped_riverpod.dart'; import 'package:test/test.dart'; diff --git a/test/lifecycle_base_notifier_test.dart b/test/lifecycle_base_notifier_test.dart new file mode 100644 index 0000000..4cd8641 --- /dev/null +++ b/test/lifecycle_base_notifier_test.dart @@ -0,0 +1,66 @@ +import 'package:tapped_riverpod/tapped_riverpod.dart'; +import 'package:test/test.dart'; + +final lifeCycle = []; + +void main() { + setUp(() => lifeCycle.clear()); + + test("the lifecycle of the provider should be correct", () async { + final container = ProviderContainer(); + + expect(lifeCycle, []); + + final notifier = container.read(_testNotifierProvider.notifier); + + expect(lifeCycle, ["build", "init"]); + + await notifier.runCatching( + () async { + await Future.delayed(const Duration(milliseconds: 10)); + + return 1; + }, + identifier: "test", + setState: (result) {}, + ); + + expect(lifeCycle, ["build", "init"]); + + container.invalidate(_testNotifierProvider); + + expect(lifeCycle, ["build", "init", "onDispose"]); + + // should recreate the provider + container.read(_testNotifierProvider.notifier); + + expect(lifeCycle, ["build", "init", "onDispose", "build", "init"]); + }); +} + +final _testNotifierProvider = NotifierProvider<_BaseTestNotifier, String>( + () => _BaseTestNotifier(), +); + +class _BaseTestNotifier extends BaseNotifier { + @override + String build() { + lifeCycle.add("build"); + + return super.build(); + } + + @override + String init() { + lifeCycle.add("init"); + + return ""; + } + + @override + void onDispose() { + lifeCycle.add("onDispose"); + + super.onDispose(); + } +} From 17323a088ddbe692ce76ab8ef8d842b93bc78e48 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Mon, 15 Dec 2025 08:21:09 +0100 Subject: [PATCH 09/14] add lifecycle test --- lib/src/base_notifier.dart | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/lib/src/base_notifier.dart b/lib/src/base_notifier.dart index fc259b7..fca4233 100644 --- a/lib/src/base_notifier.dart +++ b/lib/src/base_notifier.dart @@ -5,10 +5,30 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:tapped_riverpod/tapped_riverpod.dart'; import 'package:uuid/uuid.dart'; -/// Base class for custom Notifiers that provides: -/// - cancelable async operations -/// - standardized error handling -/// - helper for loading/success/failure Result states +/// Base class for custom Riverpod [Notifier]s with built-in support for +/// cancelable async operations, standardized error handling and +/// convenient helpers for loading/success/failure result states. +/// +/// ## What this class provides +/// - Management of **cancelable async operations** via [CancelableOperation] +/// - A unified `runCatching` helper for: +/// - loading / success / failure state transitions +/// - automatic cancellation of previous runs +/// - centralized error logging +/// - Automatic cleanup of all running operations when the provider is disposed +/// +/// +/// ** 🚨🚨🚨 ** +/// Even though [onDispose] is called when the provider is invalidated, +/// the *Notifier instance itself is reused* by Riverpod. +/// This means: +/// +/// - Member variables **are not reset automatically** +/// - Any state stored in fields will persist across rebuilds +/// +/// 👉 **Avoid keeping mutable state in member variables.** +/// If you must store state in fields, ensure it is fully cleaned up in +/// [onDispose]. abstract class BaseNotifier extends Notifier { // region public From 62aabd4138b9a0b4be06c07408eba5697430e0d6 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Mon, 15 Dec 2025 08:30:58 +0100 Subject: [PATCH 10/14] improve error --- test/base_notifier_test.dart | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/test/base_notifier_test.dart b/test/base_notifier_test.dart index d162990..46209da 100644 --- a/test/base_notifier_test.dart +++ b/test/base_notifier_test.dart @@ -129,12 +129,20 @@ void main() { test( "OperationErrorLogger.logError should be called when error occur", () async { - bool errorLogged = false; + String? emittedError; + Type? notifierType; + String? requestId; final container = ProviderContainer( overrides: [ BaseNotifier.errorLogger.overrideWithValue( - _CallbackErrorLogger(onLog: (err) => errorLogged = true), + _CallbackErrorLogger( + onLog: (err, type, id) { + emittedError = err.exception.toString(); + notifierType = type; + requestId = id; + }, + ), ), ], ); @@ -151,19 +159,23 @@ void main() { identifier: "my-task", ); - expect(errorLogged, true); + expect(emittedError, isNotEmpty); + expect(notifierType, _BaseTestNotifier().runtimeType); + expect(requestId, "my-task"); }, ); test( "OperationErrorLogger.logError should not be called when no error occur", () async { - bool errorLogged = false; + String? emittedError; final container = ProviderContainer( overrides: [ BaseNotifier.errorLogger.overrideWithValue( - _CallbackErrorLogger(onLog: (err) => errorLogged = true), + _CallbackErrorLogger( + onLog: (err, _, _) => emittedError = err.exception.toString(), + ), ), ], ); @@ -180,7 +192,7 @@ void main() { identifier: "my-task", ); - expect(errorLogged, false); + expect(emittedError, isNull); }, ); } @@ -201,12 +213,17 @@ class _BaseTestNotifier extends BaseNotifier { } class _CallbackErrorLogger extends OperationErrorLogger { - final void Function(DisplayableError error) onLog; + final void Function( + DisplayableError error, + Type runtimeType, + String identifier, + ) + onLog; _CallbackErrorLogger({required this.onLog}); @override void logError(DisplayableError error, Type runtimeType, String identifier) { - onLog(error); + onLog(error, runtimeType, identifier); } } From 4cf2c4f5113b738f29f0b3b5eb07a76e66ded196 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Mon, 15 Dec 2025 08:34:30 +0100 Subject: [PATCH 11/14] improve error --- lib/src/result.dart | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/src/result.dart b/lib/src/result.dart index 04190bd..3b66ce6 100644 --- a/lib/src/result.dart +++ b/lib/src/result.dart @@ -27,15 +27,19 @@ sealed class Result with _$Result { bool get isDone => isSuccess || isFailure; ResultFailure? asFailureOrNull() { - if (isFailure) { - return this as ResultFailure; + final result = this; + + if (result is ResultFailure) { + return result; } return null; } ResultSuccess? asSuccessOrNull() { - if (isSuccess) { - return this as ResultSuccess; + final result = this; + + if (result is ResultSuccess) { + return result; } return null; } From 719b07550e51a79a0f62fd272744ef9e69c0409b Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Mon, 15 Dec 2025 08:40:36 +0100 Subject: [PATCH 12/14] remove docu --- lib/src/base_notifier.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/base_notifier.dart b/lib/src/base_notifier.dart index fca4233..4b3cb45 100644 --- a/lib/src/base_notifier.dart +++ b/lib/src/base_notifier.dart @@ -32,7 +32,6 @@ import 'package:uuid/uuid.dart'; abstract class BaseNotifier extends Notifier { // region public - /// The error logger that is used from [CatchingExecutor]. /// This can be overridden in: /// ProviderScope( /// overrides: BaseNotifier.errorLogger.overrideWithValue(myNewLogger), From 129724319fa7232bb15afae1f3a8962007d66f12 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Mon, 15 Dec 2025 08:42:33 +0100 Subject: [PATCH 13/14] add more stuff to the test --- test/lifecycle_base_notifier_test.dart | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/lifecycle_base_notifier_test.dart b/test/lifecycle_base_notifier_test.dart index 4cd8641..f984f91 100644 --- a/test/lifecycle_base_notifier_test.dart +++ b/test/lifecycle_base_notifier_test.dart @@ -35,6 +35,19 @@ void main() { container.read(_testNotifierProvider.notifier); expect(lifeCycle, ["build", "init", "onDispose", "build", "init"]); + + container.refresh(_testNotifierProvider); + + expect(lifeCycle, [ + "build", + "init", + "onDispose", + "build", + "init", + 'onDispose', + 'build', + 'init', + ]); }); } From 2b68e61afbd8c9b796ec0f4cad54220927da802f Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Mon, 15 Dec 2025 08:43:17 +0100 Subject: [PATCH 14/14] cleanup naming --- lib/src/base_notifier.dart | 2 +- lib/src/operation_error_logger.dart | 2 +- test/base_notifier_test.dart | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/src/base_notifier.dart b/lib/src/base_notifier.dart index 4b3cb45..0c58883 100644 --- a/lib/src/base_notifier.dart +++ b/lib/src/base_notifier.dart @@ -196,5 +196,5 @@ final _errorLogger = Provider((ref) { class _DummyOperationErrorLogger implements OperationErrorLogger { @override - void logError(DisplayableError error, Type runtimeType, String identifier) {} + void logError(DisplayableError error, Type providerType, String identifier) {} } diff --git a/lib/src/operation_error_logger.dart b/lib/src/operation_error_logger.dart index 434b25a..d2b93f0 100644 --- a/lib/src/operation_error_logger.dart +++ b/lib/src/operation_error_logger.dart @@ -1,5 +1,5 @@ import 'package:tapped_riverpod/src/error/displayble_error.dart'; abstract class OperationErrorLogger { - void logError(DisplayableError error, Type runtimeType, String identifier); + void logError(DisplayableError error, Type providerType, String identifier); } diff --git a/test/base_notifier_test.dart b/test/base_notifier_test.dart index 46209da..c94ccfa 100644 --- a/test/base_notifier_test.dart +++ b/test/base_notifier_test.dart @@ -215,7 +215,7 @@ class _BaseTestNotifier extends BaseNotifier { class _CallbackErrorLogger extends OperationErrorLogger { final void Function( DisplayableError error, - Type runtimeType, + Type providerType, String identifier, ) onLog; @@ -223,7 +223,7 @@ class _CallbackErrorLogger extends OperationErrorLogger { _CallbackErrorLogger({required this.onLog}); @override - void logError(DisplayableError error, Type runtimeType, String identifier) { - onLog(error, runtimeType, identifier); + void logError(DisplayableError error, Type providerType, String identifier) { + onLog(error, providerType, identifier); } }