Summary
ListSignal's read accessors (length, operator[], elementAt, iterator, etc.) read the underlying _value field directly without going through the tracked value getter. This means reading them inside a reactive scope (e.g. SignalBuilder.builder, Effect) does not register a subscription — so the reactive scope never re-runs when the list mutates.
SetSignal and MapSignal do not have this bug — every read accessor calls value; first to trigger tracking. The asymmetry is the smoking gun.
Affected version
solidart 2.8.3 (still present on main at the time of writing).
Reproduction
import 'package:solidart/solidart.dart';
void main() {
final list = ListSignal<int>([1, 2, 3]);
Effect((_) {
// Reading via `.length` is meant to subscribe. It does not.
print('list.length = ${list.length}');
});
list.add(4); // expected: effect re-runs and prints 4. actual: nothing.
}
Same applies to list[0], list.elementAt(0), for (final x in list) …, list.isEmpty (via ListMixin, which calls the un-tracked length getter), list.map(...).reduce(...), etc.
Workaround: read list.value.length / list.value[i] instead.
Root cause
From packages/solidart/lib/src/core/collections/list.dart:
@override
int get length {
return _value.length; // bypasses tracking
}
@override
E elementAt(int index) {
return _value.elementAt(index); // bypasses tracking
}
@override
E operator [](int index) {
return _value[index]; // bypasses tracking
}
@override
Iterator<E> get iterator {
return _value.iterator; // bypasses tracking
}
Compare with SetSignal (set.dart):
@override
int get length {
value; // ← triggers tracking
return _value.length;
}
@override
E elementAt(int index) {
value; // ← triggers tracking
return _value.elementAt(index);
}
And MapSignal (map.dart):
@override
int get length {
value; // ← triggers tracking
return _value.length;
}
@override
bool get isEmpty {
value;
return _value.isEmpty;
}
Impact
Beyond the obvious "loop over a ListSignal" case, this silently defeats reactivity in any code that goes through Dart's ListMixin defaults — .isEmpty, .isNotEmpty, .first, .last, .contains, .fold, .map(...).reduce(...) all funnel through length / elementAt and therefore also skip tracking. Code that looks reactive (and matches the patterns in the README/examples) silently doesn't re-run.
Suggested fix
Add a value; read at the top of every read-side accessor in ListSignal, matching SetSignal/MapSignal. Same for any other read methods that currently go straight to _value (+, sublist, cast, …).
Happy to send a PR if it'd help.
Summary
ListSignal's read accessors (length,operator[],elementAt,iterator, etc.) read the underlying_valuefield directly without going through the trackedvaluegetter. This means reading them inside a reactive scope (e.g.SignalBuilder.builder,Effect) does not register a subscription — so the reactive scope never re-runs when the list mutates.SetSignalandMapSignaldo not have this bug — every read accessor callsvalue;first to trigger tracking. The asymmetry is the smoking gun.Affected version
solidart 2.8.3(still present onmainat the time of writing).Reproduction
Same applies to
list[0],list.elementAt(0),for (final x in list) …,list.isEmpty(viaListMixin, which calls the un-trackedlengthgetter),list.map(...).reduce(...), etc.Workaround: read
list.value.length/list.value[i]instead.Root cause
From
packages/solidart/lib/src/core/collections/list.dart:Compare with
SetSignal(set.dart):And
MapSignal(map.dart):Impact
Beyond the obvious "loop over a
ListSignal" case, this silently defeats reactivity in any code that goes through Dart'sListMixindefaults —.isEmpty,.isNotEmpty,.first,.last,.contains,.fold,.map(...).reduce(...)all funnel throughlength/elementAtand therefore also skip tracking. Code that looks reactive (and matches the patterns in the README/examples) silently doesn't re-run.Suggested fix
Add a
value;read at the top of every read-side accessor inListSignal, matchingSetSignal/MapSignal. Same for any other read methods that currently go straight to_value(+,sublist,cast, …).Happy to send a PR if it'd help.