From 2de5d3c5773a75567fdfe10b4a279a4974f3fc4d Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 27 Aug 2026 17:24:51 +0700 Subject: [PATCH 1/5] refactor: move signal Listenable support into solidart via package:listen solidart signals now implement package:listen's ValueListenable/ValueNotifier natively (pure Dart), so flutter_solidart no longer needs Flutter-specific signal wrappers. - solidart: ReadableSignal implements ValueListenable; Signal implements ValueNotifier (adds hasListeners/notifyListeners); Computed implements ValueListenable. ListSignal/MapSignal/SetSignal/Resource inherit via Signal. addListener/removeListener are backed by observe(), preserving auto-dispose semantics (a listener keeps an autoDispose signal alive; removing the last one disposes it). notifyListeners snapshots its listeners to stay reentrancy-safe. - flutter_solidart: now just re-exports solidart plus the widgets (SignalBuilder, Show) and the toValueNotifier/toSignal bridge. Deleted the 8 wrapper/mixin files under src/core. - Moved the interface-conformance tests into solidart's pure-Dart suite (asserting listen's types). BREAKING: signals implement listen's Listenable types, not Flutter's, so a Signal is not directly assignable to a Flutter ValueListenableBuilder/ AnimatedBuilder until flutter/flutter#189111 lands. Use SignalBuilder, or the toValueNotifier() bridge, in the meantime. --- .../lib/flutter_solidart.dart | 20 +--- .../lib/src/core/computed.dart | 18 --- .../lib/src/core/list_signal.dart | 18 --- .../lib/src/core/map_signal.dart | 18 --- .../lib/src/core/readable_signal.dart | 28 ----- .../lib/src/core/resource.dart | 35 ------ .../lib/src/core/set_signal.dart | 18 --- .../flutter_solidart/lib/src/core/signal.dart | 48 -------- .../core/value_listenable_signal_mixin.dart | 36 ------ .../src/core/value_notifier_signal_mixin.dart | 46 -------- .../test/flutter_solidart_test.dart | 73 ------------ packages/solidart/lib/src/core/computed.dart | 26 ++++- packages/solidart/lib/src/core/core.dart | 3 + .../solidart/lib/src/core/read_signal.dart | 26 ++++- packages/solidart/lib/src/core/signal.dart | 15 ++- packages/solidart/lib/src/utils.dart | 5 +- packages/solidart/pubspec.yaml | 1 + packages/solidart/test/solidart_test.dart | 109 ++++++++++++++++++ 18 files changed, 179 insertions(+), 364 deletions(-) delete mode 100644 packages/flutter_solidart/lib/src/core/computed.dart delete mode 100644 packages/flutter_solidart/lib/src/core/list_signal.dart delete mode 100644 packages/flutter_solidart/lib/src/core/map_signal.dart delete mode 100644 packages/flutter_solidart/lib/src/core/readable_signal.dart delete mode 100644 packages/flutter_solidart/lib/src/core/resource.dart delete mode 100644 packages/flutter_solidart/lib/src/core/set_signal.dart delete mode 100644 packages/flutter_solidart/lib/src/core/signal.dart delete mode 100644 packages/flutter_solidart/lib/src/core/value_listenable_signal_mixin.dart delete mode 100644 packages/flutter_solidart/lib/src/core/value_notifier_signal_mixin.dart diff --git a/packages/flutter_solidart/lib/flutter_solidart.dart b/packages/flutter_solidart/lib/flutter_solidart.dart index a85edcf6..f6298f7a 100644 --- a/packages/flutter_solidart/lib/flutter_solidart.dart +++ b/packages/flutter_solidart/lib/flutter_solidart.dart @@ -2,26 +2,8 @@ /// Flutter solidart library. library; -export 'package:solidart/solidart.dart' - hide - Computed, - ListSignal, - MapSignal, - ReadableSignal, - Resource, - SetSignal, - Signal, - ToggleBoolSignal; +export 'package:solidart/solidart.dart'; -export 'src/core/computed.dart'; -export 'src/core/list_signal.dart'; -export 'src/core/map_signal.dart'; -export 'src/core/readable_signal.dart'; -export 'src/core/resource.dart'; -export 'src/core/set_signal.dart'; -export 'src/core/signal.dart'; -export 'src/core/value_listenable_signal_mixin.dart'; -export 'src/core/value_notifier_signal_mixin.dart'; export 'src/utils/extensions.dart'; export 'src/widgets/show.dart'; export 'src/widgets/signal_builder.dart'; diff --git a/packages/flutter_solidart/lib/src/core/computed.dart b/packages/flutter_solidart/lib/src/core/computed.dart deleted file mode 100644 index 8ee331b6..00000000 --- a/packages/flutter_solidart/lib/src/core/computed.dart +++ /dev/null @@ -1,18 +0,0 @@ -// coverage:ignore-file -import 'package:flutter_solidart/src/core/value_listenable_signal_mixin.dart'; -import 'package:solidart/solidart.dart' as solidart; - -/// {@macro computed} -class Computed extends solidart.Computed - with ValueListenableSignalMixin { - /// {@macro computed} - Computed( - super.selector, { - super.equals, - super.name, - super.autoDispose, - super.comparator, - super.trackInDevTools, - super.trackPreviousValue, - }); -} diff --git a/packages/flutter_solidart/lib/src/core/list_signal.dart b/packages/flutter_solidart/lib/src/core/list_signal.dart deleted file mode 100644 index b6f24964..00000000 --- a/packages/flutter_solidart/lib/src/core/list_signal.dart +++ /dev/null @@ -1,18 +0,0 @@ -// coverage:ignore-file -import 'package:flutter_solidart/src/core/value_notifier_signal_mixin.dart'; -import 'package:solidart/solidart.dart' as solidart; - -/// {@macro list-signal} -class ListSignal extends solidart.ListSignal - with ValueNotifierSignalMixin> { - /// {@macro list-signal} - ListSignal( - super.initialValue, { - super.equals, - super.name, - super.autoDispose, - super.comparator, - super.trackInDevTools, - super.trackPreviousValue, - }); -} diff --git a/packages/flutter_solidart/lib/src/core/map_signal.dart b/packages/flutter_solidart/lib/src/core/map_signal.dart deleted file mode 100644 index f854a0cd..00000000 --- a/packages/flutter_solidart/lib/src/core/map_signal.dart +++ /dev/null @@ -1,18 +0,0 @@ -// coverage:ignore-file -import 'package:flutter_solidart/src/core/value_notifier_signal_mixin.dart'; -import 'package:solidart/solidart.dart' as solidart; - -/// {@macro map-signal} -class MapSignal extends solidart.MapSignal - with ValueNotifierSignalMixin> { - /// {@macro map-signal} - MapSignal( - super.initialValue, { - super.equals, - super.name, - super.autoDispose, - super.comparator, - super.trackInDevTools, - super.trackPreviousValue, - }); -} diff --git a/packages/flutter_solidart/lib/src/core/readable_signal.dart b/packages/flutter_solidart/lib/src/core/readable_signal.dart deleted file mode 100644 index 4e7ae4b2..00000000 --- a/packages/flutter_solidart/lib/src/core/readable_signal.dart +++ /dev/null @@ -1,28 +0,0 @@ -// coverage:ignore-file -import 'package:flutter_solidart/src/core/value_listenable_signal_mixin.dart'; -import 'package:solidart/solidart.dart' as solidart; - -/// {@macro readsignal} -class ReadableSignal extends solidart.ReadableSignal - with ValueListenableSignalMixin { - /// {@macro readsignal} - ReadableSignal( - super.initialValue, { - super.equals, - super.name, - super.autoDispose, - super.comparator, - super.trackInDevTools, - super.trackPreviousValue, - }); - - /// {@macro readsignal} - ReadableSignal.lazy({ - super.equals, - super.name, - super.autoDispose, - super.comparator, - super.trackInDevTools, - super.trackPreviousValue, - }) : super.lazy(); -} diff --git a/packages/flutter_solidart/lib/src/core/resource.dart b/packages/flutter_solidart/lib/src/core/resource.dart deleted file mode 100644 index d47fde87..00000000 --- a/packages/flutter_solidart/lib/src/core/resource.dart +++ /dev/null @@ -1,35 +0,0 @@ -// coverage:ignore-file -import 'package:flutter_solidart/src/core/value_notifier_signal_mixin.dart'; -import 'package:solidart/solidart.dart' as solidart; - -/// {@macro resource} -class Resource extends solidart.Resource - with ValueNotifierSignalMixin> { - /// {@macro resource} - Resource( - super.fetcher, { - super.equals, - super.name, - super.autoDispose, - super.lazy, - super.trackInDevTools, - super.useRefreshing, - super.debounceDelay, - super.source, - super.trackPreviousState, - }); - - /// {@macro resource} - Resource.stream( - super.stream, { - super.equals, - super.name, - super.autoDispose, - super.lazy, - super.trackInDevTools, - super.useRefreshing, - super.debounceDelay, - super.source, - super.trackPreviousState, - }) : super.stream(); -} diff --git a/packages/flutter_solidart/lib/src/core/set_signal.dart b/packages/flutter_solidart/lib/src/core/set_signal.dart deleted file mode 100644 index 6de44e1e..00000000 --- a/packages/flutter_solidart/lib/src/core/set_signal.dart +++ /dev/null @@ -1,18 +0,0 @@ -// coverage:ignore-file -import 'package:flutter_solidart/src/core/value_notifier_signal_mixin.dart'; -import 'package:solidart/solidart.dart' as solidart; - -/// {@macro set-signal} -class SetSignal extends solidart.SetSignal - with ValueNotifierSignalMixin> { - /// {@macro set-signal} - SetSignal( - super.initialValue, { - super.equals, - super.name, - super.autoDispose, - super.comparator, - super.trackInDevTools, - super.trackPreviousValue, - }); -} diff --git a/packages/flutter_solidart/lib/src/core/signal.dart b/packages/flutter_solidart/lib/src/core/signal.dart deleted file mode 100644 index a7904e17..00000000 --- a/packages/flutter_solidart/lib/src/core/signal.dart +++ /dev/null @@ -1,48 +0,0 @@ -// coverage:ignore-file -import 'package:flutter_solidart/src/core/readable_signal.dart'; -import 'package:flutter_solidart/src/core/value_notifier_signal_mixin.dart'; - -/// Adds the [toggle] method to boolean signals -extension ToggleBoolSignal on Signal { - /// Toggles the signal boolean value. - void toggle() => value = !value; -} - -/// {@macro signal} -class Signal extends ReadableSignal with ValueNotifierSignalMixin { - /// {@macro signal} - Signal( - super.initialValue, { - super.equals, - super.name, - super.autoDispose, - super.comparator, - super.trackInDevTools, - super.trackPreviousValue, - }); - - /// {@macro signal} - Signal.lazy({ - super.equals, - super.name, - super.autoDispose, - super.comparator, - super.trackInDevTools, - super.trackPreviousValue, - }) : super.lazy(); - - /// {@macro set-signal-value} - @override - set value(T newValue) { - setValue(newValue); - } - - /// Calls a function with the current value and assigns the result as the - /// new value. - T updateValue(T Function(T value) callback) => - value = callback(untrackedValue); - - /// Converts this [Signal] into a [ReadableSignal] - /// Use this method to remove the visility to the value setter. - ReadableSignal toReadSignal() => this; -} diff --git a/packages/flutter_solidart/lib/src/core/value_listenable_signal_mixin.dart b/packages/flutter_solidart/lib/src/core/value_listenable_signal_mixin.dart deleted file mode 100644 index 09121250..00000000 --- a/packages/flutter_solidart/lib/src/core/value_listenable_signal_mixin.dart +++ /dev/null @@ -1,36 +0,0 @@ -// coverage:ignore-file -import 'package:flutter/foundation.dart'; -import 'package:solidart/solidart.dart' as solidart; - -/// [ValueNotifier] implementation for [solidart.Signal] -mixin ValueListenableSignalMixin on solidart.ReadSignal - implements ValueListenable { - final _listeners = {}; - - /// If true, the callback will be run when the listener is added - bool get fireImmediately => false; - - @override - void addListener(VoidCallback listener) { - _listeners.putIfAbsent(listener, () { - return observe((_, _) { - listener(); - }, fireImmediately: fireImmediately); - }); - } - - @override - void removeListener(VoidCallback listener) { - final cleanup = _listeners.remove(listener); - cleanup?.call(); - } - - @override - void dispose() { - super.dispose(); - for (final cleanup in _listeners.values) { - cleanup(); - } - _listeners.clear(); - } -} diff --git a/packages/flutter_solidart/lib/src/core/value_notifier_signal_mixin.dart b/packages/flutter_solidart/lib/src/core/value_notifier_signal_mixin.dart deleted file mode 100644 index 1899c14b..00000000 --- a/packages/flutter_solidart/lib/src/core/value_notifier_signal_mixin.dart +++ /dev/null @@ -1,46 +0,0 @@ -// coverage:ignore-file -import 'package:flutter/widgets.dart'; -import 'package:solidart/solidart.dart' as solidart; - -/// [ValueNotifier] implementation for [solidart.Signal] -mixin ValueNotifierSignalMixin on solidart.ReadableSignal - implements ValueNotifier { - final _listeners = {}; - - /// If true, the callback will be run when the listener is added - bool get fireImmediately => false; - - @override - void addListener(VoidCallback listener) { - _listeners.putIfAbsent(listener, () { - return observe((_, _) { - listener(); - }, fireImmediately: fireImmediately); - }); - } - - @override - void removeListener(VoidCallback listener) { - final cleanup = _listeners.remove(listener); - cleanup?.call(); - } - - @override - bool get hasListeners => _listeners.isNotEmpty; - - @override - void notifyListeners() { - for (final listener in _listeners.keys) { - listener(); - } - } - - @override - void dispose() { - super.dispose(); - for (final cleanup in _listeners.values) { - cleanup(); - } - _listeners.clear(); - } -} diff --git a/packages/flutter_solidart/test/flutter_solidart_test.dart b/packages/flutter_solidart/test/flutter_solidart_test.dart index e75ef830..00f4ec76 100644 --- a/packages/flutter_solidart/test/flutter_solidart_test.dart +++ b/packages/flutter_solidart/test/flutter_solidart_test.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:disco/disco.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_solidart/flutter_solidart.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -891,76 +890,4 @@ SignalBuilder must detect at least one Signal, Computed, or Resource during the ), ); }); - - test('Signal is a ValueNotifier', () { - final signal = Signal(0); - expect(signal, isA>()); - expect(signal.value, 0); - var notifiedValue = -1; - void listener() { - notifiedValue = signal.value; - } - - signal.addListener(listener); - signal.value = 1; - expect(notifiedValue, 1); - signal.removeListener(listener); - signal.value = 2; - expect(notifiedValue, 1); // Not updated since listener was removed - }); - - test('Resource is a ValueNotifier', () { - final r = Resource(() => Future.value(0)); - expect(r, isA>>()); - expect(r.state, isA>()); - var notifiedState = r.state; - void listener() { - notifiedState = r.state; - } - - r.addListener(listener); - // Wait for the resource to load - return Future.delayed(const Duration(milliseconds: 10), () { - expect(notifiedState, isA>()); - r.removeListener(listener); - r.refresh(); - expect( - notifiedState, - isA>(), - ); // Not updated since listener was removed - }); - }); - - test('ReadableSignal is a ValueListenable', () { - final signal = Signal(0).toReadSignal(); - expect(signal, isA>()); - expect(signal.value, 0); - var notifiedValue = -1; - void listener() { - notifiedValue = signal.value; - } - - signal.addListener(listener); - signal.dispose(); // Dispose before changing value to test cleanup - expect(notifiedValue, -1); // Not updated since value didn't change - signal.removeListener(listener); - }); - - test('Computed is a ValueListenable', () { - final baseSignal = Signal(1); - final computed = Computed(() => baseSignal.value * 2); - expect(computed, isA>()); - expect(computed.value, 2); - var notifiedValue = -1; - void listener() { - notifiedValue = computed.value; - } - - computed.addListener(listener); - baseSignal.value = 2; - expect(notifiedValue, 4); - computed.removeListener(listener); - baseSignal.value = 3; - expect(notifiedValue, 4); // Not updated since listener was removed - }); } diff --git a/packages/solidart/lib/src/core/computed.dart b/packages/solidart/lib/src/core/computed.dart index 74909bc2..189af896 100644 --- a/packages/solidart/lib/src/core/computed.dart +++ b/packages/solidart/lib/src/core/computed.dart @@ -50,7 +50,7 @@ part of 'core.dart'; /// value, but still contains `false`. /// - If you update the value to `6`, `isGreaterThan5` emits a new `true` value. /// {@endtemplate} -class Computed extends ReadSignal { +class Computed extends ReadSignal implements ValueListenable { /// {@macro computed} Computed( this.selector, { @@ -151,6 +151,12 @@ class Computed extends ReadSignal { cb(); } _onDisposeCallbacks.clear(); + + for (final cleanup in _listeners.values) { + cleanup(); + } + _listeners.clear(); + _notifySignalDisposal(); } @@ -233,6 +239,24 @@ class Computed extends ReadSignal { @override int get listenerCount => _internalComputed.subscriberCount; + // Maps each [ValueListenable] listener to the observation that drives it. + final _listeners = {}; + + /// Whether [addListener] callbacks fire immediately when added. + bool get fireImmediately => false; + + @override + void addListener(VoidCallback listener) { + _listeners.putIfAbsent(listener, () { + return observe((_, _) => listener(), fireImmediately: fireImmediately); + }); + } + + @override + void removeListener(VoidCallback listener) { + _listeners.remove(listener)?.call(); + } + @override void onDispose(VoidCallback cb) { _onDisposeCallbacks.add(cb); diff --git a/packages/solidart/lib/src/core/core.dart b/packages/solidart/lib/src/core/core.dart index 04adce7c..17ffa7e3 100644 --- a/packages/solidart/lib/src/core/core.dart +++ b/packages/solidart/lib/src/core/core.dart @@ -7,6 +7,9 @@ import 'dart:math'; import 'package:alien_signals/preset.dart' as alien; import 'package:alien_signals/system.dart' as alien_system; import 'package:collection/collection.dart'; +// `ErrorCallback` is hidden because solidart declares its own (with a +// different signature) in `src/utils.dart`. +import 'package:listen/listen.dart' hide ErrorCallback; import 'package:meta/meta.dart'; import 'package:solidart/src/extensions/until.dart'; import 'package:solidart/src/utils.dart'; diff --git a/packages/solidart/lib/src/core/read_signal.dart b/packages/solidart/lib/src/core/read_signal.dart index 84ba6e17..a97d01b9 100644 --- a/packages/solidart/lib/src/core/read_signal.dart +++ b/packages/solidart/lib/src/core/read_signal.dart @@ -23,7 +23,7 @@ abstract class ReadSignal extends SignalBase { /// All derived-signals are [ReadableSignal]s because they depend /// on the value of a [Signal]. /// {@endtemplate} -class ReadableSignal implements ReadSignal { +class ReadableSignal implements ReadSignal, ValueListenable { /// {@macro readsignal} ReadableSignal( T initialValue, { @@ -250,6 +250,24 @@ class ReadableSignal implements ReadSignal { @override int get listenerCount => _internalSignal.subscriberCount; + // Maps each [ValueListenable] listener to the observation that drives it. + final _listeners = {}; + + /// Whether [addListener] callbacks fire immediately when added. + bool get fireImmediately => false; + + @override + void addListener(VoidCallback listener) { + _listeners.putIfAbsent(listener, () { + return observe((_, _) => listener(), fireImmediately: fireImmediately); + }); + } + + @override + void removeListener(VoidCallback listener) { + _listeners.remove(listener)?.call(); + } + @override void dispose() { // ignore if already disposed @@ -272,6 +290,12 @@ class ReadableSignal implements ReadSignal { cb(); } _onDisposeCallbacks.clear(); + + for (final cleanup in _listeners.values) { + cleanup(); + } + _listeners.clear(); + _notifySignalDisposal(); } diff --git a/packages/solidart/lib/src/core/signal.dart b/packages/solidart/lib/src/core/signal.dart index 228b7892..317892c7 100644 --- a/packages/solidart/lib/src/core/signal.dart +++ b/packages/solidart/lib/src/core/signal.dart @@ -101,7 +101,7 @@ part of 'core.dart'; /// value, but still contains `false`. /// - If you update the value to `6`, `isGreaterThan5` emits a new `true` value. /// {@endtemplate} -class Signal extends ReadableSignal { +class Signal extends ReadableSignal implements ValueNotifier { /// {@macro signal} Signal( super.initialValue, { @@ -151,8 +151,21 @@ class Signal extends ReadableSignal { }) : super.lazy(); /// {@macro set-signal-value} + @override set value(T newValue) => setValue(newValue); + @override + bool get hasListeners => _listeners.isNotEmpty; + + @override + void notifyListeners() { + // Snapshot the listeners so a listener that adds/removes a listener while + // being notified does not trigger a ConcurrentModificationError. + for (final listener in _listeners.keys.toList()) { + listener(); + } + } + /// Calls a function with the current value and assigns the result as the /// new value. T updateValue(T Function(T value) callback) => diff --git a/packages/solidart/lib/src/utils.dart b/packages/solidart/lib/src/utils.dart index 50413fa8..6021e6a7 100644 --- a/packages/solidart/lib/src/utils.dart +++ b/packages/solidart/lib/src/utils.dart @@ -1,11 +1,8 @@ import 'dart:async'; +import 'package:listen/listen.dart'; import 'package:meta/meta.dart'; -/// coverage:ignore-start -/// Signature of callbacks that have no arguments and return no data. -typedef VoidCallback = void Function(); - /// Error callback typedef ErrorCallback = void Function(Object error); diff --git a/packages/solidart/pubspec.yaml b/packages/solidart/pubspec.yaml index 76abe44c..07decb82 100644 --- a/packages/solidart/pubspec.yaml +++ b/packages/solidart/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: # we depend on the alien signals reactivity implementation because it's the fastest available right now (30/12/2024) alien_signals: ^2.3.1 collection: ^1.18.0 + listen: ^1.0.1 meta: ^1.11.0 dev_dependencies: diff --git a/packages/solidart/test/solidart_test.dart b/packages/solidart/test/solidart_test.dart index 7e9d8e7b..7e6caf5f 100644 --- a/packages/solidart/test/solidart_test.dart +++ b/packages/solidart/test/solidart_test.dart @@ -5,6 +5,7 @@ import 'dart:math'; import 'package:alien_signals/system.dart' as alien_system; import 'package:collection/collection.dart'; +import 'package:listen/listen.dart'; import 'package:meta/meta.dart'; import 'package:mockito/mockito.dart'; import 'package:solidart/src/core/core.dart'; @@ -2408,6 +2409,114 @@ void main() { }, timeout: const Timeout(Duration(seconds: 1)), ); + + group('ValueListenable / ValueNotifier (listen)', () { + test('Signal is a ValueNotifier', () { + final signal = Signal(0); + expect(signal, isA>()); + expect(signal.value, 0); + var notifiedValue = -1; + void listener() => notifiedValue = signal.value; + + signal.addListener(listener); + signal.value = 1; + expect(notifiedValue, 1); + signal.removeListener(listener); + signal.value = 2; + expect(notifiedValue, 1); // Not updated since listener was removed + signal.dispose(); + }); + + test('notifyListeners tolerates a listener removing itself', () { + final signal = Signal(0, autoDispose: false); + var calls = 0; + late void Function() self; + self = () { + calls++; + signal.removeListener(self); + }; + + signal.addListener(self); + signal.notifyListeners(); // must not throw ConcurrentModificationError + expect(calls, 1); + expect(signal.hasListeners, isFalse); + signal.dispose(); + }); + + test('Signal hasListeners and notifyListeners', () { + final signal = Signal(0); + expect(signal.hasListeners, isFalse); + var count = 0; + void listener() => count++; + + signal.addListener(listener); + expect(signal.hasListeners, isTrue); + signal.notifyListeners(); + expect(count, 1); + signal.removeListener(listener); + expect(signal.hasListeners, isFalse); + signal.dispose(); + }); + + test('ReadableSignal is a ValueListenable', () { + final signal = Signal(0).toReadSignal(); + expect(signal, isA>()); + expect(signal.value, 0); + var notifiedValue = -1; + void listener() => notifiedValue = signal.value; + + signal.addListener(listener); + signal.dispose(); // Dispose before changing value to test cleanup + expect(notifiedValue, -1); // Not updated since value didn't change + signal.removeListener(listener); + }); + + test('Computed is a ValueListenable', () { + final baseSignal = Signal(1); + final computed = Computed(() => baseSignal.value * 2); + expect(computed, isA>()); + expect(computed.value, 2); + var notifiedValue = -1; + void listener() => notifiedValue = computed.value; + + computed.addListener(listener); + baseSignal.value = 2; + expect(notifiedValue, 4); + computed.removeListener(listener); + baseSignal.value = 3; + expect(notifiedValue, 4); // Not updated since listener was removed + computed.dispose(); + baseSignal.dispose(); + }); + + test('Resource is a ValueNotifier', () { + final r = Resource(() => Future.value(0)); + expect(r, isA>>()); + r.dispose(); + }); + + test('addListener keeps an autoDispose signal alive; removeListener ' + 'disposes it once no listeners remain', () { + final signal = Signal(0, autoDispose: true); + void listener() {} + + signal.addListener(listener); + expect(signal.disposed, isFalse); + signal.removeListener(listener); + expect(signal.disposed, isTrue); + }); + + test('a non-autoDispose signal stays active after listeners are removed', + () { + final signal = Signal(0); + void listener() {} + + signal.addListener(listener); + signal.removeListener(listener); + expect(signal.disposed, isFalse); + signal.dispose(); + }); + }); } class _AlwaysZeroRandom implements Random { From 34dbc9f7a36765e5ad6bfae8c1c59a82f40bc18e Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 27 Aug 2026 17:48:10 +0700 Subject: [PATCH 2/5] refactor: extract shared _SignalListeners helper to dedup listener wiring ReadableSignal and Computed had identical inline listener plumbing (the _listeners map, addListener/removeListener, dispose cleanup) and Signal held hasListeners/notifyListeners. Extract it all into a single _SignalListeners helper; the classes now delegate to it. Also drop the dead fireImmediately getter (always false, never overridden). Behavior-preserving: the map dedup, the notifyListeners reentrancy snapshot, and the observe()-backed auto-dispose semantics are unchanged. --- packages/solidart/lib/src/core/computed.dart | 21 ++------- packages/solidart/lib/src/core/core.dart | 1 + .../solidart/lib/src/core/read_signal.dart | 21 ++------- packages/solidart/lib/src/core/signal.dart | 10 +--- .../lib/src/core/signal_listeners.dart | 47 +++++++++++++++++++ 5 files changed, 58 insertions(+), 42 deletions(-) create mode 100644 packages/solidart/lib/src/core/signal_listeners.dart diff --git a/packages/solidart/lib/src/core/computed.dart b/packages/solidart/lib/src/core/computed.dart index 189af896..64cfee1c 100644 --- a/packages/solidart/lib/src/core/computed.dart +++ b/packages/solidart/lib/src/core/computed.dart @@ -152,10 +152,7 @@ class Computed extends ReadSignal implements ValueListenable { } _onDisposeCallbacks.clear(); - for (final cleanup in _listeners.values) { - cleanup(); - } - _listeners.clear(); + _listeners.dispose(); _notifySignalDisposal(); } @@ -239,23 +236,13 @@ class Computed extends ReadSignal implements ValueListenable { @override int get listenerCount => _internalComputed.subscriberCount; - // Maps each [ValueListenable] listener to the observation that drives it. - final _listeners = {}; - - /// Whether [addListener] callbacks fire immediately when added. - bool get fireImmediately => false; + late final _listeners = _SignalListeners(this); @override - void addListener(VoidCallback listener) { - _listeners.putIfAbsent(listener, () { - return observe((_, _) => listener(), fireImmediately: fireImmediately); - }); - } + void addListener(VoidCallback listener) => _listeners.add(listener); @override - void removeListener(VoidCallback listener) { - _listeners.remove(listener)?.call(); - } + void removeListener(VoidCallback listener) => _listeners.remove(listener); @override void onDispose(VoidCallback cb) { diff --git a/packages/solidart/lib/src/core/core.dart b/packages/solidart/lib/src/core/core.dart index 17ffa7e3..ff400ba4 100644 --- a/packages/solidart/lib/src/core/core.dart +++ b/packages/solidart/lib/src/core/core.dart @@ -28,5 +28,6 @@ part 'read_signal.dart'; part 'resource.dart'; part 'signal.dart'; part 'signal_base.dart'; +part 'signal_listeners.dart'; part 'extensions.dart'; part 'untracked.dart'; diff --git a/packages/solidart/lib/src/core/read_signal.dart b/packages/solidart/lib/src/core/read_signal.dart index a97d01b9..4733a84f 100644 --- a/packages/solidart/lib/src/core/read_signal.dart +++ b/packages/solidart/lib/src/core/read_signal.dart @@ -250,23 +250,13 @@ class ReadableSignal implements ReadSignal, ValueListenable { @override int get listenerCount => _internalSignal.subscriberCount; - // Maps each [ValueListenable] listener to the observation that drives it. - final _listeners = {}; - - /// Whether [addListener] callbacks fire immediately when added. - bool get fireImmediately => false; + late final _listeners = _SignalListeners(this); @override - void addListener(VoidCallback listener) { - _listeners.putIfAbsent(listener, () { - return observe((_, _) => listener(), fireImmediately: fireImmediately); - }); - } + void addListener(VoidCallback listener) => _listeners.add(listener); @override - void removeListener(VoidCallback listener) { - _listeners.remove(listener)?.call(); - } + void removeListener(VoidCallback listener) => _listeners.remove(listener); @override void dispose() { @@ -291,10 +281,7 @@ class ReadableSignal implements ReadSignal, ValueListenable { } _onDisposeCallbacks.clear(); - for (final cleanup in _listeners.values) { - cleanup(); - } - _listeners.clear(); + _listeners.dispose(); _notifySignalDisposal(); } diff --git a/packages/solidart/lib/src/core/signal.dart b/packages/solidart/lib/src/core/signal.dart index 317892c7..f9e87b17 100644 --- a/packages/solidart/lib/src/core/signal.dart +++ b/packages/solidart/lib/src/core/signal.dart @@ -155,16 +155,10 @@ class Signal extends ReadableSignal implements ValueNotifier { set value(T newValue) => setValue(newValue); @override - bool get hasListeners => _listeners.isNotEmpty; + bool get hasListeners => _listeners.hasListeners; @override - void notifyListeners() { - // Snapshot the listeners so a listener that adds/removes a listener while - // being notified does not trigger a ConcurrentModificationError. - for (final listener in _listeners.keys.toList()) { - listener(); - } - } + void notifyListeners() => _listeners.notify(); /// Calls a function with the current value and assigns the result as the /// new value. diff --git a/packages/solidart/lib/src/core/signal_listeners.dart b/packages/solidart/lib/src/core/signal_listeners.dart new file mode 100644 index 00000000..40db1d65 --- /dev/null +++ b/packages/solidart/lib/src/core/signal_listeners.dart @@ -0,0 +1,47 @@ +part of 'core.dart'; + +/// Backs the [ValueListenable]/[ValueNotifier] listener API shared by signals. +/// +/// Each listener is bridged to a solidart [ObserveSignal.observe] observation, +/// so listeners participate in auto-disposal exactly like any other observer: +/// adding one keeps the signal alive, and removing the last one lets the signal +/// dispose itself when [SignalBase.autoDispose] is enabled. +class _SignalListeners { + _SignalListeners(this._signal); + + final SignalBase _signal; + final _observations = {}; + + /// Whether any listener is currently registered. + bool get hasListeners => _observations.isNotEmpty; + + /// Registers [listener]. Adding the same listener twice is a no-op. + void add(VoidCallback listener) { + _observations.putIfAbsent( + listener, + () => _signal.observe((_, _) => listener()), + ); + } + + /// Unregisters [listener], stopping its observation. No-op if not registered. + void remove(VoidCallback listener) { + _observations.remove(listener)?.call(); + } + + /// Notifies every listener. + void notify() { + // Snapshot so a listener that adds/removes a listener while being notified + // does not trigger a ConcurrentModificationError. + for (final listener in _observations.keys.toList()) { + listener(); + } + } + + /// Stops every observation and clears the registry. + void dispose() { + for (final cleanup in _observations.values) { + cleanup(); + } + _observations.clear(); + } +} From 54da09a8baee55f0e654a551ea79676608341dc6 Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 27 Aug 2026 17:54:30 +0700 Subject: [PATCH 3/5] chore: release solidart & flutter_solidart 3.0.0-dev.2 Bump both packages to 3.0.0-dev.2 with CHANGELOG entries for the package:listen migration. flutter_solidart now requires solidart ^3.0.0-dev.2. --- packages/flutter_solidart/CHANGELOG.md | 6 ++++++ packages/flutter_solidart/pubspec.yaml | 4 ++-- packages/solidart/CHANGELOG.md | 5 +++++ packages/solidart/pubspec.yaml | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/flutter_solidart/CHANGELOG.md b/packages/flutter_solidart/CHANGELOG.md index f97e5c13..cfa1b769 100644 --- a/packages/flutter_solidart/CHANGELOG.md +++ b/packages/flutter_solidart/CHANGELOG.md @@ -1,3 +1,9 @@ +## 3.0.0-dev.2 + +- **BREAKING**: `flutter_solidart` no longer defines its own `Signal`/`ReadableSignal`/`Computed`/`Resource`/`ListSignal`/`MapSignal`/`SetSignal` wrappers, nor the `ValueNotifierSignalMixin`/`ValueListenableSignalMixin` mixins — these are removed. The `Listenable` support now lives in `solidart` (via `package:listen`), so `flutter_solidart` just re-exports `solidart` alongside the widgets (`SignalBuilder`, `Show`) and the `toValueNotifier`/`toSignal` bridge. +- **BREAKING**: Signals now implement `package:listen`'s `ValueNotifier`/`ValueListenable`, which are distinct types from Flutter's until [flutter/flutter#189111](https://github.com/flutter/flutter/pull/189111) lands. Until then a `Signal` is not directly assignable to a Flutter `ValueListenableBuilder`/`AnimatedBuilder`; use `SignalBuilder` or `signal.toValueNotifier()`. +- **CHORE**: Require `solidart: ^3.0.0-dev.2`. + ## 3.0.0-dev.1 - **BREAKING**: Inherits solidart's auto-dispose change — auto-dispose is now opt-in (`SolidartConfig.autoDispose` defaults to `false`). diff --git a/packages/flutter_solidart/pubspec.yaml b/packages/flutter_solidart/pubspec.yaml index c8755f84..ee29c734 100644 --- a/packages/flutter_solidart/pubspec.yaml +++ b/packages/flutter_solidart/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_solidart description: A simple State Management solution for Flutter applications inspired by SolidJS -version: 3.0.0-dev.1 +version: 3.0.0-dev.2 repository: https://github.com/nank1ro/solidart documentation: https://solidart.mariuti.com topics: @@ -18,7 +18,7 @@ dependencies: flutter: sdk: flutter meta: ^1.11.0 - solidart: ^3.0.0-dev.1 + solidart: ^3.0.0-dev.2 dev_dependencies: disco: ^1.0.0 diff --git a/packages/solidart/CHANGELOG.md b/packages/solidart/CHANGELOG.md index 38029c14..a26be873 100644 --- a/packages/solidart/CHANGELOG.md +++ b/packages/solidart/CHANGELOG.md @@ -1,3 +1,8 @@ +## 3.0.0-dev.2 + +- **FEAT**: `Signal`, `ReadableSignal`, `Computed`, `Resource`, and the collection signals now implement `package:listen`'s `ValueListenable`/`ValueNotifier` natively in pure Dart, exposing `addListener`/`removeListener` (plus `hasListeners`/`notifyListeners` on the mutable signals). Listeners participate in auto-disposal: adding one keeps the signal alive, and removing the last one lets an `autoDispose` signal dispose itself. +- **CHORE**: Depend on `package:listen` for the `Listenable`/`ValueNotifier`/`ValueListenable` primitives. Note: these are distinct types from Flutter's `package:flutter/foundation.dart` equivalents until [flutter/flutter#189111](https://github.com/flutter/flutter/pull/189111) lands in a released Flutter, after which the two unify. + ## 3.0.0-dev.1 - **BREAKING**: `SolidartConfig.autoDispose` now defaults to `false` — auto-dispose is opt-in from v3. Enable it globally (`SolidartConfig.autoDispose = true`) or per `Signal`/`Computed`/`Effect` via the `autoDispose` parameter. diff --git a/packages/solidart/pubspec.yaml b/packages/solidart/pubspec.yaml index 07decb82..94fb783e 100644 --- a/packages/solidart/pubspec.yaml +++ b/packages/solidart/pubspec.yaml @@ -1,6 +1,6 @@ name: solidart description: A simple State Management solution for Dart applications inspired by SolidJS -version: 3.0.0-dev.1 +version: 3.0.0-dev.2 repository: https://github.com/nank1ro/solidart documentation: https://solidart.mariuti.com topics: From 4775eba6a960f839c93577e4f83a676200b8ff0d Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 27 Aug 2026 18:00:20 +0700 Subject: [PATCH 4/5] chore: bump solidart_hooks & solidart_lint for the 3.0.0-dev.2 line solidart_hooks 4.0.0-dev.2 and solidart_lint 3.1.0-dev.2, both requiring the new solidart/flutter_solidart ^3.0.0-dev.2 (package:listen migration). --- packages/solidart_hooks/CHANGELOG.md | 4 ++++ packages/solidart_hooks/pubspec.yaml | 4 ++-- packages/solidart_lint/CHANGELOG.md | 4 ++++ packages/solidart_lint/pubspec.yaml | 6 +++--- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/solidart_hooks/CHANGELOG.md b/packages/solidart_hooks/CHANGELOG.md index 21db0323..945d928c 100644 --- a/packages/solidart_hooks/CHANGELOG.md +++ b/packages/solidart_hooks/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.0.0-dev.2 + +- **CHORE**: Require `flutter_solidart: ^3.0.0-dev.2` (the `package:listen` migration). + ## 4.0.0-dev.1 - **CHORE**: Require `flutter_solidart: ^3.0.0-dev.1` (the `alien_signals` 2.3.1 / solidart 3.0 line). diff --git a/packages/solidart_hooks/pubspec.yaml b/packages/solidart_hooks/pubspec.yaml index 2e516d8c..0bb3338e 100644 --- a/packages/solidart_hooks/pubspec.yaml +++ b/packages/solidart_hooks/pubspec.yaml @@ -1,6 +1,6 @@ name: solidart_hooks description: Flutter Hooks bindings for Solidart, suitable for ephemeral state and for writing less boilerplate. -version: 4.0.0-dev.1 +version: 4.0.0-dev.2 repository: https://github.com/nank1ro/solidart documentation: https://solidart.mariuti.com topics: @@ -18,7 +18,7 @@ dependencies: flutter: sdk: flutter flutter_hooks: ^0.21.3+1 - flutter_solidart: ^3.0.0-dev.1 + flutter_solidart: ^3.0.0-dev.2 dev_dependencies: flutter_test: diff --git a/packages/solidart_lint/CHANGELOG.md b/packages/solidart_lint/CHANGELOG.md index b48c365c..5bd2da68 100644 --- a/packages/solidart_lint/CHANGELOG.md +++ b/packages/solidart_lint/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.1.0-dev.2 + +- **CHORE**: Require `solidart: ^3.0.0-dev.2` and `flutter_solidart: ^3.0.0-dev.2` (the `package:listen` migration). + ## 3.1.0-dev.1 - **CHORE**: Require `solidart: ^3.0.0-dev.1` and `flutter_solidart: ^3.0.0-dev.1` (the `alien_signals` 2.3.1 / solidart 3.0 line). diff --git a/packages/solidart_lint/pubspec.yaml b/packages/solidart_lint/pubspec.yaml index 4b841925..fbdf2c39 100644 --- a/packages/solidart_lint/pubspec.yaml +++ b/packages/solidart_lint/pubspec.yaml @@ -1,6 +1,6 @@ name: solidart_lint description: solidart_lint is a developer tool for users of solidart, designed to help stop common issues and simplify repetitive tasks -version: 3.1.0-dev.1 +version: 3.1.0-dev.2 repository: https://github.com/nank1ro/solidart documentation: https://solidart.mariuti.com topics: @@ -27,5 +27,5 @@ dependencies: dev_dependencies: lints: ^6.0.0 test: ^1.25.2 - solidart: ^3.0.0-dev.1 - flutter_solidart: ^3.0.0-dev.1 + solidart: ^3.0.0-dev.2 + flutter_solidart: ^3.0.0-dev.2 From c94a194491923a4ec4aeda034321b65bec0dd388 Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 27 Aug 2026 18:14:51 +0700 Subject: [PATCH 5/5] fix: restore whole-file coverage ignore in utils.dart The migration removed the file's opening coverage:ignore-start (it sat above the deleted VoidCallback) but left the closing coverage:ignore-end, which made the coverage tool throw 'unmatched coverage:ignore-end' during CI. Replace the start/end pair with the idiomatic // coverage:ignore-file directive. --- packages/solidart/lib/src/utils.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/solidart/lib/src/utils.dart b/packages/solidart/lib/src/utils.dart index 6021e6a7..e294f977 100644 --- a/packages/solidart/lib/src/utils.dart +++ b/packages/solidart/lib/src/utils.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'dart:async'; import 'package:listen/listen.dart'; @@ -176,5 +177,3 @@ abstract class Debouncer { return _operations.length; } } - -/// coverage:ignore-end