diff --git a/CHANGELOG.md b/CHANGELOG.md index d67c1f0..0628aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog + +## 2.11.0 +Create ActionV2 class with non-nullable payloads in preparation for null-safety. + ## 2.10.15 - Dependency upgrades diff --git a/lib/src/action.dart b/lib/src/action.dart index eb78b46..6c4ccef 100644 --- a/lib/src/action.dart +++ b/lib/src/action.dart @@ -20,11 +20,22 @@ import 'package:w_common/disposable.dart'; import 'package:w_flux/src/constants.dart' show v3Deprecation; +/// Like [ActionV2], but payloads cannot be made non-nullable since the argument +/// to [call] is optional. +@Deprecated('Use ActionV2 instead, which supports non-nullable payloads.') +class Action extends ActionV2 { + @override + String get disposableTypeName => 'Action'; + + @override + Future call([T payload]) => super.call(payload); +} + /// A command that can be dispatched and listened to. /// -/// An [Action] manages a collection of listeners and the manner of +/// An [ActionV2] manages a collection of listeners and the manner of /// their invocation. It *does not* rely on [Stream] for managing listeners. By -/// managing its own listeners, an [Action] can track a [Future] that completes +/// managing its own listeners, an [ActionV2] can track a [Future] that completes /// when all registered listeners have completed. This allows consumers to use /// `await` to wait for an action to finish processing. /// @@ -45,15 +56,15 @@ import 'package:w_flux/src/constants.dart' show v3Deprecation; /// when a consumer needs to check state changes immediately after invoking an /// action. /// -class Action extends Object with Disposable implements Function { +class ActionV2 extends Object with Disposable implements Function { @override - String get disposableTypeName => 'Action'; + String get disposableTypeName => 'ActionV2'; - List _listeners = []; + List<_ActionListener> _listeners = []; - /// Dispatch this [Action] to all listeners. If a payload is supplied, it will - /// be passed to each listener's callback, otherwise null will be passed. - Future call([T payload]) { + /// Dispatch this [ActionV2] to all listeners. The payload will be passed to + /// each listener's callback. + Future call(T payload) { // Invoke all listeners in a microtask to enable waiting on futures. The // microtask queue is emptied before the event loop continues. This ensures // synchronous listeners are invoked in the current tick of the event loop @@ -65,11 +76,12 @@ class Action extends Object with Disposable implements Function { // a [Stream]-based action implementation. At smaller sample sizes this // implementation slows down in comparison, yielding average times of 0.1 ms // for stream-based actions vs. 0.14 ms for this action implementation. - Future callListenerInMicrotask(l) => Future.microtask(() => l(payload)); + Future callListenerInMicrotask(_ActionListener l) => + Future.microtask(() => l(payload)); return Future.wait(_listeners.map(callListenerInMicrotask)); } - /// Cancel all subscriptions that exist on this [Action] as a result of + /// Cancel all subscriptions that exist on this [ActionV2] as a result of /// [listen] being called. Useful when tearing down a flux cycle in some /// module or unit test. @Deprecated('Use (and await) dispose() instead. $v3Deprecation') @@ -77,11 +89,11 @@ class Action extends Object with Disposable implements Function { _listeners.clear(); } - /// Supply a callback that will be called any time this [Action] is + /// Supply a callback that will be called any time this [ActionV2] is /// dispatched. A payload of type [T] will be passed to the callback if /// supplied at dispatch time, otherwise null will be passed. Returns an /// [ActionSubscription] which provides means to cancel the subscription. - ActionSubscription listen(dynamic onData(T event)) { + ActionSubscription listen(dynamic Function(T event) onData) { _listeners.add(onData); return ActionSubscription(() => _listeners.remove(onData)); } @@ -97,13 +109,15 @@ class Action extends Object with Disposable implements Function { } } -/// A subscription used to cancel registered listeners to an [Action]. +typedef _ActionListener = dynamic Function(T event); + +/// A subscription used to cancel registered listeners to an [ActionV2]. class ActionSubscription { Function _onCancel; ActionSubscription(this._onCancel); - /// Cancel this subscription to an [Action] + /// Cancel this subscription to an [ActionV2] void cancel() { if (_onCancel != null) { _onCancel(); diff --git a/lib/src/store.dart b/lib/src/store.dart index 9972466..de5d3c0 100644 --- a/lib/src/store.dart +++ b/lib/src/store.dart @@ -29,7 +29,7 @@ typedef StoreHandler = Function(Store event); /// /// General guidelines with respect to a `Store`'s data: /// - A `Store`'s data should not be exposed for direct mutation. -/// - A `Store`'s data should be mutated internally in response to [Action]s. +/// - A `Store`'s data should be mutated internally in response to [ActionV2]s. /// - A `Store` should expose relevant data ONLY via public getters. /// /// To receive notifications of a `Store`'s data mutations, `Store`s can be @@ -141,7 +141,7 @@ class Store extends Stream with Disposable { /// Deprecated: 2.9.5 /// To be removed: 3.0.0 @deprecated - triggerOnAction(Action action, [void onAction(payload)]) { + triggerOnAction(ActionV2 action, [void onAction(payload)]) { triggerOnActionV2(action, onAction); } @@ -153,7 +153,7 @@ class Store extends Stream with Disposable { /// called until that future has resolved. /// /// If the `Store` has been disposed, this method throws a [StateError]. - void triggerOnActionV2(Action action, + void triggerOnActionV2(ActionV2 action, [FutureOr onAction(T payload)]) { if (isOrWillBeDisposed) { throw StateError('Store of type $runtimeType has been disposed'); diff --git a/test/action_test.dart b/test/action_test.dart index 4d1d713..7ba2bf3 100644 --- a/test/action_test.dart +++ b/test/action_test.dart @@ -26,62 +26,46 @@ void main() { setUp(() { action = Action(); + addTearDown(action.dispose); }); test('should only be equivalent to itself', () { - Action _action = Action(); - Action _action2 = Action(); - expect(_action == _action, isTrue); - expect(_action == _action2, isFalse); + Action action = Action(); + Action actionV2 = Action(); + expect(action == action, isTrue); + expect(action == actionV2, isFalse); }); test('should support dispatch without a payload', () async { - Completer c = Completer(); - Action _action = Action(); + Action action = Action() + ..listen(expectAsync1((payload) { + expect(payload, isNull); + })); - _action.listen((String payload) { - expect(payload, equals(null)); - c.complete(); - }); - - _action(); - return c.future; + await action(); }); - test('should support dispatch with a payload', () async { - Completer c = Completer(); - action.listen((String payload) { + test('should support dispatch by default when called with a payload', + () async { + action.listen(expectAsync1((String payload) { expect(payload, equals('990 guerrero')); - c.complete(); - }); + })); - action('990 guerrero'); - return c.future; - }); - - test('should dispatch by default when called', () async { - Completer c = Completer(); - action.listen((String payload) { - expect(payload, equals('990 guerrero')); - c.complete(); - }); - - action('990 guerrero'); - return c.future; + await action('990 guerrero'); }); group('dispatch', () { test( 'should invoke and complete synchronous listeners in future event in ' 'event queue', () async { - var action = Action(); var listenerCompleted = false; - action.listen((_) { - listenerCompleted = true; - }); + var action = Action() + ..listen((_) { + listenerCompleted = true; + }); // No immediate invocation. - action(); + unawaited(action(null)); expect(listenerCompleted, isFalse); // Invoked during the next scheduled event in the queue. @@ -101,7 +85,7 @@ void main() { }); // No immediate invocation. - action(); + unawaited(action(null)); expect(listenerInvoked, isFalse); // Invoked during next scheduled event in the queue. @@ -131,8 +115,7 @@ void main() { }); test('should surface errors in listeners', () { - var action = Action(); - action.listen((_) => throw UnimplementedError()); + var action = Action()..listen((_) => throw UnimplementedError()); expect(action(0), throwsUnimplementedError); }); }); @@ -186,9 +169,9 @@ void main() { const int sampleSize = 1000; var stopwatch = Stopwatch(); - var awaitableAction = Action(); - awaitableAction.listen((_) => {}); - awaitableAction.listen((_) async {}); + var awaitableAction = Action() + ..listen((_) => {}) + ..listen((_) async {}); stopwatch.start(); for (var i = 0; i < sampleSize; i++) { await awaitableAction(); @@ -201,16 +184,186 @@ void main() { Completer syncCompleter; Completer asyncCompleter; - var action = Action(); - action.listen((_) => syncCompleter.complete()); + var action = Action() + ..listen((_) => syncCompleter.complete()) + ..listen((_) async { + asyncCompleter.complete(); + }); + stopwatch.start(); + for (var i = 0; i < sampleSize; i++) { + syncCompleter = Completer(); + asyncCompleter = Completer(); + await action(); + await Future.wait([syncCompleter.future, asyncCompleter.future]); + } + stopwatch.stop(); + var averageStreamDispatchTime = + stopwatch.elapsedMicroseconds / sampleSize / 1000.0; + + print('awaitable action (ms): $averageActionDispatchTime; ' + 'stream-based action (ms): $averageStreamDispatchTime'); + }, skip: true); + }); + }); + + group('ActionV2', () { + ActionV2 action; + + setUp(() { + action = ActionV2(); + addTearDown(action.dispose); + }); + + test('should only be equivalent to itself', () { + ActionV2 action = ActionV2(); + ActionV2 actionV2 = ActionV2(); + expect(action == action, isTrue); + expect(action == actionV2, isFalse); + }); + + test('should support dispatch by default when called with a payload', + () async { + action.listen(expectAsync1((payload) { + expect(payload, equals('990 guerrero')); + })); + + await action('990 guerrero'); + }); + + group('dispatch', () { + test( + 'should invoke and complete synchronous listeners in future event in ' + 'event queue', () async { + var listenerCompleted = false; + action.listen((_) { + listenerCompleted = true; + }); + + // No immediate invocation. + unawaited(action('payload')); + expect(listenerCompleted, isFalse); + + // Invoked during the next scheduled event in the queue. + await Future(() => {}); + expect(listenerCompleted, isTrue); + }); + + test( + 'should invoke asynchronous listeners in future event and complete ' + 'in another future event', () async { + var listenerInvoked = false; + var listenerCompleted = false; + action.listen((_) async { + listenerInvoked = true; + await Future(() => listenerCompleted = true); + }); + + // No immediate invocation. + unawaited(action('payload')); + expect(listenerInvoked, isFalse); + + // Invoked during next scheduled event in the queue. + await Future(() => {}); + expect(listenerInvoked, isTrue); + expect(listenerCompleted, isFalse); + + // Completed in next next scheduled event. + await Future(() => {}); + expect(listenerCompleted, isTrue); + }); + + test('should complete future after listeners complete', () async { + var asyncListenerCompleted = false; action.listen((_) async { - asyncCompleter.complete(); + await Future.delayed(const Duration(milliseconds: 100), () { + asyncListenerCompleted = true; + }); }); + + Future future = action('payload'); + expect(asyncListenerCompleted, isFalse); + + await future; + expect(asyncListenerCompleted, isTrue); + }); + + test('should surface errors in listeners', () { + action.listen((_) => throw UnimplementedError()); + expect(action('payload'), throwsUnimplementedError); + }); + }); + + group('listen', () { + test('should stop listening when subscription is canceled', () async { + var listened = false; + var subscription = action.listen((_) => listened = true); + + await action('payload'); + expect(listened, isTrue); + + listened = false; + subscription.cancel(); + await action('payload'); + expect(listened, isFalse); + }); + + test('should stop listening when listeners are cleared', () async { + var listened = false; + action.listen((_) => listened = true); + + await action('payload'); + expect(listened, isTrue); + + listened = false; + await action.dispose(); + await action('payload'); + expect(listened, isFalse); + }); + + test('should stop listening when actions are disposed', () async { + var listened = false; + action.listen((_) => listened = true); + + await action('payload'); + expect(listened, isTrue); + + listened = false; + await action.dispose(); + await action('payload'); + expect(listened, isFalse); + }); + }); + + group('benchmarks', () { + test('should dispatch actions faster than streams :(', () async { + const int sampleSize = 1000; + var stopwatch = Stopwatch(); + + var awaitableAction = ActionV2() + ..listen((_) => {}) + ..listen((_) async {}); + stopwatch.start(); + for (var i = 0; i < sampleSize; i++) { + await awaitableAction(null); + } + stopwatch.stop(); + var averageActionDispatchTime = + stopwatch.elapsedMicroseconds / sampleSize / 1000.0; + + stopwatch.reset(); + + Completer syncCompleter; + Completer asyncCompleter; + var action = ActionV2() + ..listen((_) => syncCompleter.complete()) + ..listen((_) async { + asyncCompleter.complete(); + }); stopwatch.start(); for (var i = 0; i < sampleSize; i++) { syncCompleter = Completer(); asyncCompleter = Completer(); - action(); + await action(null); await Future.wait([syncCompleter.future, asyncCompleter.future]); } stopwatch.stop(); @@ -222,4 +375,36 @@ void main() { }, skip: true); }); }); + + group('Null typed', () { + ActionV2 nullAction; + + setUp(() { + nullAction = ActionV2(); + addTearDown(nullAction.dispose); + }); + + test('should support dispatch with a null payload', () async { + nullAction.listen(expectAsync1((payload) { + expect(payload, isNull); + })); + + await nullAction(null); + }); + }); + + group('void typed', () { + ActionV2 voidAction; + + setUp(() { + voidAction = ActionV2(); + addTearDown(voidAction.dispose); + }); + + test('should support dispatch with a null payload', () async { + voidAction.listen(expectAsync1((_) {})); + + await voidAction(null); + }); + }); }