diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b29a12236..25dfa1487 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: package: ["mobx_codegen", "mobx"] - version: ["stable", "beta"] + version: ["stable"] steps: - uses: actions/checkout@v3 diff --git a/mobx/CHANGELOG.md b/mobx/CHANGELOG.md index 5d8940a71..5a2fcda84 100644 --- a/mobx/CHANGELOG.md +++ b/mobx/CHANGELOG.md @@ -1,10 +1,18 @@ +## 2.2.0 + +- Allows a reaction to be fired even if the value hasn't changed by [@amondnet](https://github.com/amondnet) in [#907](https://github.com/mobxjs/mobx.dart/pull/907) +- Adds custom `equals` for creating observables. [@amondnet](https://github.com/amondnet) in [#907](https://github.com/mobxjs/mobx.dart/pull/907) + ## 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) + Breaking changes: + +- 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) + ## 2.1.2 - 2.1.3+1 - Fix tests in dart 2.19 - [@amondnet](https://github.com/amondnet) diff --git a/mobx/lib/mobx.dart b/mobx/lib/mobx.dart index 9be541d00..d5caa80ed 100644 --- a/mobx/lib/mobx.dart +++ b/mobx/lib/mobx.dart @@ -30,7 +30,14 @@ library mobx; export 'package:mobx/src/api/action.dart'; export 'package:mobx/src/api/annotations.dart' - show action, computed, readonly, observable, StoreConfig; + show + action, + computed, + readonly, + observable, + StoreConfig, + MakeObservable, + alwaysNotify, observableAlwaysNotEqual; export 'package:mobx/src/api/async.dart' show ObservableFuture, diff --git a/mobx/lib/src/api/annotations.dart b/mobx/lib/src/api/annotations.dart index 90a4f4de6..602ce7a36 100644 --- a/mobx/lib/src/api/annotations.dart +++ b/mobx/lib/src/api/annotations.dart @@ -3,27 +3,57 @@ class StoreConfig { const StoreConfig({this.hasToString = true}); + final bool hasToString; } /// Internal class only used for code-generation with `mobx_codegen`. /// /// During code-generation, this type is detected to identify an `Observable` +/// [readOnly] indicates that the field is only modifiable within the Store. +/// It is possible to override equality comparison of new values with [equals]. +/// ``` +/// +/// bool _alwaysNotEqual(_, __) => false; +/// +/// @MakeObservable(equals: _alwaysNotEqual) +/// String alwaysNotifyObservable = 'hello'; +/// +/// bool _equals(oldValue, newValue) => oldValue == newValue; +/// +/// @MakeObservable(equals: _equals) +/// String withEquals = 'world'; +/// ``` class MakeObservable { - const MakeObservable._({this.readOnly = false}); + const MakeObservable({this.readOnly = false, this.equals}); final bool readOnly; + /// A [Function] to use check whether the value of an observable has changed. + /// + /// Must be a top-level or static [Function] that takes two arguments and + /// returns a [bool]. + /// The arguments are the old value and the new value of the observable. + /// If the function returns `true`, a reaction will be triggered. + /// If the function returns `false`, no reaction will be triggered. + /// If no function is provided, the default behavior is to only trigger if + /// : `oldValue != newValue`. + final Function? equals; } +bool observableAlwaysNotEqual(_, __) => false; + /// Declares a class field as an observable. See the `Observable` class for full /// documentation -const MakeObservable observable = MakeObservable._(); +const MakeObservable observable = MakeObservable(); /// Declares a class field as an observable. See the `Observable` class for full /// documentation. /// /// But, it's only modifiable within the Store -const MakeObservable readonly = MakeObservable._(readOnly: true); +const MakeObservable readonly = MakeObservable(readOnly: true); + +/// Allows a reaction to be fired even if the value hasn't changed. +const MakeObservable alwaysNotify = MakeObservable(equals: observableAlwaysNotEqual); /// Internal class only used for code-generation with `mobx_codegen`. /// diff --git a/mobx/lib/src/core.dart b/mobx/lib/src/core.dart index f02caf09e..ad1d7701e 100644 --- a/mobx/lib/src/core.dart +++ b/mobx/lib/src/core.dart @@ -7,18 +7,31 @@ import '../mobx.dart'; import 'utils.dart'; part 'core/action.dart'; + part 'core/atom.dart'; + part 'core/computed.dart'; + part 'core/context.dart'; + part 'core/context_extensions.dart'; + part 'core/derivation.dart'; + part 'core/notification_handlers.dart'; + part 'core/observable.dart'; + part 'core/observable_value.dart'; + part 'core/reaction.dart'; + part 'core/reaction_helper.dart'; + part 'core/spy.dart'; + part 'interceptable.dart'; + part 'listenable.dart'; /// An Exception class to capture MobX specific exceptions diff --git a/mobx/lib/src/core/atom_extensions.dart b/mobx/lib/src/core/atom_extensions.dart index d0b3498ad..36bd2cf65 100644 --- a/mobx/lib/src/core/atom_extensions.dart +++ b/mobx/lib/src/core/atom_extensions.dart @@ -6,9 +6,13 @@ extension AtomSpyReporter on Atom { reportObserved(); } - void reportWrite(T newValue, T oldValue, void Function() setNewValue) { + void reportWrite(T newValue, T oldValue, void Function() setNewValue, + {EqualityComparer? equals}) { + final areEqual = + equals == null ? oldValue == newValue : equals(oldValue, newValue); + // Avoid unnecessary observable notifications of @observable fields of Stores - if (newValue == oldValue) { + if (areEqual) { return; } diff --git a/mobx/lib/version.dart b/mobx/lib/version.dart index e39cab4f5..855f13a8b 100644 --- a/mobx/lib/version.dart +++ b/mobx/lib/version.dart @@ -1,4 +1,4 @@ // Generated via set_version.dart. !!!DO NOT MODIFY BY HAND!!! /// The current version as per `pubspec.yaml`. -const version = '2.1.4'; +const version = '2.2.0'; diff --git a/mobx/pubspec.yaml b/mobx/pubspec.yaml index 013672e6b..ae530a21e 100644 --- a/mobx/pubspec.yaml +++ b/mobx/pubspec.yaml @@ -1,5 +1,5 @@ name: mobx -version: 2.1.4 +version: 2.2.0 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/annotations_test.dart b/mobx/test/annotations_test.dart index f4c0e7d4a..d96caabe4 100644 --- a/mobx/test/annotations_test.dart +++ b/mobx/test/annotations_test.dart @@ -13,4 +13,12 @@ void main() { expect(StoreConfig, isNotNull); expect(readonly, isNotNull); }); + + test('observableAlwaysNotEqual should return false', () { + expect(observableAlwaysNotEqual(1, 2), isFalse); + expect(observableAlwaysNotEqual(1, 1), isFalse); + expect(observableAlwaysNotEqual('a', 'a'), isFalse); + expect(observableAlwaysNotEqual(true, true), isFalse); + expect(observableAlwaysNotEqual(false, false), isFalse); + }); } diff --git a/mobx/test/atom_extensions_test.dart b/mobx/test/atom_extensions_test.dart index 003f07fda..4349f26fb 100644 --- a/mobx/test/atom_extensions_test.dart +++ b/mobx/test/atom_extensions_test.dart @@ -32,13 +32,57 @@ void main() { expect(autorunResults, ['first']); }); + + test( + 'when write to @alwaysNotify field with unchanged value, should trigger notifications for downstream', + () { + final store = _ExampleStore(); + + final autorunResults = []; + autorun((_) => autorunResults.add(store.value2)); + + expect(autorunResults, ['first']); + + store.value2 = store.value2; + + expect(autorunResults, ['first', 'first']); + }); + + test( + 'when write to @MakeObservable(equals: "a?.length == b?.length") field with changed value and not equals, should trigger notifications for downstream', + () { + final store = _ExampleStore(); + + final autorunResults = []; + autorun((_) => autorunResults.add(store.value3)); + + expect(autorunResults, ['first']); // length: 5 + + // length: 5, should not trigger + store.value3 = 'third'; + + expect(autorunResults, ['first']); + + // length: 6, should trigger + store.value3 = 'second'; + + expect(autorunResults, ['first', 'second']); + }); } class _ExampleStore = __ExampleStore with _$_ExampleStore; +bool _equals(String? oldValue, String? newValue) => (oldValue == newValue); + abstract class __ExampleStore with Store { @observable String value = 'first'; + + @alwaysNotify + String value2 = 'first'; + + @MakeObservable(equals: _equals) + String value3 = 'first'; } // This is what typically a mobx codegen will generate. @@ -58,4 +102,40 @@ mixin _$_ExampleStore on __ExampleStore, Store { super.value = value; }); } + + // ignore: non_constant_identifier_names + late final _$value2Atom = + Atom(name: '__ExampleStore.value2', context: context); + + @override + String get value2 { + _$value2Atom.reportRead(); + return super.value2; + } + + @override + set value2(String value) { + _$value2Atom.reportWrite(value, super.value2, () { + super.value2 = value; + }, equals: (String? oldValue, String? newValue) => false); + } + + // ignore: non_constant_identifier_names + late final _$value3Atom = + Atom(name: '__ExampleStore.value3', context: context); + + @override + String get value3 { + _$value3Atom.reportRead(); + return super.value3; + } + + @override + set value3(String value) { + _$value3Atom.reportWrite(value, super.value3, () { + super.value3 = value; + }, + equals: (String? oldValue, String? newValue) => + oldValue?.length == newValue?.length); + } } diff --git a/mobx_codegen/CHANGELOG.md b/mobx_codegen/CHANGELOG.md index a1bac5b17..5888395fe 100644 --- a/mobx_codegen/CHANGELOG.md +++ b/mobx_codegen/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.3.0 + +- Adds `@alwaysNotify` annotation support for creating always notify observables. [@amondnet](https://github.com/amondnet) in [#907](https://github.com/mobxjs/mobx.dart/pull/907) +- Adds custom `equals` for creating observables. [@amondnet](https://github.com/amondnet) in [#907](https://github.com/mobxjs/mobx.dart/pull/907) + ## 2.2.0 - Adds support for annotations `@protected`, `@visibleForTesting` and `@visibleForOverriding` for actions, observables futures and observables stream. diff --git a/mobx_codegen/lib/src/store_class_visitor.dart b/mobx_codegen/lib/src/store_class_visitor.dart index 80015b460..17564bf73 100644 --- a/mobx_codegen/lib/src/store_class_visitor.dart +++ b/mobx_codegen/lib/src/store_class_visitor.dart @@ -3,6 +3,7 @@ import 'package:analyzer/dart/element/visitor.dart'; import 'package:build/build.dart'; import 'package:meta/meta.dart'; import 'package:mobx/mobx.dart'; + // ignore: implementation_imports import 'package:mobx/src/api/annotations.dart' show ComputedMethod, MakeAction, MakeObservable, StoreConfig; @@ -101,6 +102,7 @@ class StoreClassVisitor extends SimpleElementVisitor { name: element.name, isPrivate: element.isPrivate, isReadOnly: _isObservableReadOnly(element), + equals: _getEquals(element), ); _storeTemplate.observables.add(template); @@ -114,6 +116,11 @@ class StoreClassVisitor extends SimpleElementVisitor { ?.toBoolValue() ?? false; + ExecutableElement? _getEquals(FieldElement element) => _observableChecker + .firstAnnotationOfExact(element) + ?.getField('equals') + ?.toFunctionValue(); + bool _fieldIsNotValid(FieldElement element) => _any([ errors.staticObservables.addIf(element.isStatic, element.name), errors.finalObservables.addIf(element.isFinal, element.name), diff --git a/mobx_codegen/lib/src/template/observable.dart b/mobx_codegen/lib/src/template/observable.dart index 6269a47e9..bb89577d4 100644 --- a/mobx_codegen/lib/src/template/observable.dart +++ b/mobx_codegen/lib/src/template/observable.dart @@ -1,3 +1,4 @@ +import 'package:analyzer/dart/element/element.dart'; import 'package:meta/meta.dart'; import 'package:mobx_codegen/src/template/store.dart'; import 'package:mobx_codegen/src/utils/non_private_name_extension.dart'; @@ -10,6 +11,7 @@ class ObservableTemplate { required this.name, this.isReadOnly = false, this.isPrivate = false, + this.equals, }); final StoreTemplate storeTemplate; @@ -18,6 +20,7 @@ class ObservableTemplate { final String name; final bool isPrivate; final bool isReadOnly; + final ExecutableElement? equals; /// Formats the `name` from `_foo_bar` to `foo_bar` /// such that the getter gets public @@ -58,6 +61,6 @@ ${_buildGetters()} set $name($type value) { $atomName.reportWrite(value, super.$name, () { super.$name = value; - }); + }${equals != null ? ', equals: ${equals!.name}' : ''}); }"""; } diff --git a/mobx_codegen/pubspec.yaml b/mobx_codegen/pubspec.yaml index 3f7af9ee6..a437ed681 100644 --- a/mobx_codegen/pubspec.yaml +++ b/mobx_codegen/pubspec.yaml @@ -1,6 +1,6 @@ name: mobx_codegen description: Code generator for MobX that adds support for annotating your code with @observable, @computed, @action and also creating Store classes. -version: 2.2.0 +version: 2.3.0 homepage: https://github.com/mobxjs/mobx.dart issue_tracker: https://github.com/mobxjs/mobx.dart/issues @@ -13,7 +13,7 @@ dependencies: build: ^2.2.1 build_resolvers: ^2.0.6 meta: ^1.3.0 - mobx: ^2.0.7 + mobx: ^2.2.0 path: ^1.8.0 source_gen: ^1.2.1 diff --git a/mobx_codegen/test/generator_usage_test.dart b/mobx_codegen/test/generator_usage_test.dart index 291685df5..306b6be51 100644 --- a/mobx_codegen/test/generator_usage_test.dart +++ b/mobx_codegen/test/generator_usage_test.dart @@ -6,6 +6,8 @@ part 'generator_usage_test.g.dart'; // ignore: library_private_types_in_public_api class TestStore = _TestStore with _$TestStore; +bool customEquals(String? oldValue, String? newValue) => oldValue != newValue; + abstract class _TestStore with Store { // ignore: unused_element _TestStore(this.field1, {this.field2}); @@ -28,6 +30,12 @@ abstract class _TestStore with Store { @observable String stuff = 'stuff'; + @alwaysNotify + String always = 'stuff'; + + @MakeObservable(equals: customEquals) + String custom = 'stuff'; + @action Future loadStuff() async { stuff = 'stuff0'; diff --git a/mobx_codegen/test/generator_usage_test.g.dart b/mobx_codegen/test/generator_usage_test.g.dart index f8a2b5516..09d94af67 100644 --- a/mobx_codegen/test/generator_usage_test.g.dart +++ b/mobx_codegen/test/generator_usage_test.g.dart @@ -68,6 +68,36 @@ mixin _$TestStore on _TestStore, Store { }); } + late final _$alwaysAtom = Atom(name: '_TestStore.always', context: context); + + @override + String get always { + _$alwaysAtom.reportRead(); + return super.always; + } + + @override + set always(String value) { + _$alwaysAtom.reportWrite(value, super.always, () { + super.always = value; + }, equals: observableAlwaysNotEqual); + } + + late final _$customAtom = Atom(name: '_TestStore.custom', context: context); + + @override + String get custom { + _$customAtom.reportRead(); + return super.custom; + } + + @override + set custom(String value) { + _$customAtom.reportWrite(value, super.custom, () { + super.custom = value; + }, equals: customEquals); + } + late final _$batchItem1Atom = Atom(name: '_TestStore.batchItem1', context: context); @@ -225,6 +255,8 @@ mixin _$TestStore on _TestStore, Store { field1: ${field1}, field2: ${field2}, stuff: ${stuff}, +always: ${always}, +custom: ${custom}, batchItem1: ${batchItem1}, batchItem2: ${batchItem2}, batchItem3: ${batchItem3},