Skip to content

ListSignal read accessors (length, [], elementAt, iterator) skip tracking — inconsistent with SetSignal / MapSignal #173

@tlvenn

Description

@tlvenn

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions