Skip to content

Commit

Permalink
feat: add keepAlive to Computed
Browse files Browse the repository at this point in the history
  • Loading branch information
amondnet committed Dec 27, 2023
1 parent 00fe8b0 commit 8db3f62
Show file tree
Hide file tree
Showing 17 changed files with 213 additions and 23 deletions.
15 changes: 14 additions & 1 deletion docs/docs/api/observable.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -223,14 +223,22 @@ abstract class _Todos with Store {

## Computed

#### `Computed(T Function() fn, {String name, ReactiveContext context})`
#### `Computed(T Function() fn, {String name, ReactiveContext context, EqualityComparer<T>? equals, bool? keepAlive})`

- **`T Function() fn`**: the function which relies on observables to compute its
value.
- **`String name`**: a name to identify during debugging
- **`ReactiveContext context`**: the context to which this computed is bound. By
default, all computeds are bound to the singleton `mainContext` of the
application.
- **`EqualityComparer<T>? equals`**: It acts as a comparison function for
comparing the previous value with the next value. If this function considers
the values to be equal, then the observers will not be re-evaluated.
This is useful when working with structural data and types from other libraries.
- **`bool? keepAlive`**: This avoids suspending computed values when they are not
being observed by anything (see the above explanation). Can potentially create memory
leaks.


**Computeds** form the derived state of your application. They depend on other
observables or computeds for their value. Any time the depending observables
Expand All @@ -241,6 +249,11 @@ the connected reactions don't execute unnecessarily.

> #### CachingThe caching behavior is only for _notifications_ and **not** for the _value_. Calling a computed property will always evaluate and return the value. There is no caching on the computation itself. However, notifications fire only when the computed value is different from the previous one. This is where the caching behavior applies.
It can be overridden by setting the annotation with the keepAlive option or by creating
a no-op `autorun(() { someComputed })`, which can be nicely cleaned
up later if needed. Note that both solutions have the risk of creating memory leaks. Changing
the default behavior here is an anti-pattern.

```dart
final first = Observable('Pavan');
final last = Observable('Podila');
Expand Down
4 changes: 4 additions & 0 deletions mobx/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 2.3.0

- Add `keepAlive` to `Computed` to avoids suspending computed values when they are not being observed by anything.

## 2.2.3+1

Make the change in 2.2.3 optional. If you want the use this behavior , modify `@observable` to
Expand Down
4 changes: 3 additions & 1 deletion mobx/lib/mobx.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ export 'package:mobx/src/api/annotations.dart'
observable,
StoreConfig,
MakeObservable,
alwaysNotify, observableAlwaysNotEqual;
alwaysNotify,
observableAlwaysNotEqual,
ComputedMethod;
export 'package:mobx/src/api/async.dart'
show
ObservableFuture,
Expand Down
6 changes: 4 additions & 2 deletions mobx/lib/src/api/annotations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,14 @@ const MakeObservable alwaysNotify = MakeObservable(equals: observableAlwaysNotEq
///
/// During code-generation, this type is detected to identify a `Computed`
class ComputedMethod {
const ComputedMethod._();
const ComputedMethod({this.keepAlive});

final bool? keepAlive;
}

/// Declares a method as a computed value. See the `Computed` class for full
/// documentation.
const ComputedMethod computed = ComputedMethod._();
const ComputedMethod computed = ComputedMethod();

/// Internal class only used for code-generation with `mobx_codegen`.
///
Expand Down
34 changes: 23 additions & 11 deletions mobx/lib/src/core/computed.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ class Computed<T> extends Atom implements Derivation, ObservableValue<T> {
/// It is possible to override equality comparison (when deciding whether to notify observers)
/// by providing an [equals] comparator.
///
/// [keepAlive]
/// This avoids suspending computed values when they are not being observed by anything.
/// Can potentially create memory leaks.
///
/// ```
/// var x = Observable(10);
/// var y = Observable(10);
Expand All @@ -34,14 +38,20 @@ class Computed<T> extends Atom implements Derivation, ObservableValue<T> {
factory Computed(T Function() fn,
{String? name,
ReactiveContext? context,
EqualityComparer<T>? equals}) =>
Computed._(context ?? mainContext, fn, name: name, equals: equals);
EqualityComparer<T>? equals,
bool? keepAlive}) =>
Computed._(context ?? mainContext, fn,
name: name, equals: equals, keepAlive: keepAlive);

