Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions mobx/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 2.6.0

- Allow a custom equals parameter for observable collections( ObservableList, ObservableMap, ObservableSet ) - [@amondnet](https://github.com/amondnet)

## 2.5.0

- **FIX**: package upgrades, analysis issue fixes.
Expand Down
2 changes: 2 additions & 0 deletions mobx/lib/src/api/observable_collections.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import 'dart:collection';
import 'dart:math';

import 'package:collection/collection.dart';
import 'package:meta/meta.dart';
import 'package:mobx/mobx.dart';
import 'package:mobx/src/utils.dart';

part 'observable_collections/observable_list.dart';
part 'observable_collections/observable_map.dart';
Expand Down
71 changes: 59 additions & 12 deletions mobx/lib/src/api/observable_collections/observable_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,34 @@ Atom _observableListAtom<T>(ReactiveContext? context, String? name) {
///
/// As the name suggests, this is the Observable-counterpart to the standard Dart `List<T>`.
///
/// ## Custom Equality
///
/// You can provide a custom `equals` parameter to control when values are considered
/// equal. This is useful for optimizing change detection or implementing custom
/// equality semantics:
///
/// ```dart
/// // Only notify changes when names are different
/// final list = ObservableList<Person>(
/// equals: (a, b) => a?.name == b?.name
/// );
///
/// // Deep equality for nested structures
/// final list = ObservableList<List<int>>(
/// equals: (a, b) {
/// if (a == null && b == null) return true;
/// if (a == null || b == null) return false;
/// return a.length == b.length &&
/// a.asMap().entries.every((e) => e.value == b[e.key]);
/// }
/// );
/// ```
///
/// Note: Bulk operations like `replaceRange` and `setRange` always trigger
/// notifications for performance reasons, regardless of the custom equals function.
///
/// ## Basic Usage
///
/// ```dart
/// final list = ObservableList<int>.of([1]);
///
Expand All @@ -25,20 +53,23 @@ class ObservableList<T>
ListMixin<T>
implements
Listenable<ListChange<T>> {
ObservableList({ReactiveContext? context, String? name})
: this._wrap(context, _observableListAtom<T>(context, name), []);
ObservableList(
{ReactiveContext? context, String? name, EqualityComparer<T>? equals})
: this._wrap(context, _observableListAtom<T>(context, name), [], equals);

ObservableList.of(Iterable<T> elements,
{ReactiveContext? context, String? name})
{ReactiveContext? context, String? name, EqualityComparer<T>? equals})
: this._wrap(context, _observableListAtom<T>(context, name),
List<T>.of(elements, growable: true));
List<T>.of(elements, growable: true), equals);

ObservableList._wrap(ReactiveContext? context, this._atom, this._list)
ObservableList._wrap(
ReactiveContext? context, this._atom, this._list, this._equals)
: _context = context ?? mainContext;

final ReactiveContext _context;
final Atom _atom;
final List<T> _list;
final EqualityComparer<T>? _equals;

List<T> get nonObservableInner => _list;

Expand Down Expand Up @@ -96,7 +127,7 @@ class ObservableList<T>
_context.conditionallyRunInAction(() {
final oldValue = _list[index];

if (oldValue != value) {
if (!_areEquals(oldValue, value)) {
_list[index] = value;
_notifyElementUpdate(index, value, oldValue);
}
Expand Down Expand Up @@ -167,10 +198,18 @@ class ObservableList<T>
}

@override
Map<int, T> asMap() => ObservableMap._wrap(_context, _list.asMap(), _atom);
Map<int, T> asMap() =>
ObservableMap._wrap(_context, _list.asMap(), _atom, _equals);

@override
List<R> cast<R>() => ObservableList._wrap(_context, _atom, _list.cast<R>());
List<R> cast<R>([EqualityComparer<R>? equals]) => ObservableList._wrap(
_context,
_atom,
_list.cast<R>(),
equals ??
(_equals != null
? (R? a, R? b) => _equals!(a as T?, b as T?)
Copy link
Preview

Copilot AI Sep 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsafe casting from R? to T? without type checking. If R and T are incompatible types, this will cause a runtime exception. Should add type compatibility checks or use a safer casting approach.

Suggested change
? (R? a, R? b) => _equals!(a as T?, b as T?)
? (R? a, R? b) {
if (a is T? && b is T?) {
return _equals!(a, b);
}
return false;
}

Copilot uses AI. Check for mistakes.

: null));

@override
List<T> toList({bool growable = true}) {
Expand All @@ -184,7 +223,7 @@ class ObservableList<T>
set first(T value) {
_context.conditionallyRunInAction(() {
final oldValue = _list.first;
if (oldValue != value) {
if (!_areEquals(oldValue, value)) {
_list.first = value;
_notifyElementUpdate(0, value, oldValue);
}
Expand Down Expand Up @@ -376,7 +415,7 @@ class ObservableList<T>
for (var i = 0; i < _list.length; ++i) {
final oldValue = oldList[i];
final newValue = _list[i];
if (newValue != oldValue) {
if (!_areEquals(oldValue, newValue)) {
changes.add(ElementChange(
index: i, oldValue: oldValue, newValue: newValue));
}
Expand All @@ -398,7 +437,7 @@ class ObservableList<T>
for (var i = 0; i < _list.length; ++i) {
final oldValue = oldList[i];
final newValue = _list[i];
if (newValue != oldValue) {
if (!_areEquals(oldValue, newValue)) {
changes.add(ElementChange(
index: i, oldValue: oldValue, newValue: newValue));
}
Expand Down Expand Up @@ -456,6 +495,14 @@ class ObservableList<T>

_listeners.notifyListeners(change);
}

bool _areEquals(T? a, T? b) {
if (_equals != null) {
return _equals!(a, b);
} else {
return equatable(a, b);
}
}
}

typedef ListChangeListener<TNotification> = void Function(
Expand Down Expand Up @@ -520,4 +567,4 @@ class ListChange<T> {
/// Used during testing for wrapping a regular `List<T>` as an `ObservableList<T>`
@visibleForTesting
ObservableList<T> wrapInObservableList<T>(Atom atom, List<T> list) =>
ObservableList._wrap(mainContext, atom, list);
ObservableList._wrap(mainContext, atom, list, null);
67 changes: 54 additions & 13 deletions mobx/lib/src/api/observable_collections/observable_map.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ Atom _observableMapAtom<K, V>(ReactiveContext? context, String? name) {
///
/// As the name suggests, this is the Observable-counterpart to the standard Dart `Map<K,V>`.
///
/// ## Custom Equality for Values
///
/// You can provide a custom `equals` parameter to control when values are considered
/// equal. This only affects value comparisons - keys are always compared using standard
/// equality:
///
/// ```dart
/// // Only notify changes when person names are different
/// final map = ObservableMap<String, Person>(
/// equals: (a, b) => a?.name == b?.name
/// );
///
/// map['key'] = Person('Alice', 25);
/// map['key'] = Person('Alice', 30); // No notification - same name
/// ```
///
/// ## Basic Usage
///
/// ```dart
/// final map = ObservableMap<String, int>.of({'first': 1});
///
Expand All @@ -26,37 +44,45 @@ class ObservableMap<K, V>
MapMixin<K, V>
implements
Listenable<MapChange<K, V>> {
ObservableMap({ReactiveContext? context, String? name})
ObservableMap(
{ReactiveContext? context, String? name, EqualityComparer<V>? equals})
: _context = context ?? mainContext,
_atom = _observableMapAtom<K, V>(context, name),
_map = <K, V>{};
_map = <K, V>{},
_equals = equals;

ObservableMap.of(Map<K, V> other, {ReactiveContext? context, String? name})
ObservableMap.of(Map<K, V> other,
{ReactiveContext? context, String? name, EqualityComparer<V>? equals})
: _context = context ?? mainContext,
_atom = _observableMapAtom<K, V>(context, name),
_map = Map.of(other);
_map = Map.of(other),
_equals = equals;

ObservableMap.linkedHashMapFrom(Map<K, V> other,
{ReactiveContext? context, String? name})
{ReactiveContext? context, String? name, EqualityComparer<V>? equals})
: _context = context ?? mainContext,
_atom = _observableMapAtom<K, V>(context, name),
_map = LinkedHashMap.from(other);
_map = LinkedHashMap.from(other),
_equals = equals;

ObservableMap.splayTreeMapFrom(Map<K, V> other,
{int Function(K, K)? compare,
// ignore: avoid_annotating_with_dynamic
bool Function(dynamic)? isValidKey,
ReactiveContext? context,
String? name})
String? name,
EqualityComparer<V>? equals})
: _context = context ?? mainContext,
_atom = _observableMapAtom<K, V>(context, name),
_map = SplayTreeMap.from(other, compare, isValidKey);
_map = SplayTreeMap.from(other, compare, isValidKey),
_equals = equals;

ObservableMap._wrap(this._context, this._map, this._atom);
ObservableMap._wrap(this._context, this._map, this._atom, this._equals);

final ReactiveContext _context;
final Atom _atom;
final Map<K, V> _map;
final EqualityComparer<V>? _equals;

Map<K, V> get nonObservableInner => _map;

Expand Down Expand Up @@ -94,7 +120,7 @@ class ObservableMap<K, V>
}
}

if (!_map.containsKey(key) || value != oldValue) {
if (!_map.containsKey(key) || !_areEquals(value, oldValue)) {
_map[key] = value;
if (type == 'update') {
_reportUpdate(key, value, oldValue);
Expand Down Expand Up @@ -127,8 +153,15 @@ class ObservableMap<K, V>
Iterable<K> get keys => MapKeysIterable(_map.keys, _atom);

@override
Map<RK, RV> cast<RK, RV>() =>
ObservableMap._wrap(_context, super.cast(), _atom);
Map<RK, RV> cast<RK, RV>([EqualityComparer<RV>? equals]) =>
ObservableMap._wrap(
_context,
super.cast(),
_atom,
equals ??
(_equals == null
? null
: (RV? a, RV? b) => _equals!(a as V?, b as V?)));
Copy link
Preview

Copilot AI Sep 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsafe casting from RV? to V? without type checking. If RV and V are incompatible types, this will cause a runtime exception. Should add type compatibility checks or use a safer casting approach.

Suggested change
: (RV? a, RV? b) => _equals!(a as V?, b as V?)));
: (RV? a, RV? b) {
if (a is V? && b is V?) {
return _equals!(a, b);
}
// If types are incompatible, consider them not equal
return false;
}));

Copilot uses AI. Check for mistakes.


@override
V? remove(Object? key) {
Expand Down Expand Up @@ -231,13 +264,21 @@ class ObservableMap<K, V>
}
return _listeners.add(listener);
}

bool _areEquals(V? a, V? b) {
if (_equals != null) {
return _equals!(a, b);
} else {
return equatable(a, b);
}
}
}

/// A convenience method to wrap the standard `Map<K,V>` in an `ObservableMap<K,V>`.
/// This is mostly to aid in testing.
@visibleForTesting
ObservableMap<K, V> wrapInObservableMap<K, V>(Atom atom, Map<K, V> map) =>
ObservableMap._wrap(mainContext, map, atom);
ObservableMap._wrap(mainContext, map, atom, null);

typedef MapChangeListener<K, V> = void Function(MapChange<K, V>);

Expand Down
Loading