Repository: mobxjs/mobx.dart

Issue:
feat: add keepAlive to Computed
https://mobx.js.org/computeds.html#keepalive

This avoids suspending computed values when they are not being observed by anything (see the above explanation). Can potentially create memory leaks.





Fix patch:
diff --git a/docs/docs/api/observable.mdx b/docs/docs/api/observable.mdx
index 9ff43e05..dab40c59 100644
--- a/docs/docs/api/observable.mdx
+++ b/docs/docs/api/observable.mdx
@@ -223,7 +223,7 @@ 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.
@@ -231,6 +231,14 @@ abstract class _Todos with Store {
 - **`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
@@ -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');
diff --git a/mobx/CHANGELOG.md b/mobx/CHANGELOG.md
index d6e1d0d0..b5f1d253 100644
--- a/mobx/CHANGELOG.md
+++ b/mobx/CHANGELOG.md
@@ -1,6 +1,10 @@
+## 2.3.0
+
+- Add `keepAlive` to `Computed` to avoid 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 
+Make the change in `2.2.3` optional. If you want the use this behavior , modify `@observable` to 
 `@MakeObservable(useDeepEquality: true)`.
 
 - Adds `useDeepEquality` for creating observables by [@amondnet](https://github.com/amondnet)
diff --git a/mobx/lib/mobx.dart b/mobx/lib/mobx.dart
index d5caa80e..c93b748a 100644
--- a/mobx/lib/mobx.dart
+++ b/mobx/lib/mobx.dart
@@ -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,
diff --git a/mobx/lib/src/api/annotations.dart b/mobx/lib/src/api/annotations.dart
index 4d8d017d..99028660 100644
--- a/mobx/lib/src/api/annotations.dart
+++ b/mobx/lib/src/api/annotations.dart
@@ -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`.
 ///
diff --git a/mobx/lib/src/core/computed.dart b/mobx/lib/src/core/computed.dart
index 6b7e7483..2acc0e8a 100644
--- a/mobx/lib/src/core/computed.dart
+++ b/mobx/lib/src/core/computed.dart
@@ -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);
@@ -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;
 
