Skip to content

Commit

Permalink
feat: Adds custom equals for creating observables. (#907)
Browse files Browse the repository at this point in the history
* feat: allows a reaction to be fired even if the value hasn't changed.
  • Loading branch information
amondnet authored May 5, 2023
1 parent ea1ba4f commit 6e6378c
Show file tree
Hide file tree
Showing 16 changed files with 218 additions and 13 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
strategy:
matrix:
package: ["mobx_codegen", "mobx"]
version: ["stable", "beta"]
version: ["stable"]

steps:
- uses: actions/checkout@v3
Expand Down
10 changes: 9 additions & 1 deletion mobx/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
9 changes: 8 additions & 1 deletion mobx/lib/mobx.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 33 additions & 3 deletions mobx/lib/src/api/annotations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
///
Expand Down
13 changes: 13 additions & 0 deletions mobx/lib/src/core.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions mobx/lib/src/core/atom_extensions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@ extension AtomSpyReporter on Atom {
reportObserved();
}

void reportWrite<T>(T newValue, T oldValue, void Function() setNewValue) {
void reportWrite<T>(T newValue, T oldValue, void Function() setNewValue,
{EqualityComparer<T>? 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;
}

Expand Down
2 changes: 1 addition & 1 deletion mobx/lib/version.dart
Original file line number Diff line number Diff line change
@@ -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';
2 changes: 1 addition & 1 deletion mobx/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 8 additions & 0 deletions mobx/test/annotations_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
80 changes: 80 additions & 0 deletions mobx/test/atom_extensions_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <String>[];
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 = <String>[];
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.
Expand All @@ -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);
}
}
5 changes: 5 additions & 0 deletions mobx_codegen/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
7 changes: 7 additions & 0 deletions mobx_codegen/lib/src/store_class_visitor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -101,6 +102,7 @@ class StoreClassVisitor extends SimpleElementVisitor {
name: element.name,
isPrivate: element.isPrivate,
isReadOnly: _isObservableReadOnly(element),
equals: _getEquals(element),
);

_storeTemplate.observables.add(template);
Expand All @@ -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),
Expand Down
5 changes: 4 additions & 1 deletion mobx_codegen/lib/src/template/observable.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,6 +11,7 @@ class ObservableTemplate {
required this.name,
this.isReadOnly = false,
this.isPrivate = false,
this.equals,
});

final StoreTemplate storeTemplate;
Expand All @@ -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
Expand Down Expand Up @@ -58,6 +61,6 @@ ${_buildGetters()}
set $name($type value) {
$atomName.reportWrite(value, super.$name, () {
super.$name = value;
});
}${equals != null ? ', equals: ${equals!.name}' : ''});
}""";
}
4 changes: 2 additions & 2 deletions mobx_codegen/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down
8 changes: 8 additions & 0 deletions mobx_codegen/test/generator_usage_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand All @@ -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<void> loadStuff() async {
stuff = 'stuff0';
Expand Down
Loading

0 comments on commit 6e6378c

Please sign in to comment.