Computed._(ReactiveContext context, this._fn, {String? name, this.equals})
: super._(context, name: name ?? context.nameFor('Computed'));
Computed._(ReactiveContext context, this._fn,
{String? name, this.equals, bool? keepAlive})
: _keepAlive = keepAlive ?? false,
super._(context, name: name ?? context.nameFor('Computed'));

final EqualityComparer<T>? equals;

final bool _keepAlive;

@override
MobXCaughtException? _errorValue;

Expand Down Expand Up @@ -72,7 +82,7 @@ class Computed<T> extends Atom implements Derivation, ObservableValue<T> {
'Cycle detected in computation $name: $_fn');
}

if (!_context.isWithinBatch && _observers.isEmpty) {
if (!_context.isWithinBatch && _observers.isEmpty && !_keepAlive) {
if (_context._shouldCompute(this)) {
_context.startBatch();
_value = computeValue(track: false);
Expand Down Expand Up @@ -122,8 +132,10 @@ class Computed<T> extends Atom implements Derivation, ObservableValue<T> {

@override
void _suspend() {
_context.clearObservables(this);
_value = null;
if (!this._keepAlive) {
_context.clearObservables(this);
_value = null;
}
}

@override
Expand Down Expand Up @@ -156,10 +168,10 @@ class Computed<T> extends Atom implements Derivation, ObservableValue<T> {

bool _isEqual(T? x, T? y) => equals == null ? x == y : equals!(x, y);

void Function() observe(
void Function(ChangeNotification<T>) handler,
{@Deprecated('fireImmediately has no effect anymore. It is on by default.')
bool? fireImmediately}) {
void Function() observe(void Function(ChangeNotification<T>) handler,
{@Deprecated(
'fireImmediately has no effect anymore. It is on by default.')
bool? fireImmediately}) {
T? prevValue;

void notifyChange() {
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.2.3+1';
const version = '2.3.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.2.3+1
version: 2.3.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
76 changes: 76 additions & 0 deletions mobx/test/computed_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -246,5 +246,81 @@ void main() {
expect(value, equals('SUCCESS'));
expect(error, isNull);
});

test("keeping computed properties alive does not run before access", () {
var calcs = 0;
final x = Observable(1);
// ignore: unused_local_variable
final y = Computed(() {
calcs++;
return x.value * 2;
}, keepAlive: true);

expect(calcs, 0); // initially there is no calculation done
});

test("keeping computed properties alive runs on first access", () {
var calcs = 0;
final x = Observable(1);
final y = Computed(() {
calcs++;
return x.value * 2;
}, keepAlive: true);

expect(calcs, 0);
expect(y.value, 2); // perform calculation on access
expect(calcs, 1);
});

test(
"keeping computed properties alive caches values on subsequent accesses",
() {
var calcs = 0;
final x = Observable(1);
final y = Computed(() {
calcs++;
return x.value * 2;
}, keepAlive: true);

expect(y.value, 2); // first access: do calculation
expect(y.value, 2); // second access: use cached value, no calculation
expect(calcs, 1); // only one calculation: cached!


});

test("keeping computed properties alive does not recalculate when dirty",
() {
var calcs = 0;
final x = Observable(1);
final y = Computed(() {
calcs++;
return x.value * 2;
}, keepAlive: true);

expect(y.value, 2); // first access: do calculation
expect(calcs, 1);
x.value = 3; // mark as dirty: no calculation
expect(calcs, 1);
expect(y.value, 6);
});

test(
"keeping computed properties alive recalculates when accessing it dirty",
() {
var calcs = 0;
final x = Observable(1);
final y = Computed(() {
calcs++;
return x.value * 2;
}, keepAlive: true);

expect(y.value, 2); // first access: do calculation
expect(calcs, 1);
x.value = 3; // mark as dirty: no calculation
expect(calcs, 1);
expect(y.value, 6);
expect(calcs, 2);
});
});
}
4 changes: 4 additions & 0 deletions mobx_codegen/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 2.6.0

- Add `keepAlive` to `Computed` to avoids suspending computed values when they are not being observed by anything.

## 2.5.0

- Support `late` observables by [@amondnet](https://github.com/amondnet). fix [#919](https://github.com/mobxjs/mobx.dart/issues/919)
Expand Down
10 changes: 8 additions & 2 deletions mobx_codegen/lib/src/store_class_visitor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ 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 @@ -131,6 +130,12 @@ class StoreClassVisitor extends SimpleElementVisitor {
),
]);

bool? _isComputedKeepAlive(Element element) =>
_computedChecker
.firstAnnotationOfExact(element)
?.getField('keepAlive')
?.toBoolValue();

@override
void visitPropertyAccessorElement(PropertyAccessorElement element) {
if (element.isSetter && element.isPublic) {
Expand All @@ -156,7 +161,8 @@ class StoreClassVisitor extends SimpleElementVisitor {
storeTemplate: _storeTemplate,
name: element.name,
type: typeNameFinder.findGetterTypeName(element),
isPrivate: element.isPrivate);
isPrivate: element.isPrivate,
isKeepAlive: _isComputedKeepAlive(element));

_storeTemplate.computeds.add(template);
return;
Expand Down
6 changes: 4 additions & 2 deletions mobx_codegen/lib/src/template/computed.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,21 @@ class ComputedTemplate {
required this.computedName,
required this.type,
required this.name,
this.isPrivate = false});
this.isPrivate = false,
this.isKeepAlive});

final StoreTemplate storeTemplate;
final String computedName;
final String type;
final String name;
final bool isPrivate;
final bool? isKeepAlive;

@override
// ignore: prefer_single_quotes
String toString() => """
Computed<$type>? $computedName;
@override
$type get $name => ($computedName ??= Computed<$type>(() => super.$name, name: '${storeTemplate.parentTypeName}.$name')).value;""";
$type get $name => ($computedName ??= Computed<$type>(() => super.$name, name: '${storeTemplate.parentTypeName}.$name'${isKeepAlive != null ? ', keepAlive: $isKeepAlive' : ''})).value;""";
}
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.5.0
version: 2.6.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.2.3+1
mobx: ^2.3.0
path: ^1.8.0
source_gen: ^1.2.1

Expand Down
15 changes: 15 additions & 0 deletions mobx_codegen/test/data/valid_keep_alive_computed_input.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
library generator_sample;

import 'package:mobx/mobx.dart';

part 'generator_sample.g.dart';

class TestStore = _TestStore with _$TestStore;

@StoreConfig(hasToString: false)
abstract class _TestStore with Store {
@observable
late String username;
@ComputedMethod(keepAlive: true)
String get usernameComputed => username;
}
25 changes: 25 additions & 0 deletions mobx_codegen/test/data/valid_keep_alive_computed_output.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
mixin _$TestStore on _TestStore, Store {
Computed<String>? _$usernameComputedComputed;

@override
String get usernameComputed => (_$usernameComputedComputed ??=
Computed<String>(() => super.usernameComputed,
name: '_TestStore.usernameComputed', keepAlive: true))
.value;

late final _$usernameAtom =
Atom(name: '_TestStore.username', context: context);

@override
String get username {
_$usernameAtom.reportRead();
return super.username;
}

@override
set username(String value) {
_$usernameAtom.reportWrite(value, super.username, () {
super.username = value;
});
}
}
15 changes: 15 additions & 0 deletions mobx_codegen/test/generator_usage_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ abstract class _TestStore with Store {
@computed
String get fields => '$field1 $field2';

@ComputedMethod(keepAlive: true)
String get fieldsKeepAlive => '$field1 $field2';

@observable
String stuff = 'stuff';

Expand Down Expand Up @@ -167,6 +170,18 @@ void main() {
expect(fields, equals(['field1 field2', 'field1++ field2++']));
});

test('keep alive computed fields works', () {
final store = TestStore('field1', field2: 'field2');

final fields = <String>[];
autorun((_) {
fields.add(store.fieldsKeepAlive);
});
store.setFields('field1++', 'field2++');

expect(fields, equals(['field1 field2', 'field1++ field2++']));
});

test('async action works', () async {
final store = TestStore('field1', field2: 'field2');

Expand Down
Loading

0 comments on commit 8db3f62

Please sign in to comment.