@@ -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);
@@ -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
@@ -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() {
diff --git a/mobx/lib/version.dart b/mobx/lib/version.dart
index d3f0725f..56d7242b 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.2.3+1';
+const version = '2.3.0';
diff --git a/mobx/pubspec.yaml b/mobx/pubspec.yaml
index ea2d898d..5407e292 100644
--- a/mobx/pubspec.yaml
+++ b/mobx/pubspec.yaml
@@ -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
diff --git a/mobx_codegen/CHANGELOG.md b/mobx_codegen/CHANGELOG.md
index c2641986..7b660ba0 100644
--- a/mobx_codegen/CHANGELOG.md
+++ b/mobx_codegen/CHANGELOG.md
@@ -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)
diff --git a/mobx_codegen/lib/src/store_class_visitor.dart b/mobx_codegen/lib/src/store_class_visitor.dart
index 83abf574..b909e56d 100644
--- a/mobx_codegen/lib/src/store_class_visitor.dart
+++ b/mobx_codegen/lib/src/store_class_visitor.dart
@@ -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;
@@ -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) {
@@ -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;
diff --git a/mobx_codegen/lib/src/template/computed.dart b/mobx_codegen/lib/src/template/computed.dart
index 2cb3918a..fa910f93 100644
--- a/mobx_codegen/lib/src/template/computed.dart
+++ b/mobx_codegen/lib/src/template/computed.dart
@@ -6,13 +6,15 @@ 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
@@ -20,5 +22,5 @@ class ComputedTemplate {
   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;""";
 }
diff --git a/mobx_codegen/lib/version.dart b/mobx_codegen/lib/version.dart
index bc245f3f..26dc6bd5 100644
--- a/mobx_codegen/lib/version.dart
+++ b/mobx_codegen/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.5.0';
+const version = '2.6.0';
diff --git a/mobx_codegen/pubspec.yaml b/mobx_codegen/pubspec.yaml
index 91509440..e1589412 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.5.0
+version: 2.6.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.2.3+1
+  mobx: ^2.3.0
   path: ^1.8.0
   source_gen: ^1.2.1
 


Test patch:
diff --git a/mobx/test/computed_test.dart b/mobx/test/computed_test.dart
index 8668cb55..a46fda67 100644
--- a/mobx/test/computed_test.dart
+++ b/mobx/test/computed_test.dart
@@ -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);
+    });
   });
 }
diff --git a/mobx_codegen/test/data/valid_keep_alive_computed_input.dart b/mobx_codegen/test/data/valid_keep_alive_computed_input.dart
new file mode 100644
index 00000000..a4110183
--- /dev/null
+++ b/mobx_codegen/test/data/valid_keep_alive_computed_input.dart
@@ -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;
+}
diff --git a/mobx_codegen/test/data/valid_keep_alive_computed_output.dart b/mobx_codegen/test/data/valid_keep_alive_computed_output.dart
new file mode 100644
index 00000000..576726c4
--- /dev/null
+++ b/mobx_codegen/test/data/valid_keep_alive_computed_output.dart
@@ -0,0 +1,29 @@
+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;
+  }
+
+  bool _usernameIsInitialized = false;
+
+  @override
+  set username(String value) {
+    _$usernameAtom
+        .reportWrite(value, _usernameIsInitialized ? super.username : null, () {
+      super.username = value;
+      _usernameIsInitialized = true;
+    });
+  }
+}
diff --git a/mobx_codegen/test/generator_usage_test.dart b/mobx_codegen/test/generator_usage_test.dart
index 3a6bb2dd..833aafc1 100644
--- a/mobx_codegen/test/generator_usage_test.dart
+++ b/mobx_codegen/test/generator_usage_test.dart
@@ -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';
 
@@ -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');
 
diff --git a/mobx_codegen/test/generator_usage_test.g.dart b/mobx_codegen/test/generator_usage_test.g.dart
index dff31d23..0b814d83 100644
--- a/mobx_codegen/test/generator_usage_test.g.dart
+++ b/mobx_codegen/test/generator_usage_test.g.dart
@@ -15,6 +15,14 @@ mixin _$TestStore on _TestStore, Store {
   String get fields => (_$fieldsComputed ??=
           Computed<String>(() => super.fields, name: '_TestStore.fields'))
       .value;
+  Computed<String>? _$fieldsKeepAliveComputed;
+
+  @override
+  String get fieldsKeepAlive => (_$fieldsKeepAliveComputed ??= Computed<String>(
+          () => super.fieldsKeepAlive,
+          name: '_TestStore.fieldsKeepAlive',
+          keepAlive: true))
+      .value;
   Computed<String>? _$batchedItemsComputed;
 
   @override
@@ -284,6 +292,7 @@ batchItem4: ${batchItem4},
 errorField: ${errorField},
 lateField: ${lateField},
 fields: ${fields},
+fieldsKeepAlive: ${fieldsKeepAlive},
 batchedItems: ${batchedItems}
     ''';
   }
diff --git a/mobx_codegen/test/mobx_codegen_test.dart b/mobx_codegen/test/mobx_codegen_test.dart
index dcd029f4..87222fcf 100644
--- a/mobx_codegen/test/mobx_codegen_test.dart
+++ b/mobx_codegen/test/mobx_codegen_test.dart
@@ -54,6 +54,11 @@ void main() {
           './data/annotations_test_class_output.dart');
     });
 
+    test('generates for a class containing keep alive computed', () async {
+      await compareFiles('./data/valid_keep_alive_computed_input.dart',
+          './data/valid_keep_alive_computed_output.dart');
+    });
+
     createTests([
       const TestInfo(
           description: 'invalid output is handled',