diff --git a/mobx/CHANGELOG.md b/mobx/CHANGELOG.md index 78be08618..5d8940a71 100644 --- a/mobx/CHANGELOG.md +++ b/mobx/CHANGELOG.md @@ -1,3 +1,10 @@ +## 2.1.4 + +- Allow users to bypass observability system for performance by [@fzyzcjy](https://github.com/fzyzcjy) in [#844](https://github.com/mobxjs/mobx.dart/pull/844) +- Avoid unnecessary observable notifications of @observable fields of Stores by [@fzyzcjy](https://github.com/fzyzcjy) in [#844](https://github.com/mobxjs/mobx.dart/pull/844) +- Fix Reaction lacks toString, so cannot see which reaction causes the error by [@fzyzcjy](https://github.com/fzyzcjy) in [#844](https://github.com/mobxjs/mobx.dart/pull/844) +- Add StackTrace to reactions in debug mode to easily spot which reaction it is by [@fzyzcjy](https://github.com/fzyzcjy) in [#844](https://github.com/mobxjs/mobx.dart/pull/844) + ## 2.1.2 - 2.1.3+1 - Fix tests in dart 2.19 - [@amondnet](https://github.com/amondnet) diff --git a/mobx/lib/src/api/observable_collections/observable_list.dart b/mobx/lib/src/api/observable_collections/observable_list.dart index 692bc1bc7..1175127c4 100644 --- a/mobx/lib/src/api/observable_collections/observable_list.dart +++ b/mobx/lib/src/api/observable_collections/observable_list.dart @@ -40,6 +40,8 @@ class ObservableList final Atom _atom; final List _list; + List get nonObservableInner => _list; + Listeners>? _listenersField; Listeners> get _listeners => diff --git a/mobx/lib/src/api/observable_collections/observable_map.dart b/mobx/lib/src/api/observable_collections/observable_map.dart index 842e9fff6..be1cd7d4d 100644 --- a/mobx/lib/src/api/observable_collections/observable_map.dart +++ b/mobx/lib/src/api/observable_collections/observable_map.dart @@ -58,6 +58,8 @@ class ObservableMap final Atom _atom; final Map _map; + Map get nonObservableInner => _map; + String get name => _atom.name; Listeners>? _listenersField; diff --git a/mobx/lib/src/api/observable_collections/observable_set.dart b/mobx/lib/src/api/observable_collections/observable_set.dart index d9b561000..65cc56e86 100644 --- a/mobx/lib/src/api/observable_collections/observable_set.dart +++ b/mobx/lib/src/api/observable_collections/observable_set.dart @@ -46,6 +46,8 @@ class ObservableSet final Atom _atom; final Set _set; + Set get nonObservableInner => _set; + String get name => _atom.name; Listeners>? _listenersField; diff --git a/mobx/lib/src/core/action.dart b/mobx/lib/src/core/action.dart index d01cae8d4..63789d96b 100644 --- a/mobx/lib/src/core/action.dart +++ b/mobx/lib/src/core/action.dart @@ -1,6 +1,6 @@ part of '../core.dart'; -class Action { +class Action with DebugCreationStack { /// Creates an action that encapsulates all the mutations happening on the /// observables. /// @@ -61,6 +61,10 @@ class Action { _controller.endAction(runInfo); } } + + @override + String toString() => + 'Action(name: $name, identity: ${identityHashCode(this)})'; } /// `ActionController` is used to define the start/end boundaries of code which diff --git a/mobx/lib/src/core/atom.dart b/mobx/lib/src/core/atom.dart index 56005c7d3..8d92b01a6 100644 --- a/mobx/lib/src/core/atom.dart +++ b/mobx/lib/src/core/atom.dart @@ -5,7 +5,7 @@ enum _ListenerKind { onBecomeUnobserved, } -class Atom { +class Atom with DebugCreationStack { /// Creates a simple Atom for tracking its usage in a reactive context. This is useful when /// you don't need the value but instead a way of knowing when it becomes active and inactive /// in a reaction. @@ -42,6 +42,8 @@ class Atom { DerivationState _lowestObserverState = DerivationState.notTracking; + bool get isBeingObserved => _isBeingObserved; + // ignore: prefer_final_fields bool _isBeingObserved = false; @@ -110,6 +112,9 @@ class Atom { } }; } + + @override + String toString() => 'Atom(name: $name, identity: ${identityHashCode(this)})'; } class WillChangeNotification { diff --git a/mobx/lib/src/core/atom_extensions.dart b/mobx/lib/src/core/atom_extensions.dart index ed2a117de..d0b3498ad 100644 --- a/mobx/lib/src/core/atom_extensions.dart +++ b/mobx/lib/src/core/atom_extensions.dart @@ -7,6 +7,11 @@ extension AtomSpyReporter on Atom { } void reportWrite(T newValue, T oldValue, void Function() setNewValue) { + // Avoid unnecessary observable notifications of @observable fields of Stores + if (newValue == oldValue) { + return; + } + context.spyReport(ObservableValueSpyEvent(this, newValue: newValue, oldValue: oldValue, name: name)); diff --git a/mobx/lib/src/core/computed.dart b/mobx/lib/src/core/computed.dart index 717a23b57..6b7e74836 100644 --- a/mobx/lib/src/core/computed.dart +++ b/mobx/lib/src/core/computed.dart @@ -181,4 +181,8 @@ class Computed extends Atom implements Derivation, ObservableValue { }, context: _context) .call; } + + @override + String toString() => + 'Computed<$T>(name: $name, identity: ${identityHashCode(this)})'; } diff --git a/mobx/lib/src/core/context.dart b/mobx/lib/src/core/context.dart index 30ce706b2..1f2aef7b5 100644 --- a/mobx/lib/src/core/context.dart +++ b/mobx/lib/src/core/context.dart @@ -343,7 +343,9 @@ class ReactiveContext { _resetState(); throw MobXCyclicReactionException( - "Reaction doesn't converge to a stable state after ${config.maxIterations} iterations. Probably there is a cycle in the reactive function: $failingReaction"); + "Reaction doesn't converge to a stable state after ${config.maxIterations} iterations. " + "Probably there is a cycle in the reactive function: $failingReaction " + "(creation stack: ${failingReaction.debugCreationStack})"); } final remainingReactions = allReactions.toList(growable: false); diff --git a/mobx/lib/src/core/observable.dart b/mobx/lib/src/core/observable.dart index 4f7f99482..a29230479 100644 --- a/mobx/lib/src/core/observable.dart +++ b/mobx/lib/src/core/observable.dart @@ -53,6 +53,8 @@ class Observable extends Atom return _value; } + T get nonObservableValue => _value; + set value(T value) { _context.enforceWritePolicy(this); @@ -125,4 +127,8 @@ class Observable extends Atom @override Dispose intercept(Interceptor interceptor) => _interceptors.add(interceptor); + + @override + String toString() => + 'Observable<$T>(name: $name, identity: ${identityHashCode(this)})'; } diff --git a/mobx/lib/src/core/reaction.dart b/mobx/lib/src/core/reaction.dart index cbfce29e7..5dcce2b83 100644 --- a/mobx/lib/src/core/reaction.dart +++ b/mobx/lib/src/core/reaction.dart @@ -6,9 +6,11 @@ abstract class Reaction implements Derivation { void dispose(); void _run(); + + StackTrace? get debugCreationStack; } -class ReactionImpl implements Reaction { +class ReactionImpl with DebugCreationStack implements Reaction { ReactionImpl(this._context, Function() onInvalidate, {required this.name, void Function(Object, Reaction)? onError}) { _onInvalidate = onInvalidate; @@ -180,4 +182,8 @@ class ReactionImpl implements Reaction { _context._notifyReactionErrorHandlers(exception, this); } + + @override + String toString() => + 'Reaction(name: $name, identity: ${identityHashCode(this)})'; } diff --git a/mobx/lib/src/utils.dart b/mobx/lib/src/utils.dart index efbff8905..cd81d806b 100644 --- a/mobx/lib/src/utils.dart +++ b/mobx/lib/src/utils.dart @@ -4,3 +4,19 @@ const Duration ms = Duration(milliseconds: 1); Timer Function(void Function()) createDelayedScheduler(int delayMs) => (fn) => Timer(ms * delayMs, fn); + +mixin DebugCreationStack { + /// Set the flag to true, to enable [debugCreationStack]. + /// Otherwise, the stack is always null. + static var enable = false; + + /// The stack trace when the object is created + final StackTrace? debugCreationStack = () { + StackTrace? result; + assert(() { + if (enable) result = StackTrace.current; + return true; + }()); + return result; + }(); +} diff --git a/mobx/lib/version.dart b/mobx/lib/version.dart index dde8ee573..4a91b916d 100644 --- a/mobx/lib/version.dart +++ b/mobx/lib/version.dart @@ -1,4 +1,5 @@ // Generated via set_version.dart. !!!DO NOT MODIFY BY HAND!!! /// The current version as per `pubspec.yaml`. -const version = '2.1.3+1'; + +const version = '2.1.4'; \ No newline at end of file diff --git a/mobx/pubspec.yaml b/mobx/pubspec.yaml index e81d01082..013672e6b 100644 --- a/mobx/pubspec.yaml +++ b/mobx/pubspec.yaml @@ -1,5 +1,5 @@ name: mobx -version: 2.1.3+1 +version: 2.1.4 description: "MobX is a library for reactively managing the state of your applications. Use the power of observables, actions, and reactions to supercharge your Dart and Flutter apps." homepage: https://github.com/mobxjs/mobx.dart diff --git a/mobx/test/action_test.dart b/mobx/test/action_test.dart index 804fa356d..7df56a2c7 100644 --- a/mobx/test/action_test.dart +++ b/mobx/test/action_test.dart @@ -1,4 +1,5 @@ import 'package:mobx/mobx.dart'; +import 'package:mobx/src/utils.dart'; import 'package:mocktail/mocktail.dart' as mock; import 'package:test/test.dart'; @@ -11,6 +12,18 @@ void main() { testSetup(); group('Action', () { + test('toString', () { + final object = Action(() {}, name: 'MyName'); + expect(object.toString(), contains('MyName')); + }); + + test('debugCreationStack', () { + DebugCreationStack.enable = true; + addTearDown(() => DebugCreationStack.enable = false); + final object = Action(() {}); + expect(object.debugCreationStack, isNotNull); + }); + test('basics work', () { final a = Action((String name, String value) { expect(name, equals('name')); diff --git a/mobx/test/all_tests.dart b/mobx/test/all_tests.dart index fb1c6705e..985418cc8 100644 --- a/mobx/test/all_tests.dart +++ b/mobx/test/all_tests.dart @@ -32,6 +32,7 @@ import 'reactive_policies_test.dart' as reactive_policies_test; import 'spy_test.dart' as spy_test; import 'store_test.dart' as store_test; import 'when_test.dart' as when_test; +import 'atom_test.dart' as atom_test; void main() { observable_test.main(); @@ -71,4 +72,6 @@ void main() { spy_test.main(); store_test.main(); + + atom_test.main(); } diff --git a/mobx/test/atom_extensions_test.dart b/mobx/test/atom_extensions_test.dart new file mode 100644 index 000000000..003f07fda --- /dev/null +++ b/mobx/test/atom_extensions_test.dart @@ -0,0 +1,61 @@ +import 'package:mobx/mobx.dart'; +import 'package:test/test.dart'; + +void main() { + test( + 'when write to @observable field with changed value, should trigger notifications for downstream', + () { + final store = _ExampleStore(); + + final autorunResults = []; + autorun((_) => autorunResults.add(store.value)); + + expect(autorunResults, ['first']); + + store.value = 'second'; + + expect(autorunResults, ['first', 'second']); + }); + + // fixed by #855 + test( + 'when write to @observable field with unchanged value, should not trigger notifications for downstream', + () { + final store = _ExampleStore(); + + final autorunResults = []; + autorun((_) => autorunResults.add(store.value)); + + expect(autorunResults, ['first']); + + store.value = store.value; + + expect(autorunResults, ['first']); + }); +} + +class _ExampleStore = __ExampleStore with _$_ExampleStore; + +abstract class __ExampleStore with Store { + @observable + String value = 'first'; +} + +// This is what typically a mobx codegen will generate. +mixin _$_ExampleStore on __ExampleStore, Store { + // ignore: non_constant_identifier_names + late final _$valueAtom = Atom(name: '__ExampleStore.value', context: context); + + @override + String get value { + _$valueAtom.reportRead(); + return super.value; + } + + @override + set value(String value) { + _$valueAtom.reportWrite(value, super.value, () { + super.value = value; + }); + } +} diff --git a/mobx/test/atom_test.dart b/mobx/test/atom_test.dart new file mode 100644 index 000000000..4498adb98 --- /dev/null +++ b/mobx/test/atom_test.dart @@ -0,0 +1,31 @@ +import 'package:mobx/mobx.dart'; +import 'package:mobx/src/utils.dart'; +import 'package:test/test.dart'; + +import 'util.dart'; + +void main() { + testSetup(); + + group('Atom', () { + test('toString', () { + final object = Atom(name: 'MyName'); + expect(object.toString(), contains('MyName')); + }); + + test('debugCreationStack', () { + DebugCreationStack.enable = true; + addTearDown(() => DebugCreationStack.enable = false); + final object = Atom(); + expect(object.debugCreationStack, isNotNull); + }); + + test('isBeingObserved', () { + final observable = Observable(1, name: 'MyName'); + expect(observable.isBeingObserved, false); + final d = autorun((_) => observable.value); + expect(observable.isBeingObserved, true); + d(); + }); + }); +} diff --git a/mobx/test/computed_test.dart b/mobx/test/computed_test.dart index 143b9e1ef..8668cb559 100644 --- a/mobx/test/computed_test.dart +++ b/mobx/test/computed_test.dart @@ -1,5 +1,6 @@ import 'package:collection/collection.dart'; import 'package:mobx/mobx.dart' hide when; +import 'package:mobx/src/utils.dart'; import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; @@ -10,6 +11,18 @@ void main() { testSetup(); group('Computed', () { + test('toString', () { + final object = Computed(() {}, name: 'MyName'); + expect(object.toString(), contains('MyName')); + }); + + test('debugCreationStack', () { + DebugCreationStack.enable = true; + addTearDown(() => DebugCreationStack.enable = false); + final object = Computed(() {}); + expect(object.debugCreationStack, isNotNull); + }); + test('basics work', () { final x = Observable(20); final y = Observable(10); diff --git a/mobx/test/observable_list_test.dart b/mobx/test/observable_list_test.dart index f245f126a..52005e371 100644 --- a/mobx/test/observable_list_test.dart +++ b/mobx/test/observable_list_test.dart @@ -627,6 +627,23 @@ void main() { '[]': (_) => _[0], '+': (_) => _ + [100], }.forEach(_templateReadTest); + + test('bypass observable system', () { + final list = ObservableList(); + + int? nonObservableInnerLength; + autorun( + (_) => nonObservableInnerLength = list.nonObservableInner.length); + + expect(list.nonObservableInner.length, 0); + expect(nonObservableInnerLength, equals(0)); + + list.add(20); + + expect(list.nonObservableInner.length, 1); + expect(nonObservableInnerLength, equals(0), + reason: 'should not be observable'); + }); }); }); diff --git a/mobx/test/observable_map_test.dart b/mobx/test/observable_map_test.dart index 01d740eb4..b057d83e8 100644 --- a/mobx/test/observable_map_test.dart +++ b/mobx/test/observable_map_test.dart @@ -228,6 +228,22 @@ void main() { expect(map.containsKey('a'), isTrue); }); + + test('bypass observable system', () { + final map = ObservableMap(); + + int? nonObservableInnerLength; + autorun((_) => nonObservableInnerLength = map.nonObservableInner.length); + + expect(map.nonObservableInner.length, 0); + expect(nonObservableInnerLength, equals(0)); + + map[10] = 20; + + expect(map.nonObservableInner.length, 1); + expect(nonObservableInnerLength, equals(0), + reason: 'should not be observable'); + }); }); } diff --git a/mobx/test/observable_set_test.dart b/mobx/test/observable_set_test.dart index 811de0cc1..cf4180bef 100644 --- a/mobx/test/observable_set_test.dart +++ b/mobx/test/observable_set_test.dart @@ -1,4 +1,5 @@ import 'package:mobx/src/api/observable_collections.dart'; +import 'package:mobx/src/api/reaction.dart'; import 'package:mobx/src/core.dart'; import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; @@ -143,6 +144,22 @@ void main() { 'retainWhere': (m) => m.retainWhere((i) => i < 3), }.forEach(runWriteTest); }); + + test('bypass observable system', () { + final set = ObservableSet(); + + int? nonObservableInnerLength; + autorun((_) => nonObservableInnerLength = set.nonObservableInner.length); + + expect(set.nonObservableInner.length, 0); + expect(nonObservableInnerLength, equals(0)); + + set.add(10); + + expect(set.nonObservableInner.length, 1); + expect(nonObservableInnerLength, equals(0), + reason: 'should not be observable'); + }); }); } diff --git a/mobx/test/observable_test.dart b/mobx/test/observable_test.dart index fe9ea5a92..8e54f3a3c 100644 --- a/mobx/test/observable_test.dart +++ b/mobx/test/observable_test.dart @@ -1,4 +1,5 @@ import 'package:mobx/mobx.dart'; +import 'package:mobx/src/utils.dart'; import 'package:mocktail/mocktail.dart' as mock; import 'package:test/test.dart'; @@ -12,6 +13,18 @@ void main() { testSetup(); group('observable', () { + test('toString', () { + final object = Observable(42, name: 'MyName'); + expect(object.toString(), contains('MyName')); + }); + + test('debugCreationStack', () { + DebugCreationStack.enable = true; + addTearDown(() => DebugCreationStack.enable = false); + final object = Observable(42); + expect(object.debugCreationStack, isNotNull); + }); + test('basics work', () { final x = Observable(null); expect(x.value, equals(null)); @@ -59,6 +72,14 @@ void main() { () => context.endBatch() ]); }); + + test('nonObservableValue', () { + final x = Observable(null); + expect(x.nonObservableValue, null); + + x.value = 100; + expect(x.nonObservableValue, 100); + }); }); group('createAtom()', () { diff --git a/mobx/test/reaction_test.dart b/mobx/test/reaction_test.dart index b013a5eef..3fe6feb56 100644 --- a/mobx/test/reaction_test.dart +++ b/mobx/test/reaction_test.dart @@ -1,6 +1,7 @@ import 'package:fake_async/fake_async.dart'; import 'package:mobx/mobx.dart' hide when; import 'package:mobx/src/core.dart'; +import 'package:mobx/src/utils.dart'; import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; @@ -13,6 +14,18 @@ void main() { testSetup(); group('Reaction', () { + test('toString', () { + final object = ReactionImpl(mainContext, () => null, name: 'MyName'); + expect(object.toString(), contains('MyName')); + }); + + test('debugCreationStack', () { + DebugCreationStack.enable = true; + addTearDown(() => DebugCreationStack.enable = false); + final object = ReactionImpl(mainContext, () => null, name: 'MyName'); + expect(object.debugCreationStack, isNotNull); + }); + test('basics work', () { var executed = false; final x = Observable(10);