From 08336b3c66926da085e0602acea7610f2dad0924 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 6 Dec 2025 23:26:56 +0800 Subject: [PATCH 001/121] Replace custom reactive system with alien_signals preset --- packages/solidart/lib/src/core/alien.dart | 77 +++------ packages/solidart/lib/src/core/batch.dart | 4 +- packages/solidart/lib/src/core/computed.dart | 8 +- packages/solidart/lib/src/core/core.dart | 3 +- packages/solidart/lib/src/core/effect.dart | 47 ++++-- .../lib/src/core/reactive_system.dart | 159 ------------------ .../solidart/lib/src/core/read_signal.dart | 33 ++-- packages/solidart/lib/src/core/untracked.dart | 4 +- packages/solidart/pubspec.yaml | 2 +- 9 files changed, 74 insertions(+), 263 deletions(-) diff --git a/packages/solidart/lib/src/core/alien.dart b/packages/solidart/lib/src/core/alien.dart index d6417dfa..9de7d4bd 100644 --- a/packages/solidart/lib/src/core/alien.dart +++ b/packages/solidart/lib/src/core/alien.dart @@ -1,74 +1,35 @@ part of 'core.dart'; -class _AlienComputed extends alien.ReactiveNode implements _AlienUpdatable { - _AlienComputed(this.parent, this.getter) - : super(flags: 17 /* Mutable | Dirty */); +class _AlienComputed extends alien_preset.ComputedNode { + _AlienComputed(this.parent, T Function(T? oldValue) getter) + : super(flags: alien.ReactiveFlags.none, getter: getter); final Computed parent; - final T Function(T? oldValue) getter; - T? value; - - void dispose() => reactiveSystem.stopEffect(this); - - @override - bool update() { - final prevSub = reactiveSystem.setCurrentSub(this); - reactiveSystem.startTracking(this); - try { - final oldValue = value; - return oldValue != (value = getter(oldValue)); - } finally { - reactiveSystem - ..setCurrentSub(prevSub) - ..endTracking(this); - } - } + void dispose() => alien_preset.stop(this); } -class _AlienEffect extends alien.ReactiveNode { - _AlienEffect(this.parent, this.run, {bool? detach}) - : detach = detach ?? SolidartConfig.detachEffects, - super(flags: 2 /* Watching */); - - _AlienEffect? nextEffect; +class _AlienEffect extends alien_preset.EffectNode { + _AlienEffect( + this.parent, + {required super.fn, + bool? detach, + required super.flags, + }) : detach = detach ?? SolidartConfig.detachEffects; final bool detach; final Effect parent; - final void Function() run; - void dispose() => reactiveSystem.stopEffect(this); + void dispose() => alien_preset.stop(this); } -class _AlienSignal extends alien.ReactiveNode implements _AlienUpdatable { - _AlienSignal(this.parent, this.value) - : previousValue = value, - super(flags: 1 /* Mutable */); +class _AlienSignal extends alien_preset.SignalNode> { + _AlienSignal(this.parent, Option value) + : super( + flags: alien.ReactiveFlags.mutable, + currentValue: value, + pendingValue: value, + ); final SignalBase parent; - - Option previousValue; - Option value; - - bool forceDirty = false; - - @override - bool update() { - flags = 1 /* Mutable */; - if (forceDirty) { - forceDirty = false; - return true; - } - if (!parent._compare(previousValue.safeUnwrap(), value.safeUnwrap())) { - previousValue = value; - return true; - } - - return false; - } -} - -// ignore: one_member_abstracts -abstract interface class _AlienUpdatable { - bool update(); } diff --git a/packages/solidart/lib/src/core/batch.dart b/packages/solidart/lib/src/core/batch.dart index 3c874676..37f03f58 100644 --- a/packages/solidart/lib/src/core/batch.dart +++ b/packages/solidart/lib/src/core/batch.dart @@ -21,10 +21,10 @@ part of 'core.dart'; /// So when `x` changes, the effect is paused and you never see it printing: /// "x = 11, y = 20". T batch(T Function() fn) { - reactiveSystem.startBatch(); + alien_preset.startBatch(); try { return fn(); } finally { - reactiveSystem.endBatch(); + alien_preset.endBatch(); } } diff --git a/packages/solidart/lib/src/core/computed.dart b/packages/solidart/lib/src/core/computed.dart index 1df6260a..450fc314 100644 --- a/packages/solidart/lib/src/core/computed.dart +++ b/packages/solidart/lib/src/core/computed.dart @@ -152,7 +152,13 @@ class Computed extends ReadSignal { return _untrackedValue; } - final value = reactiveSystem.getComputedValue(_internalComputed); + if ((_internalComputed.flags & alien.ReactiveFlags.pending) != + alien.ReactiveFlags.none && + _internalComputed.deps == null) { + _internalComputed.flags &= ~alien.ReactiveFlags.pending; + } + + final value = _internalComputed.get(); if (autoDispose) { _mayDispose(); } diff --git a/packages/solidart/lib/src/core/core.dart b/packages/solidart/lib/src/core/core.dart index 18a6a069..e62105bb 100644 --- a/packages/solidart/lib/src/core/core.dart +++ b/packages/solidart/lib/src/core/core.dart @@ -4,7 +4,8 @@ import 'dart:convert'; import 'dart:developer' as dev; import 'dart:math'; -import 'package:alien_signals/alien_signals.dart' as alien; +import 'package:alien_signals/preset.dart' as alien_preset; +import 'package:alien_signals/system.dart' as alien; import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; import 'package:solidart/src/extensions/until.dart'; diff --git a/packages/solidart/lib/src/core/effect.dart b/packages/solidart/lib/src/core/effect.dart index 16b2eba4..1ca791f2 100644 --- a/packages/solidart/lib/src/core/effect.dart +++ b/packages/solidart/lib/src/core/effect.dart @@ -148,7 +148,26 @@ class Effect implements ReactionInterface { ErrorCallback? onError, bool? detach, }) : _onError = onError { - _internalEffect = _AlienEffect(this, callback, detach: detach); + VoidCallback safeCallback() { + return () { + try { + callback(); + } catch (e, s) { + if (_onError != null) { + _onError!.call(SolidartCaughtException(e, stackTrace: s)); + return; + } + rethrow; + } + }; + } + + _internalEffect = _AlienEffect( + this, + fn: safeCallback(), + detach: detach, + flags: alien.ReactiveFlags.watching | alien.ReactiveFlags.dirty, + ); } /// The name of the effect, useful for logging purposes. @@ -175,25 +194,21 @@ class Effect implements ReactionInterface { /// Runs the effect, tracking any signal read during the execution. void run() { - final currentSub = reactiveSystem.activeSub; - if (!SolidartConfig.detachEffects && currentSub != null) { - if (currentSub is! _AlienEffect || - (!_internalEffect.detach && !currentSub.detach)) { - reactiveSystem.link(_internalEffect, currentSub); - } + final currentSub = alien_preset.getActiveSub(); + if (!SolidartConfig.detachEffects && + currentSub != null && + (currentSub is! _AlienEffect || + (!_internalEffect.detach && !currentSub.detach))) { + alien_preset.link(_internalEffect, currentSub, alien_preset.cycle); } - final prevSub = reactiveSystem.setCurrentSub(_internalEffect); try { - _internalEffect.run(); - } catch (e, s) { - if (_onError != null) { - _onError.call(SolidartCaughtException(e, stackTrace: s)); - } else { - rethrow; - } + alien_preset.run(_internalEffect); + } catch (_) { + // The callback handles the error reporting, just rethrow to preserve + // the behavior when no handler is provided. + rethrow; } finally { - reactiveSystem.setCurrentSub(prevSub); if (SolidartConfig.autoDispose) { _mayDispose(); } diff --git a/packages/solidart/lib/src/core/reactive_system.dart b/packages/solidart/lib/src/core/reactive_system.dart index 25d88943..17a9d075 100644 --- a/packages/solidart/lib/src/core/reactive_system.dart +++ b/packages/solidart/lib/src/core/reactive_system.dart @@ -42,162 +42,3 @@ class ReactiveName { return '$prefix@${_instance.nextId}'; } } - -@protected -final reactiveSystem = ReactiveSystem(); - -class ReactiveSystem extends alien.ReactiveSystem { - int batchDepth = 0; - alien.ReactiveNode? activeSub; - _AlienEffect? queuedEffects; - _AlienEffect? queuedEffectsTail; - - @override - void notify(alien.ReactiveNode node) { - final flags = node.flags; - if ((flags & 64 /* Queued */ ) == 0) { - node.flags = flags | 64 /* Queued */; - final subs = node.subs; - if (subs != null) { - notify(subs.sub); - } else if (queuedEffectsTail != null) { - queuedEffectsTail = queuedEffectsTail!.nextEffect = - node as _AlienEffect; - } else { - queuedEffectsTail = queuedEffects = node as _AlienEffect; - } - } - } - - @override - void unwatched(alien.ReactiveNode node) { - if (node is _AlienComputed) { - var toRemove = node.deps; - if (toRemove != null) { - node.flags = 17 /* Mutable | Dirty */; - do { - toRemove = unlink(toRemove!, node); - } while (toRemove != null); - } - } else if (node is! _AlienSignal) { - stopEffect(node); - } - } - - @override - bool update(alien.ReactiveNode node) { - assert( - node is _AlienUpdatable, - 'Reactive node type must be signal or computed', - ); - return (node as _AlienUpdatable).update(); - } - - void startBatch() => ++batchDepth; - void endBatch() { - if ((--batchDepth) == 0) flush(); - } - - alien.ReactiveNode? setCurrentSub(alien.ReactiveNode? sub) { - final prevSub = activeSub; - activeSub = sub; - return prevSub; - } - - T getComputedValue(_AlienComputed computed) { - final flags = computed.flags; - if ((flags & 16 /* Dirty */ ) != 0 || - ((flags & 32 /* Pending */ ) != 0 && - computed.deps != null && - checkDirty(computed.deps!, computed))) { - if (computed.update()) { - final subs = computed.subs; - if (subs != null) shallowPropagate(subs); - } - } else if ((flags & 32 /* Pending */ ) != 0) { - computed.flags = flags & -33 /* ~Pending */; - } - if (activeSub != null) { - link(computed, activeSub!); - } - - return computed.value as T; - } - - Option getSignalValue(_AlienSignal signal) { - final value = signal.value; - if ((signal.flags & 16 /* Dirty */ ) != 0) { - if (signal.update()) { - final subs = signal.subs; - if (subs != null) shallowPropagate(subs); - } - } - - if (activeSub != null) link(signal, activeSub!); - return value; - } - - void setSignalValue(_AlienSignal signal, Option value) { - if (signal.value != (signal.value = value)) { - signal.flags = 17 /* Mutable | Dirty */; - final subs = signal.subs; - if (subs != null) { - propagate(subs); - if (batchDepth == 0) flush(); - } - } - } - - void stopEffect(alien.ReactiveNode effect) { - assert(effect is! _AlienSignal, 'Reactive node type not matched'); - var dep = effect.deps; - while (dep != null) { - dep = unlink(dep, effect); - } - - final sub = effect.subs; - if (sub != null) unlink(sub, effect); - effect.flags = 0 /* None */; - } - - void run(alien.ReactiveNode effect, int flags) { - if ((flags & 16 /* Dirty */ ) != 0 || - ((flags & 32 /* Pending */ ) != 0 && - effect.deps != null && - checkDirty(effect.deps!, effect))) { - final prevSub = setCurrentSub(effect); - startTracking(effect); - try { - (effect as _AlienEffect).run(); - } finally { - activeSub = prevSub; - endTracking(effect); - } - return; - } else if ((flags & 32 /* Pending */ ) != 0) { - effect.flags = flags & -33 /* ~Pending */; - } - var link = effect.deps; - while (link != null) { - final dep = link.dep; - final depFlags = dep.flags; - if ((depFlags & 64 /* Queued */ ) != 0) { - run(dep, dep.flags = depFlags & -65 /* ~Queued */); - } - link = link.nextDep; - } - } - - void flush() { - while (queuedEffects != null) { - final effect = queuedEffects!; - if ((queuedEffects = effect.nextEffect) != null) { - effect.nextEffect = null; - } else { - queuedEffectsTail = null; - } - - run(effect, effect.flags &= -65 /* ~Queued */); - } - } -} diff --git a/packages/solidart/lib/src/core/read_signal.dart b/packages/solidart/lib/src/core/read_signal.dart index 9f1b9cec..0edba55b 100644 --- a/packages/solidart/lib/src/core/read_signal.dart +++ b/packages/solidart/lib/src/core/read_signal.dart @@ -118,7 +118,7 @@ class ReadableSignal implements ReadSignal { @override bool get hasValue { - _reportObserved(); + _internalSignal.get(); return _hasValue; } @@ -133,11 +133,10 @@ class ReadableSignal implements ReadSignal { T get _value { if (_disposed) { return untracked( - () => reactiveSystem.getSignalValue(_internalSignal).unwrap(), + () => _internalSignal.get().unwrap(), ); } - _reportObserved(); - final value = reactiveSystem.getSignalValue(_internalSignal).unwrap(); + final value = _internalSignal.get().unwrap(); if (autoDispose) { _subs.clear(); @@ -174,7 +173,7 @@ class ReadableSignal implements ReadSignal { set _value(T newValue) { _untrackedPreviousValue = _untrackedValue; _untrackedValue = newValue; - reactiveSystem.setSignalValue(_internalSignal, Some(newValue)); + _internalSignal.set(Some(newValue)); } @override @@ -270,7 +269,7 @@ class ReadableSignal implements ReadSignal { // This will dispose the signal untracked(() { - reactiveSystem.getSignalValue(_internalSignal); + _internalSignal.get(); }); if (SolidartConfig.autoDispose) { @@ -321,12 +320,6 @@ class ReadableSignal implements ReadSignal { _onDisposeCallbacks.add(cb); } - void _reportObserved() { - if (reactiveSystem.activeSub != null) { - reactiveSystem.link(_internalSignal, reactiveSystem.activeSub!); - } - } - /// Forces a change notification even when the value /// hasn't substantially changed. /// @@ -336,21 +329,15 @@ class ReadableSignal implements ReadSignal { // ignore: comment_references /// use [reactiveSystem.setSignalValue] instead. void _reportChanged() { - _internalSignal.forceDirty = true; - _internalSignal.flags = 17 /* Mutable | Dirty */; - final subs = _internalSignal.subs; - if (subs != null) { - // coverage:ignore-start - reactiveSystem.propagate(subs); - if (reactiveSystem.batchDepth == 0) { - reactiveSystem.flush(); - } - // coverage:ignore-end - } + _internalSignal.set(Some(_untrackedValue)); } /// Indicates if the signal should update its value. bool shouldUpdate() { + if ((_internalSignal.flags & alien.ReactiveFlags.dirty) == + alien.ReactiveFlags.none) { + return false; + } return _internalSignal.update(); } diff --git a/packages/solidart/lib/src/core/untracked.dart b/packages/solidart/lib/src/core/untracked.dart index ed833142..290f32fe 100644 --- a/packages/solidart/lib/src/core/untracked.dart +++ b/packages/solidart/lib/src/core/untracked.dart @@ -5,10 +5,10 @@ part of 'core.dart'; /// This can be useful inside Effects or Observations to prevent a signal from /// being tracked. T untracked(T Function() callback) { - final prevSub = reactiveSystem.setCurrentSub(null); + final prevSub = alien_preset.setActiveSub(null); try { return callback(); } finally { - reactiveSystem.setCurrentSub(prevSub); + alien_preset.setActiveSub(prevSub); } } diff --git a/packages/solidart/pubspec.yaml b/packages/solidart/pubspec.yaml index 4a424839..6757d795 100644 --- a/packages/solidart/pubspec.yaml +++ b/packages/solidart/pubspec.yaml @@ -14,7 +14,7 @@ resolution: workspace dependencies: # we depend on the alien signals reactivity implementation because it's the fastest available right now (30/12/2024) - alien_signals: ^0.5.4 + alien_signals: ^2.0.1 collection: ^1.18.0 meta: ^1.11.0 From 10f733f17df5d966e1a9841b4d16478b39d77318 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 6 Dec 2025 23:38:54 +0800 Subject: [PATCH 002/121] Migrate to alien_signals for reactive system management --- .../lib/src/widgets/signal_builder.dart | 11 +++++++---- packages/flutter_solidart/pubspec.yaml | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart index 559d2b74..74b3bb62 100644 --- a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart +++ b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart @@ -1,5 +1,7 @@ // ignore_for_file: document_ignores +import 'package:alien_signals/preset.dart' as alien_preset; +import 'package:alien_signals/system.dart' as alien; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; import 'package:solidart/solidart.dart'; @@ -83,9 +85,9 @@ class _SignalBuilderElement extends StatelessElement { @override Widget build() { - final prevSub = reactiveSystem.activeSub; - // ignore: invalid_use_of_protected_member - final node = reactiveSystem.activeSub = effect.subscriber; + final prevSub = alien_preset.getActiveSub(); + final node = effect.subscriber; + alien_preset.setActiveSub(node); try { final built = super.build(); @@ -98,10 +100,11 @@ You can disable this check by setting `SolidartConfig.assertSignalBuilderWithout } // ignore: invalid_use_of_internal_member effect.setDependencies(node); + node.flags = alien.ReactiveFlags.watching; return built; } finally { - reactiveSystem.activeSub = prevSub; + alien_preset.setActiveSub(prevSub); } } } diff --git a/packages/flutter_solidart/pubspec.yaml b/packages/flutter_solidart/pubspec.yaml index 96bd8db7..6b5b5fe6 100644 --- a/packages/flutter_solidart/pubspec.yaml +++ b/packages/flutter_solidart/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: flutter: sdk: flutter meta: ^1.11.0 - solidart: ^2.8.2 + solidart: ^2.8.3 dev_dependencies: disco: ^1.0.0 From 0b3795e097449279e7e6e32abb34fc3f35b2b7b4 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 6 Dec 2025 23:51:30 +0800 Subject: [PATCH 003/121] Replace alien_signals imports with internal deps re-exports --- .../lib/src/widgets/signal_builder.dart | 12 ++++++------ packages/solidart/lib/deps/preset.dart | 1 + packages/solidart/lib/deps/system.dart | 1 + packages/solidart/lib/src/core/alien.dart | 14 +++++++------- packages/solidart/lib/src/core/batch.dart | 4 ++-- packages/solidart/lib/src/core/computed.dart | 8 ++++---- packages/solidart/lib/src/core/core.dart | 4 ++-- packages/solidart/lib/src/core/effect.dart | 14 +++++++------- .../solidart/lib/src/core/reactive_system.dart | 8 ++++---- packages/solidart/lib/src/core/read_signal.dart | 6 +++--- packages/solidart/lib/src/core/untracked.dart | 4 ++-- 11 files changed, 39 insertions(+), 37 deletions(-) create mode 100644 packages/solidart/lib/deps/preset.dart create mode 100644 packages/solidart/lib/deps/system.dart diff --git a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart index 74b3bb62..f6f847c6 100644 --- a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart +++ b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart @@ -1,7 +1,7 @@ // ignore_for_file: document_ignores -import 'package:alien_signals/preset.dart' as alien_preset; -import 'package:alien_signals/system.dart' as alien; +import 'package:solidart/deps/preset.dart' as preset; +import 'package:solidart/deps/system.dart' as system; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; import 'package:solidart/solidart.dart'; @@ -85,9 +85,9 @@ class _SignalBuilderElement extends StatelessElement { @override Widget build() { - final prevSub = alien_preset.getActiveSub(); + final prevSub = preset.getActiveSub(); final node = effect.subscriber; - alien_preset.setActiveSub(node); + preset.setActiveSub(node); try { final built = super.build(); @@ -100,11 +100,11 @@ You can disable this check by setting `SolidartConfig.assertSignalBuilderWithout } // ignore: invalid_use_of_internal_member effect.setDependencies(node); - node.flags = alien.ReactiveFlags.watching; + node.flags = system.ReactiveFlags.watching; return built; } finally { - alien_preset.setActiveSub(prevSub); + preset.setActiveSub(prevSub); } } } diff --git a/packages/solidart/lib/deps/preset.dart b/packages/solidart/lib/deps/preset.dart new file mode 100644 index 00000000..484f2f55 --- /dev/null +++ b/packages/solidart/lib/deps/preset.dart @@ -0,0 +1 @@ +export 'package:alien_signals/preset.dart'; diff --git a/packages/solidart/lib/deps/system.dart b/packages/solidart/lib/deps/system.dart new file mode 100644 index 00000000..2b6b1a29 --- /dev/null +++ b/packages/solidart/lib/deps/system.dart @@ -0,0 +1 @@ +export 'package:alien_signals/system.dart'; diff --git a/packages/solidart/lib/src/core/alien.dart b/packages/solidart/lib/src/core/alien.dart index 9de7d4bd..4c0ce153 100644 --- a/packages/solidart/lib/src/core/alien.dart +++ b/packages/solidart/lib/src/core/alien.dart @@ -1,15 +1,15 @@ part of 'core.dart'; -class _AlienComputed extends alien_preset.ComputedNode { +class _AlienComputed extends preset.ComputedNode { _AlienComputed(this.parent, T Function(T? oldValue) getter) - : super(flags: alien.ReactiveFlags.none, getter: getter); + : super(flags: system.ReactiveFlags.none, getter: getter); final Computed parent; - void dispose() => alien_preset.stop(this); + void dispose() => preset.stop(this); } -class _AlienEffect extends alien_preset.EffectNode { +class _AlienEffect extends preset.EffectNode { _AlienEffect( this.parent, {required super.fn, @@ -20,13 +20,13 @@ class _AlienEffect extends alien_preset.EffectNode { final bool detach; final Effect parent; - void dispose() => alien_preset.stop(this); + void dispose() => preset.stop(this); } -class _AlienSignal extends alien_preset.SignalNode> { +class _AlienSignal extends preset.SignalNode> { _AlienSignal(this.parent, Option value) : super( - flags: alien.ReactiveFlags.mutable, + flags: system.ReactiveFlags.mutable, currentValue: value, pendingValue: value, ); diff --git a/packages/solidart/lib/src/core/batch.dart b/packages/solidart/lib/src/core/batch.dart index 37f03f58..8cd4fcfb 100644 --- a/packages/solidart/lib/src/core/batch.dart +++ b/packages/solidart/lib/src/core/batch.dart @@ -21,10 +21,10 @@ part of 'core.dart'; /// So when `x` changes, the effect is paused and you never see it printing: /// "x = 11, y = 20". T batch(T Function() fn) { - alien_preset.startBatch(); + preset.startBatch(); try { return fn(); } finally { - alien_preset.endBatch(); + preset.endBatch(); } } diff --git a/packages/solidart/lib/src/core/computed.dart b/packages/solidart/lib/src/core/computed.dart index 450fc314..b7473aa2 100644 --- a/packages/solidart/lib/src/core/computed.dart +++ b/packages/solidart/lib/src/core/computed.dart @@ -124,7 +124,7 @@ class Computed extends ReadSignal { @override bool get hasValue => true; - final _deps = {}; + final _deps = {}; @override void dispose() { @@ -152,10 +152,10 @@ class Computed extends ReadSignal { return _untrackedValue; } - if ((_internalComputed.flags & alien.ReactiveFlags.pending) != - alien.ReactiveFlags.none && + if ((_internalComputed.flags & system.ReactiveFlags.pending) != + system.ReactiveFlags.none && _internalComputed.deps == null) { - _internalComputed.flags &= ~alien.ReactiveFlags.pending; + _internalComputed.flags &= ~system.ReactiveFlags.pending; } final value = _internalComputed.get(); diff --git a/packages/solidart/lib/src/core/core.dart b/packages/solidart/lib/src/core/core.dart index e62105bb..b29fb58a 100644 --- a/packages/solidart/lib/src/core/core.dart +++ b/packages/solidart/lib/src/core/core.dart @@ -4,8 +4,8 @@ import 'dart:convert'; import 'dart:developer' as dev; import 'dart:math'; -import 'package:alien_signals/preset.dart' as alien_preset; -import 'package:alien_signals/system.dart' as alien; +import 'package:solidart/deps/preset.dart' as preset; +import 'package:solidart/deps/system.dart' as system; import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; import 'package:solidart/src/extensions/until.dart'; diff --git a/packages/solidart/lib/src/core/effect.dart b/packages/solidart/lib/src/core/effect.dart index 1ca791f2..7a7bfe2c 100644 --- a/packages/solidart/lib/src/core/effect.dart +++ b/packages/solidart/lib/src/core/effect.dart @@ -166,7 +166,7 @@ class Effect implements ReactionInterface { this, fn: safeCallback(), detach: detach, - flags: alien.ReactiveFlags.watching | alien.ReactiveFlags.dirty, + flags: system.ReactiveFlags.watching | system.ReactiveFlags.dirty, ); } @@ -183,27 +183,27 @@ class Effect implements ReactionInterface { late final _AlienEffect _internalEffect; - final _deps = {}; + final _deps = {}; /// The subscriber of the effect, do not use it directly. @protected - alien.ReactiveNode get subscriber => _internalEffect; + system.ReactiveNode get subscriber => _internalEffect; @override bool get disposed => _disposed; /// Runs the effect, tracking any signal read during the execution. void run() { - final currentSub = alien_preset.getActiveSub(); + final currentSub = preset.getActiveSub(); if (!SolidartConfig.detachEffects && currentSub != null && (currentSub is! _AlienEffect || (!_internalEffect.detach && !currentSub.detach))) { - alien_preset.link(_internalEffect, currentSub, alien_preset.cycle); + preset.link(_internalEffect, currentSub, preset.cycle); } try { - alien_preset.run(_internalEffect); + preset.run(_internalEffect); } catch (_) { // The callback handles the error reporting, just rethrow to preserve // the behavior when no handler is provided. @@ -217,7 +217,7 @@ class Effect implements ReactionInterface { /// Sets the dependencies of the effect, do not use it directly. @internal - void setDependencies(alien.ReactiveNode node) { + void setDependencies(system.ReactiveNode node) { _deps ..clear() ..addAll(node.getDependencies()); diff --git a/packages/solidart/lib/src/core/reactive_system.dart b/packages/solidart/lib/src/core/reactive_system.dart index 17a9d075..fbc72b97 100644 --- a/packages/solidart/lib/src/core/reactive_system.dart +++ b/packages/solidart/lib/src/core/reactive_system.dart @@ -3,17 +3,17 @@ // Reactive flags map: https://github.com/medz/alien-signals-dart/blob/main/flags.md part of 'core.dart'; -extension MayDisposeDependencies on alien.ReactiveNode { - Iterable getDependencies() { +extension MayDisposeDependencies on system.ReactiveNode { + Iterable getDependencies() { var link = deps; - final foundDeps = {}; + final foundDeps = {}; for (; link != null; link = link.nextDep) { foundDeps.add(link.dep); } return foundDeps; } - void mayDisposeDependencies([Iterable? include]) { + void mayDisposeDependencies([Iterable? include]) { final dependencies = {...getDependencies(), ...?include}; for (final dep in dependencies) { switch (dep) { diff --git a/packages/solidart/lib/src/core/read_signal.dart b/packages/solidart/lib/src/core/read_signal.dart index 0edba55b..3fe73ba8 100644 --- a/packages/solidart/lib/src/core/read_signal.dart +++ b/packages/solidart/lib/src/core/read_signal.dart @@ -259,7 +259,7 @@ class ReadableSignal implements ReadSignal { @override int get listenerCount => _subs.length; - final _subs = {}; + final _subs = {}; @override void dispose() { @@ -334,8 +334,8 @@ class ReadableSignal implements ReadSignal { /// Indicates if the signal should update its value. bool shouldUpdate() { - if ((_internalSignal.flags & alien.ReactiveFlags.dirty) == - alien.ReactiveFlags.none) { + if ((_internalSignal.flags & system.ReactiveFlags.dirty) == + system.ReactiveFlags.none) { return false; } return _internalSignal.update(); diff --git a/packages/solidart/lib/src/core/untracked.dart b/packages/solidart/lib/src/core/untracked.dart index 290f32fe..e4b296c4 100644 --- a/packages/solidart/lib/src/core/untracked.dart +++ b/packages/solidart/lib/src/core/untracked.dart @@ -5,10 +5,10 @@ part of 'core.dart'; /// This can be useful inside Effects or Observations to prevent a signal from /// being tracked. T untracked(T Function() callback) { - final prevSub = alien_preset.setActiveSub(null); + final prevSub = preset.setActiveSub(null); try { return callback(); } finally { - alien_preset.setActiveSub(prevSub); + preset.setActiveSub(prevSub); } } From 1de6865a9465e72c7ea8225b211a2293d37b17ca Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 7 Dec 2025 00:23:40 +0800 Subject: [PATCH 004/121] Refactor Effect to extend preset.EffectNode directly - Remove _AlienEffect wrapper class - Simplify delayed callback logic - Improve dependency cleanup in ReadableSignal - Reorder imports consistently --- .../lib/src/widgets/signal_builder.dart | 6 +- packages/solidart/lib/src/core/alien.dart | 14 -- packages/solidart/lib/src/core/computed.dart | 23 ++-- packages/solidart/lib/src/core/core.dart | 4 +- packages/solidart/lib/src/core/effect.dart | 125 +++++++----------- .../lib/src/core/reactive_system.dart | 2 +- .../solidart/lib/src/core/read_signal.dart | 12 +- .../solidart/lib/src/core/signal_base.dart | 1 + packages/solidart/lib/src/core/untracked.dart | 2 +- 9 files changed, 74 insertions(+), 115 deletions(-) diff --git a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart index f6f847c6..8abef8b3 100644 --- a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart +++ b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart @@ -1,9 +1,9 @@ // ignore_for_file: document_ignores -import 'package:solidart/deps/preset.dart' as preset; -import 'package:solidart/deps/system.dart' as system; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; +import 'package:solidart/deps/preset.dart' as preset; +import 'package:solidart/deps/system.dart' as system; import 'package:solidart/solidart.dart'; /// {@template signalbuilder} @@ -86,7 +86,7 @@ class _SignalBuilderElement extends StatelessElement { @override Widget build() { final prevSub = preset.getActiveSub(); - final node = effect.subscriber; + final node = effect; preset.setActiveSub(node); try { diff --git a/packages/solidart/lib/src/core/alien.dart b/packages/solidart/lib/src/core/alien.dart index 4c0ce153..efae3b1c 100644 --- a/packages/solidart/lib/src/core/alien.dart +++ b/packages/solidart/lib/src/core/alien.dart @@ -9,20 +9,6 @@ class _AlienComputed extends preset.ComputedNode { void dispose() => preset.stop(this); } -class _AlienEffect extends preset.EffectNode { - _AlienEffect( - this.parent, - {required super.fn, - bool? detach, - required super.flags, - }) : detach = detach ?? SolidartConfig.detachEffects; - - final bool detach; - final Effect parent; - - void dispose() => preset.stop(this); -} - class _AlienSignal extends preset.SignalNode> { _AlienSignal(this.parent, Option value) : super( diff --git a/packages/solidart/lib/src/core/computed.dart b/packages/solidart/lib/src/core/computed.dart index b7473aa2..c9448176 100644 --- a/packages/solidart/lib/src/core/computed.dart +++ b/packages/solidart/lib/src/core/computed.dart @@ -1,4 +1,5 @@ part of 'core.dart'; +// ignore_for_file: unused_element /// {@template computed} /// A special Signal that notifies only whenever the selected @@ -229,20 +230,6 @@ class Computed extends ReadSignal { _onDisposeCallbacks.add(cb); } - // coverage:ignore-start - /// Indicates if the [oldValue] and the [newValue] are equal - @override - bool _compare(T? oldValue, T? newValue) { - // skip if the value are equals - if (equals) { - return oldValue == newValue; - } - - // return the [comparator] result - return comparator(oldValue, newValue); - } - // coverage:ignore-end - /// Manually runs the computed to update its value. /// This is usually not necessary, as the computed will automatically /// update when its dependencies change. @@ -260,4 +247,12 @@ class Computed extends ReadSignal { value; return '''Computed<$T>(value: $untrackedValue, previousValue: $untrackedPreviousValue)'''; } + + @override + bool _compare(T? oldValue, T? newValue) { + if (equals) { + return oldValue == newValue; + } + return comparator(oldValue, newValue); + } } diff --git a/packages/solidart/lib/src/core/core.dart b/packages/solidart/lib/src/core/core.dart index b29fb58a..95e58b02 100644 --- a/packages/solidart/lib/src/core/core.dart +++ b/packages/solidart/lib/src/core/core.dart @@ -4,10 +4,10 @@ import 'dart:convert'; import 'dart:developer' as dev; import 'dart:math'; -import 'package:solidart/deps/preset.dart' as preset; -import 'package:solidart/deps/system.dart' as system; import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; +import 'package:solidart/deps/preset.dart' as preset; +import 'package:solidart/deps/system.dart' as system; import 'package:solidart/src/extensions/until.dart'; import 'package:solidart/src/utils.dart'; diff --git a/packages/solidart/lib/src/core/effect.dart b/packages/solidart/lib/src/core/effect.dart index 7a7bfe2c..d90f33f1 100644 --- a/packages/solidart/lib/src/core/effect.dart +++ b/packages/solidart/lib/src/core/effect.dart @@ -64,7 +64,7 @@ abstract class ReactionInterface { /// /// > An effect is useless after it is disposed, you must not use it anymore. /// {@endtemplate} -class Effect implements ReactionInterface { +class Effect extends preset.EffectNode implements ReactionInterface { /// {@macro effect} factory Effect( void Function() callback, { @@ -93,48 +93,29 @@ class Effect implements ReactionInterface { try { final effectiveName = name ?? ReactiveName.nameFor('Effect'); final effectiveAutoDispose = autoDispose ?? SolidartConfig.autoDispose; - if (delay == null) { - effect = Effect._internal( - callback: () => callback(), - onError: onError, - name: effectiveName, - autoDispose: effectiveAutoDispose, - detach: detach, - ); - } else { - final scheduler = createDelayedScheduler(delay); - var isScheduled = false; - Timer? timer; - - effect = Effect._internal( - callback: () { - if (!isScheduled) { - isScheduled = true; - - // coverage:ignore-start - timer?.cancel(); - // coverage:ignore-end - timer = null; - - timer = scheduler(() { - isScheduled = false; - if (!effect.disposed) { - callback(); - } else { - // coverage:ignore-start - timer?.cancel(); - // coverage:ignore-end - } - }); - } - }, - onError: onError, - name: effectiveName, - autoDispose: effectiveAutoDispose, - detach: detach, - ); + Timer? timer; + void delayedCallback() { + // coverage:ignore-start + timer?.cancel(); + // coverage:ignore-end + timer = createDelayedScheduler(delay!)(() { + if (!effect.disposed) { + callback(); + } else { + // coverage:ignore-start + timer?.cancel(); + // coverage:ignore-end + } + }); } - return effect; + + return effect = Effect._internal( + callback: delay == null ? callback : delayedCallback, + onError: onError, + name: effectiveName, + autoDispose: effectiveAutoDispose, + detach: detach, + ); } finally { if (autorun ?? true) effect.run(); } @@ -147,28 +128,21 @@ class Effect implements ReactionInterface { required this.autoDispose, ErrorCallback? onError, bool? detach, - }) : _onError = onError { - VoidCallback safeCallback() { - return () { - try { - callback(); - } catch (e, s) { - if (_onError != null) { - _onError!.call(SolidartCaughtException(e, stackTrace: s)); - return; - } - rethrow; - } - }; - } - - _internalEffect = _AlienEffect( - this, - fn: safeCallback(), - detach: detach, - flags: system.ReactiveFlags.watching | system.ReactiveFlags.dirty, - ); - } + }) : detach = detach ?? SolidartConfig.detachEffects, + super( + fn: () { + try { + callback(); + } catch (e, s) { + if (onError != null) { + onError(SolidartCaughtException(e, stackTrace: s)); + return; + } + rethrow; + } + }, + flags: system.ReactiveFlags.watching | system.ReactiveFlags.dirty, + ); /// The name of the effect, useful for logging purposes. final String name; @@ -176,18 +150,19 @@ class Effect implements ReactionInterface { /// Whether to automatically dispose the effect (defaults to true). final bool autoDispose; - /// Optionally handle the error case - final ErrorCallback? _onError; + /// Whether this effect is detached from parent subscribers. + bool get isDetached => detach; bool _disposed = false; - late final _AlienEffect _internalEffect; + /// Whether the effect should detach from parent subscribers. + final bool detach; final _deps = {}; /// The subscriber of the effect, do not use it directly. @protected - system.ReactiveNode get subscriber => _internalEffect; + system.ReactiveNode get subscriber => this; @override bool get disposed => _disposed; @@ -197,17 +172,13 @@ class Effect implements ReactionInterface { final currentSub = preset.getActiveSub(); if (!SolidartConfig.detachEffects && currentSub != null && - (currentSub is! _AlienEffect || - (!_internalEffect.detach && !currentSub.detach))) { - preset.link(_internalEffect, currentSub, preset.cycle); + (currentSub is! preset.EffectNode || + !(detach || (currentSub is Effect && currentSub.detach)))) { + preset.link(this, currentSub, preset.cycle); } try { - preset.run(_internalEffect); - } catch (_) { - // The callback handles the error reporting, just rethrow to preserve - // the behavior when no handler is provided. - rethrow; + preset.run(this); } finally { if (SolidartConfig.autoDispose) { _mayDispose(); @@ -237,7 +208,7 @@ class Effect implements ReactionInterface { _disposed = true; final dependencies = {...subscriber.getDependencies(), ..._deps}; - _internalEffect.dispose(); + preset.stop(this); subscriber.mayDisposeDependencies(dependencies); } diff --git a/packages/solidart/lib/src/core/reactive_system.dart b/packages/solidart/lib/src/core/reactive_system.dart index fbc72b97..b6013ebd 100644 --- a/packages/solidart/lib/src/core/reactive_system.dart +++ b/packages/solidart/lib/src/core/reactive_system.dart @@ -1,4 +1,4 @@ -// ignore_for_file: public_member_api_docs, library_private_types_in_public_api +// ignore_for_file: public_member_api_docs // // Reactive flags map: https://github.com/medz/alien-signals-dart/blob/main/flags.md part of 'core.dart'; diff --git a/packages/solidart/lib/src/core/read_signal.dart b/packages/solidart/lib/src/core/read_signal.dart index 3fe73ba8..43b80201 100644 --- a/packages/solidart/lib/src/core/read_signal.dart +++ b/packages/solidart/lib/src/core/read_signal.dart @@ -274,15 +274,21 @@ class ReadableSignal implements ReadSignal { if (SolidartConfig.autoDispose) { for (final sub in _subs) { - if (sub is _AlienEffect) { + if (sub is Effect) { + sub.dispose(); + continue; + } + if (sub is preset.EffectNode) { if (sub.deps?.dep == _internalSignal) { sub.deps = null; } if (sub.depsTail?.dep == _internalSignal) { sub.depsTail = null; } - - sub.parent._mayDispose(); + // coverage:ignore-start + sub.mayDisposeDependencies(); + preset.stop(sub); + // coverage:ignore-end } if (sub is _AlienComputed) { // coverage:ignore-start diff --git a/packages/solidart/lib/src/core/signal_base.dart b/packages/solidart/lib/src/core/signal_base.dart index a0b7a598..df6334c7 100644 --- a/packages/solidart/lib/src/core/signal_base.dart +++ b/packages/solidart/lib/src/core/signal_base.dart @@ -105,5 +105,6 @@ abstract class SignalBase { void dispose(); /// Indicates if the [oldValue] and the [newValue] are equal + // ignore: unused_element bool _compare(T? oldValue, T? newValue); } diff --git a/packages/solidart/lib/src/core/untracked.dart b/packages/solidart/lib/src/core/untracked.dart index e4b296c4..37cf0517 100644 --- a/packages/solidart/lib/src/core/untracked.dart +++ b/packages/solidart/lib/src/core/untracked.dart @@ -5,7 +5,7 @@ part of 'core.dart'; /// This can be useful inside Effects or Observations to prevent a signal from /// being tracked. T untracked(T Function() callback) { - final prevSub = preset.setActiveSub(null); + final prevSub = preset.setActiveSub(); try { return callback(); } finally { From 9ad4ae79b84ca894d699a92077b29ac92457c0e4 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 06:41:34 +0800 Subject: [PATCH 005/121] Add v3 API implementation --- packages/solidart/lib/src/v3.dart | 351 ++++++++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 packages/solidart/lib/src/v3.dart diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart new file mode 100644 index 00000000..a10383bc --- /dev/null +++ b/packages/solidart/lib/src/v3.dart @@ -0,0 +1,351 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert' as convert; +import 'dart:developer' as developer; + +import 'package:meta/meta.dart'; +import 'package:solidart/deps/preset.dart' as preset; +import 'package:solidart/deps/system.dart' as system; + +typedef ValueComparator = bool Function(T a, T b); +typedef VoidCallback = void Function(); + +T batch(T Function() callback) { + preset.startBatch(); + try { + return callback(); + } finally { + preset.endBatch(); + } +} + +T untracked(T Function() callback) { + final prevSub = preset.setActiveSub(); + try { + return callback(); + } finally { + preset.setActiveSub(prevSub); + } +} + +sealed class Option { + const Option(); + + T unwrap() => switch (this) { + Some(:final value) => value, + _ => throw StateError('Option is None'), + }; + + T? safeUnwrap() => switch (this) { + Some(:final value) => value, + _ => null, + }; +} + +final class Some extends Option { + final T value; + + const Some(this.value); +} + +final class None extends Option { + const None(); +} + +abstract class SolidartConfig { + static bool equals = false; + static bool autoDispose = true; + static bool devToolsEnabled = false; + static bool trackPreviousValue = true; + static bool useRefreshing = true; + static bool assertSignalBuilderWithoutDependencies = true; + static final observers = []; + static bool detachEffects = false; +} + +abstract class SolidartObserver { + // coverage:ignore-start + const SolidartObserver(); + // coverage:ignore-end + + void didCreateSignal(ReadonlySignal signal); + void didUpdateSignal(ReadonlySignal signal); + void didDisposeSignal(ReadonlySignal signal); +} + +mixin Disposable { + bool _disposed = false; + final List _onDisposeCallbacks = []; + + bool get disposed => _disposed; + + @mustCallSuper + void dispose() { + if (disposed) return; + try { + for (final callback in _onDisposeCallbacks) { + callback(); + } + } finally { + _disposed = true; + _onDisposeCallbacks.clear(); + } + } + + void onDispose(VoidCallback callback) => _onDisposeCallbacks.add(callback); +} + +abstract interface class ReadonlySignal implements Disposable { + String? get name; + bool get equals; + ValueComparator get comparator; + bool get autoDispose; + bool get trackInDevTools; + bool get trackPreviousValue; + int get listenerCount; + + T get value; + set value(T newValue); + bool get hasValue; + T get untrackedValue; + + T? get previousValue; + T? get untrackedPreviousValue; + bool get hasPreviousValue; + + T call(); +} + +class Signal extends preset.SignalNode> + with Disposable + implements ReadonlySignal { + @override + final bool autoDispose; + + @override + final ValueComparator comparator; + + @override + final bool equals; + + @override + final String? name; + + @override + final bool trackInDevTools; + + @override + final bool trackPreviousValue; + + @override + bool hasValue; + + @override + bool hasPreviousValue = false; + + T? _untrackedPreviousValue; + + Signal( + T initialValue, { + ValueComparator? comparator, + String? name, + bool? autoDispose, + bool? equals, + bool? trackInDevTools, + bool? trackPreviousValue, + }) : this._internal( + Some(initialValue), + hasValue: true, + comparator: comparator ?? identical, + name: name, + autoDispose: autoDispose, + equals: equals, + trackInDevTools: trackInDevTools, + trackPreviousValue: trackPreviousValue, + ); + + Signal.lazy({ + ValueComparator? comparator, + String? name, + bool? autoDispose, + bool? equals, + bool? trackInDevTools, + bool? trackPreviousValue, + }) : this._internal( + const None(), + hasValue: false, + comparator: comparator ?? identical, + name: name, + autoDispose: autoDispose, + equals: equals, + trackInDevTools: trackInDevTools, + trackPreviousValue: trackPreviousValue, + ); + + Signal._internal( + Option initialValue, { + required this.hasValue, + required this.comparator, + this.name, + bool? autoDispose, + bool? equals, + bool? trackInDevTools, + bool? trackPreviousValue, + }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, + equals = equals ?? SolidartConfig.equals, + trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, + trackPreviousValue = + trackPreviousValue ?? SolidartConfig.trackPreviousValue, + super( + flags: system.ReactiveFlags.mutable, + currentValue: initialValue, + pendingValue: initialValue, + ); + + @override + T? get untrackedPreviousValue { + if (hasPreviousValue) return _untrackedPreviousValue; + throw StateError('Signal has no previous value'); + } + + @override + T? get previousValue { + if (trackPreviousValue) get(); + return untrackedPreviousValue; + } + + @override + T get value { + if (hasValue) return get().unwrap(); + throw StateError('Signal has no value'); + } + + @override + set value(T newValue) { + final oldValue = pendingValue.safeUnwrap(); + if (!hasValue) { + hasValue = true; + set(Some(newValue)); + SolidartConfig.observers.emit(.created, this); + return; + } + if ((!equals && !comparator(oldValue as T, newValue)) || + (equals && oldValue != newValue)) { + return; + } + + final prevHasPreviousValue = hasPreviousValue; + _untrackedPreviousValue = oldValue; + if (!prevHasPreviousValue) { + hasPreviousValue = true; + SolidartConfig.observers.emit(.created, this); + } + + set(Some(newValue)); + if (prevHasPreviousValue) { + SolidartConfig.observers.emit(.updated, this); + } + } + + @override + T get untrackedValue => currentValue.unwrap(); + + @override + // TODO: implement listenerCount + int get listenerCount => throw UnimplementedError(); + + @override + T call() => value; + + ReadonlySignal toReadonlySignal() => this; +} + +// TODO +class Computed extends preset.ComputedNode + with Disposable + implements ReadonlySignal {} + +// TODO +class Effect extends preset.EffectNode with Disposable { + // 额外需要此设置! + final bool detach; +} + +enum _ObserverEvent { created, updated, disposed } + +final class _DevTools { + const _DevTools(); + + static void emit(_ObserverEvent event, ReadonlySignal signal) { + assert(() { + final kind = 'ext.solidart.signal.${event.name}'; + var value = signal.value; + var previousValue = signal.previousValue; + // TODO: + // if (signal is Resource) { + // value = signal._value.asReady?.value; + // previousValue = signal._previousValue?.asReady?.value; + // } + + developer.postEvent(kind, { + // '_id': signal._id, + 'name': signal.name, + 'value': trySerializeJson(value), + 'previousValue': trySerializeJson(previousValue), + 'hasPreviousValue': signal.hasPreviousValue, + 'type': switch (signal) { + // TODO: + // Resource() => 'Resource', + // ListSignal() => 'ListSignal', + // MapSignal() => 'MapSignal', + // SetSignal() => 'SetSignal', + // Signal() => 'Signal', + // Computed() => 'Computed', + ReadonlySignal() => 'ReadonlySignal', + }, + 'valueType': value.runtimeType.toString(), + if (signal.hasPreviousValue) + 'previousValueType': previousValue.runtimeType.toString(), + 'disposed': signal.disposed, + 'autoDispose': signal.autoDispose, + 'listenerCount': signal.listenerCount, + 'lastUpdate': DateTime.now().toIso8601String(), + }); + return true; + }(), 'Post devtools event assertion failed'); + } + + static Object? trySerializeJson(Object? value) { + try { + return convert.json.encode(value); + } catch (_) { + return switch (value) { + Iterable() => trySerializeJson(value.map(trySerializeJson).toList()), + Map() => trySerializeJson( + value.map( + (key, value) => + MapEntry(trySerializeJson(key), trySerializeJson(value)), + ), + ), + _ => convert.json.encode(value), + }; + } + } +} + +extension on Iterable { + void emit(_ObserverEvent event, ReadonlySignal signal) { + _DevTools.emit(event, signal); + for (final observer in this) { + try { + switch (event) { + case .created: + observer.didCreateSignal(signal); + case .updated: + observer.didUpdateSignal(signal); + case .disposed: + observer.didDisposeSignal(signal); + } + } catch (_) {} + } + } +} From 3d34fb9c6a889d10e7605043ec9ff34ac3a208fa Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 06:41:59 +0800 Subject: [PATCH 006/121] Rename v3 module to next --- packages/solidart/lib/src/{v3.dart => next.dart} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/solidart/lib/src/{v3.dart => next.dart} (100%) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/next.dart similarity index 100% rename from packages/solidart/lib/src/v3.dart rename to packages/solidart/lib/src/next.dart From dcb542ee12eec0de2bac90288990d93df4690914 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:21:55 +0800 Subject: [PATCH 007/121] WIP: Remove legacy next.dart and introduce simplified v3 API --- packages/solidart/lib/src/next.dart | 351 ---------------------------- packages/solidart/lib/src/v3.dart | 104 +++++++++ packages/solidart/pubspec.yaml | 2 +- 3 files changed, 105 insertions(+), 352 deletions(-) delete mode 100644 packages/solidart/lib/src/next.dart create mode 100644 packages/solidart/lib/src/v3.dart diff --git a/packages/solidart/lib/src/next.dart b/packages/solidart/lib/src/next.dart deleted file mode 100644 index a10383bc..00000000 --- a/packages/solidart/lib/src/next.dart +++ /dev/null @@ -1,351 +0,0 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first -import 'dart:convert' as convert; -import 'dart:developer' as developer; - -import 'package:meta/meta.dart'; -import 'package:solidart/deps/preset.dart' as preset; -import 'package:solidart/deps/system.dart' as system; - -typedef ValueComparator = bool Function(T a, T b); -typedef VoidCallback = void Function(); - -T batch(T Function() callback) { - preset.startBatch(); - try { - return callback(); - } finally { - preset.endBatch(); - } -} - -T untracked(T Function() callback) { - final prevSub = preset.setActiveSub(); - try { - return callback(); - } finally { - preset.setActiveSub(prevSub); - } -} - -sealed class Option { - const Option(); - - T unwrap() => switch (this) { - Some(:final value) => value, - _ => throw StateError('Option is None'), - }; - - T? safeUnwrap() => switch (this) { - Some(:final value) => value, - _ => null, - }; -} - -final class Some extends Option { - final T value; - - const Some(this.value); -} - -final class None extends Option { - const None(); -} - -abstract class SolidartConfig { - static bool equals = false; - static bool autoDispose = true; - static bool devToolsEnabled = false; - static bool trackPreviousValue = true; - static bool useRefreshing = true; - static bool assertSignalBuilderWithoutDependencies = true; - static final observers = []; - static bool detachEffects = false; -} - -abstract class SolidartObserver { - // coverage:ignore-start - const SolidartObserver(); - // coverage:ignore-end - - void didCreateSignal(ReadonlySignal signal); - void didUpdateSignal(ReadonlySignal signal); - void didDisposeSignal(ReadonlySignal signal); -} - -mixin Disposable { - bool _disposed = false; - final List _onDisposeCallbacks = []; - - bool get disposed => _disposed; - - @mustCallSuper - void dispose() { - if (disposed) return; - try { - for (final callback in _onDisposeCallbacks) { - callback(); - } - } finally { - _disposed = true; - _onDisposeCallbacks.clear(); - } - } - - void onDispose(VoidCallback callback) => _onDisposeCallbacks.add(callback); -} - -abstract interface class ReadonlySignal implements Disposable { - String? get name; - bool get equals; - ValueComparator get comparator; - bool get autoDispose; - bool get trackInDevTools; - bool get trackPreviousValue; - int get listenerCount; - - T get value; - set value(T newValue); - bool get hasValue; - T get untrackedValue; - - T? get previousValue; - T? get untrackedPreviousValue; - bool get hasPreviousValue; - - T call(); -} - -class Signal extends preset.SignalNode> - with Disposable - implements ReadonlySignal { - @override - final bool autoDispose; - - @override - final ValueComparator comparator; - - @override - final bool equals; - - @override - final String? name; - - @override - final bool trackInDevTools; - - @override - final bool trackPreviousValue; - - @override - bool hasValue; - - @override - bool hasPreviousValue = false; - - T? _untrackedPreviousValue; - - Signal( - T initialValue, { - ValueComparator? comparator, - String? name, - bool? autoDispose, - bool? equals, - bool? trackInDevTools, - bool? trackPreviousValue, - }) : this._internal( - Some(initialValue), - hasValue: true, - comparator: comparator ?? identical, - name: name, - autoDispose: autoDispose, - equals: equals, - trackInDevTools: trackInDevTools, - trackPreviousValue: trackPreviousValue, - ); - - Signal.lazy({ - ValueComparator? comparator, - String? name, - bool? autoDispose, - bool? equals, - bool? trackInDevTools, - bool? trackPreviousValue, - }) : this._internal( - const None(), - hasValue: false, - comparator: comparator ?? identical, - name: name, - autoDispose: autoDispose, - equals: equals, - trackInDevTools: trackInDevTools, - trackPreviousValue: trackPreviousValue, - ); - - Signal._internal( - Option initialValue, { - required this.hasValue, - required this.comparator, - this.name, - bool? autoDispose, - bool? equals, - bool? trackInDevTools, - bool? trackPreviousValue, - }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, - equals = equals ?? SolidartConfig.equals, - trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, - trackPreviousValue = - trackPreviousValue ?? SolidartConfig.trackPreviousValue, - super( - flags: system.ReactiveFlags.mutable, - currentValue: initialValue, - pendingValue: initialValue, - ); - - @override - T? get untrackedPreviousValue { - if (hasPreviousValue) return _untrackedPreviousValue; - throw StateError('Signal has no previous value'); - } - - @override - T? get previousValue { - if (trackPreviousValue) get(); - return untrackedPreviousValue; - } - - @override - T get value { - if (hasValue) return get().unwrap(); - throw StateError('Signal has no value'); - } - - @override - set value(T newValue) { - final oldValue = pendingValue.safeUnwrap(); - if (!hasValue) { - hasValue = true; - set(Some(newValue)); - SolidartConfig.observers.emit(.created, this); - return; - } - if ((!equals && !comparator(oldValue as T, newValue)) || - (equals && oldValue != newValue)) { - return; - } - - final prevHasPreviousValue = hasPreviousValue; - _untrackedPreviousValue = oldValue; - if (!prevHasPreviousValue) { - hasPreviousValue = true; - SolidartConfig.observers.emit(.created, this); - } - - set(Some(newValue)); - if (prevHasPreviousValue) { - SolidartConfig.observers.emit(.updated, this); - } - } - - @override - T get untrackedValue => currentValue.unwrap(); - - @override - // TODO: implement listenerCount - int get listenerCount => throw UnimplementedError(); - - @override - T call() => value; - - ReadonlySignal toReadonlySignal() => this; -} - -// TODO -class Computed extends preset.ComputedNode - with Disposable - implements ReadonlySignal {} - -// TODO -class Effect extends preset.EffectNode with Disposable { - // 额外需要此设置! - final bool detach; -} - -enum _ObserverEvent { created, updated, disposed } - -final class _DevTools { - const _DevTools(); - - static void emit(_ObserverEvent event, ReadonlySignal signal) { - assert(() { - final kind = 'ext.solidart.signal.${event.name}'; - var value = signal.value; - var previousValue = signal.previousValue; - // TODO: - // if (signal is Resource) { - // value = signal._value.asReady?.value; - // previousValue = signal._previousValue?.asReady?.value; - // } - - developer.postEvent(kind, { - // '_id': signal._id, - 'name': signal.name, - 'value': trySerializeJson(value), - 'previousValue': trySerializeJson(previousValue), - 'hasPreviousValue': signal.hasPreviousValue, - 'type': switch (signal) { - // TODO: - // Resource() => 'Resource', - // ListSignal() => 'ListSignal', - // MapSignal() => 'MapSignal', - // SetSignal() => 'SetSignal', - // Signal() => 'Signal', - // Computed() => 'Computed', - ReadonlySignal() => 'ReadonlySignal', - }, - 'valueType': value.runtimeType.toString(), - if (signal.hasPreviousValue) - 'previousValueType': previousValue.runtimeType.toString(), - 'disposed': signal.disposed, - 'autoDispose': signal.autoDispose, - 'listenerCount': signal.listenerCount, - 'lastUpdate': DateTime.now().toIso8601String(), - }); - return true; - }(), 'Post devtools event assertion failed'); - } - - static Object? trySerializeJson(Object? value) { - try { - return convert.json.encode(value); - } catch (_) { - return switch (value) { - Iterable() => trySerializeJson(value.map(trySerializeJson).toList()), - Map() => trySerializeJson( - value.map( - (key, value) => - MapEntry(trySerializeJson(key), trySerializeJson(value)), - ), - ), - _ => convert.json.encode(value), - }; - } - } -} - -extension on Iterable { - void emit(_ObserverEvent event, ReadonlySignal signal) { - _DevTools.emit(event, signal); - for (final observer in this) { - try { - switch (event) { - case .created: - observer.didCreateSignal(signal); - case .updated: - observer.didUpdateSignal(signal); - case .disposed: - observer.didDisposeSignal(signal); - } - } catch (_) {} - } - } -} diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart new file mode 100644 index 00000000..951988eb --- /dev/null +++ b/packages/solidart/lib/src/v3.dart @@ -0,0 +1,104 @@ +// ignore_for_file: public_member_api_docs + +import 'package:solidart/deps/preset.dart' as preset; +import 'package:solidart/deps/system.dart' as system; + +typedef ValueGetter = T Function(); +typedef VoidCallback = ValueGetter; + +sealed class Option { + const Option(); + + T unwrap() => switch (this) { + Some(:final value) => value, + _ => throw StateError('Option is None'), + }; + + T? safeUnwrap() => switch (this) { + Some(:final value) => value, + _ => null, + }; +} + +final class Some extends Option { + const Some(this.value); + + final T value; +} + +final class None extends Option { + const None(); +} + +/// Maybe rename to `ReadSignal` ? +/// CC @nank1ro +abstract interface class ReadonlySignal implements system.ReactiveNode { + T get value; +} + +class Signal extends preset.SignalNode> + implements ReadonlySignal { + Signal(T initialValue) + : super( + flags: system.ReactiveFlags.mutable, + currentValue: Some(initialValue), + pendingValue: const None(), + ) { + pendingValue = currentValue; + } + + Signal._internal(Option initialValue) + : super( + flags: system.ReactiveFlags.mutable, + currentValue: initialValue, + pendingValue: initialValue, + ); + + factory Signal.lazy() = LazySignal; + + @override + T get value => super.get().unwrap(); + + set value(T newValue) => set(Some(newValue)); +} + +class LazySignal extends Signal { + LazySignal() : super._internal(const None()); + + @override + T get value { + if (currentValue is None) { + throw StateError( + 'LazySignal is not initialized, Please call `.value` first.', + ); + } + + return super.value; + } +} + +class Computed extends preset.ComputedNode implements ReadonlySignal { + Computed(ValueGetter getter) + : super(flags: system.ReactiveFlags.none, getter: (_) => getter()); + + @override + T get value => super.get(); +} + +class Effect extends preset.EffectNode { + Effect(VoidCallback callback) + : super( + fn: callback, + flags: + system.ReactiveFlags.watching | system.ReactiveFlags.recursedCheck, + ) { + final prevSub = preset.setActiveSub(this); + if (prevSub != null) preset.link(this, prevSub, 0); + try { + callback(); + } finally { + preset.activeSub = prevSub; + flags &= ~system.ReactiveFlags.recursedCheck; + } + } +} diff --git a/packages/solidart/pubspec.yaml b/packages/solidart/pubspec.yaml index 6757d795..4e65c5a3 100644 --- a/packages/solidart/pubspec.yaml +++ b/packages/solidart/pubspec.yaml @@ -14,7 +14,7 @@ resolution: workspace dependencies: # we depend on the alien signals reactivity implementation because it's the fastest available right now (30/12/2024) - alien_signals: ^2.0.1 + alien_signals: ^2.1.0 collection: ^1.18.0 meta: ^1.11.0 From 0095e25efbf5a3584ee58f60cef5cf80af3144a0 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:23:19 +0800 Subject: [PATCH 008/121] legacy: Rename update method to didUpdate in internal signals --- packages/solidart/lib/src/core/computed.dart | 2 +- packages/solidart/lib/src/core/read_signal.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/solidart/lib/src/core/computed.dart b/packages/solidart/lib/src/core/computed.dart index c9448176..41afb00e 100644 --- a/packages/solidart/lib/src/core/computed.dart +++ b/packages/solidart/lib/src/core/computed.dart @@ -236,7 +236,7 @@ class Computed extends ReadSignal { /// However, in some cases, you may want to force an update. void run() { if (_disposed) return; - _internalComputed.update(); + _internalComputed.didUpdate(); } @override diff --git a/packages/solidart/lib/src/core/read_signal.dart b/packages/solidart/lib/src/core/read_signal.dart index 43b80201..ac960c77 100644 --- a/packages/solidart/lib/src/core/read_signal.dart +++ b/packages/solidart/lib/src/core/read_signal.dart @@ -344,7 +344,7 @@ class ReadableSignal implements ReadSignal { system.ReactiveFlags.none) { return false; } - return _internalSignal.update(); + return _internalSignal.didUpdate(); } @override From 109ba0d97737cf3357656b76963cf4e5947d2e45 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:48:34 +0800 Subject: [PATCH 009/121] Add Disposable interface and mixin for reactive primitives Introduce a `Disposable` interface and `DisponsableMixin` (note: typo in name) to provide a common disposal mechanism for `Signal`, `Computed`, and `Effect`. This allows registering cleanup callbacks and ensures proper resource disposal. --- packages/solidart/lib/src/v3.dart | 53 +++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index 951988eb..c58365fa 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -1,5 +1,6 @@ // ignore_for_file: public_member_api_docs +import 'package:meta/meta.dart'; import 'package:solidart/deps/preset.dart' as preset; import 'package:solidart/deps/system.dart' as system; @@ -30,13 +31,27 @@ final class None extends Option { const None(); } +abstract class Configuration { + String? get name; + bool get autoDispose; +} + +abstract class Disposable { + bool get isDisposed; + + void onDispose(VoidCallback callback); + void dispose(); +} + /// Maybe rename to `ReadSignal` ? /// CC @nank1ro -abstract interface class ReadonlySignal implements system.ReactiveNode { +abstract interface class ReadonlySignal + implements system.ReactiveNode, Disposable { T get value; } class Signal extends preset.SignalNode> + with DisponsableMixin implements ReadonlySignal { Signal(T initialValue) : super( @@ -77,7 +92,9 @@ class LazySignal extends Signal { } } -class Computed extends preset.ComputedNode implements ReadonlySignal { +class Computed extends preset.ComputedNode + with DisponsableMixin + implements ReadonlySignal { Computed(ValueGetter getter) : super(flags: system.ReactiveFlags.none, getter: (_) => getter()); @@ -85,7 +102,9 @@ class Computed extends preset.ComputedNode implements ReadonlySignal { T get value => super.get(); } -class Effect extends preset.EffectNode { +class Effect extends preset.EffectNode + with DisponsableMixin + implements Disposable { Effect(VoidCallback callback) : super( fn: callback, @@ -102,3 +121,31 @@ class Effect extends preset.EffectNode { } } } + +mixin DisponsableMixin implements Disposable { + @internal + late final cleanups = []; + + @override + bool isDisposed = false; + + @mustCallSuper + @override + void onDispose(VoidCallback callback) { + cleanups.add(callback); + } + + @mustCallSuper + @override + void dispose() { + if (isDisposed) return; + isDisposed = true; + try { + for (final callback in cleanups) { + callback(); + } + } finally { + cleanups.clear(); + } + } +} From 76d6bc48f593d344f3a8028a14ca3f3cdf1743d6 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:48:42 +0800 Subject: [PATCH 010/121] Add v3.dart as a re-export entry point --- packages/solidart/lib/v3.dart | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 packages/solidart/lib/v3.dart diff --git a/packages/solidart/lib/v3.dart b/packages/solidart/lib/v3.dart new file mode 100644 index 00000000..470b767a --- /dev/null +++ b/packages/solidart/lib/v3.dart @@ -0,0 +1,3 @@ +// TODO: rename the v3.dart to solidart.dart filename. + +export 'src/v3.dart' show Computed, Effect, LazySignal, ReadonlySignal, Signal; From 7693e13e02a35ed13a25207f8127dbdd52dd45eb Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:59:18 +0800 Subject: [PATCH 011/121] Add Configuration interface and global autoDispose setting - Add SolidartConfig with static autoDispose flag - Make ReadonlySignal implement Configuration - Add name and autoDispose parameters to Signal, LazySignal, and Computed - Export Configuration and Disposable from the main library --- packages/solidart/lib/src/v3.dart | 56 ++++++++++++++++++++----------- packages/solidart/lib/v3.dart | 10 +++++- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index c58365fa..b07bc840 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -31,6 +31,12 @@ final class None extends Option { const None(); } +final class SolidartConifg { + const SolidartConifg._(); + + static bool autoDispose = false; +} + abstract class Configuration { String? get name; bool get autoDispose; @@ -46,30 +52,34 @@ abstract class Disposable { /// Maybe rename to `ReadSignal` ? /// CC @nank1ro abstract interface class ReadonlySignal - implements system.ReactiveNode, Disposable { + implements system.ReactiveNode, Disposable, Configuration { T get value; } class Signal extends preset.SignalNode> with DisponsableMixin implements ReadonlySignal { - Signal(T initialValue) - : super( - flags: system.ReactiveFlags.mutable, - currentValue: Some(initialValue), - pendingValue: const None(), - ) { - pendingValue = currentValue; - } + Signal(T initialValue, {String? name, bool? autoDispose}) + : this._internal(Some(initialValue), name: name, autoDispose: autoDispose); + + Signal._internal( + Option initialValue, { + this.name, + bool? autoDispose, + }) : autoDispose = autoDispose ?? SolidartConifg.autoDispose, + super( + flags: system.ReactiveFlags.mutable, + currentValue: initialValue, + pendingValue: initialValue, + ); + + factory Signal.lazy({String? name, bool? autoDispose}) = LazySignal; - Signal._internal(Option initialValue) - : super( - flags: system.ReactiveFlags.mutable, - currentValue: initialValue, - pendingValue: initialValue, - ); + @override + final bool autoDispose; - factory Signal.lazy() = LazySignal; + @override + final String? name; @override T get value => super.get().unwrap(); @@ -78,7 +88,8 @@ class Signal extends preset.SignalNode> } class LazySignal extends Signal { - LazySignal() : super._internal(const None()); + LazySignal({String? name, bool? autoDispose}) + : super._internal(const None(), name: name, autoDispose: autoDispose); @override T get value { @@ -95,8 +106,15 @@ class LazySignal extends Signal { class Computed extends preset.ComputedNode with DisponsableMixin implements ReadonlySignal { - Computed(ValueGetter getter) - : super(flags: system.ReactiveFlags.none, getter: (_) => getter()); + Computed(ValueGetter getter, {this.name, bool? autoDispose}) + : autoDispose = autoDispose ?? SolidartConifg.autoDispose, + super(flags: system.ReactiveFlags.none, getter: (_) => getter()); + + @override + final bool autoDispose; + + @override + final String? name; @override T get value => super.get(); diff --git a/packages/solidart/lib/v3.dart b/packages/solidart/lib/v3.dart index 470b767a..286642cc 100644 --- a/packages/solidart/lib/v3.dart +++ b/packages/solidart/lib/v3.dart @@ -1,3 +1,11 @@ // TODO: rename the v3.dart to solidart.dart filename. -export 'src/v3.dart' show Computed, Effect, LazySignal, ReadonlySignal, Signal; +export 'src/v3.dart' + show + Computed, + Configuration, + Disposable, + Effect, + LazySignal, + ReadonlySignal, + Signal; From 6a71777c7fda428fd3497dbf12bf8c153595c008 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:03:06 +0800 Subject: [PATCH 012/121] Add name and autoDispose parameters to Effect constructor The Effect class now implements Configuration and accepts optional name and autoDispose parameters, defaulting autoDispose to the global SolidartConfig.autoDispose value when not provided. --- packages/solidart/lib/src/v3.dart | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index b07bc840..3cc35ca0 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -122,9 +122,10 @@ class Computed extends preset.ComputedNode class Effect extends preset.EffectNode with DisponsableMixin - implements Disposable { - Effect(VoidCallback callback) - : super( + implements Disposable, Configuration { + Effect(VoidCallback callback, {this.name, bool? autoDispose}) + : autoDispose = autoDispose ?? SolidartConifg.autoDispose, + super( fn: callback, flags: system.ReactiveFlags.watching | system.ReactiveFlags.recursedCheck, @@ -138,6 +139,12 @@ class Effect extends preset.EffectNode flags &= ~system.ReactiveFlags.recursedCheck; } } + + @override + final bool autoDispose; + + @override + final String? name; } mixin DisponsableMixin implements Disposable { From f151329f53d6b241411c788671ba4efe197f3784 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:10:30 +0800 Subject: [PATCH 013/121] Replace signal name with unique identifier Introduce an `Identifier` class that provides a unique integer value alongside an optional name. This replaces the simple `name` property on signals, computed values, and effects to ensure each reactive primitive has a distinct identifier for debugging and tracking. --- packages/solidart/lib/src/v3.dart | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index 3cc35ca0..81a9b44f 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -37,8 +37,16 @@ final class SolidartConifg { static bool autoDispose = false; } +class Identifier { + Identifier._(this.name) : value = _counter++; + static int _counter = 0; + + final String? name; + final int value; +} + abstract class Configuration { - String? get name; + Identifier get identifier; bool get autoDispose; } @@ -59,14 +67,15 @@ abstract interface class ReadonlySignal class Signal extends preset.SignalNode> with DisponsableMixin implements ReadonlySignal { - Signal(T initialValue, {String? name, bool? autoDispose}) - : this._internal(Some(initialValue), name: name, autoDispose: autoDispose); + Signal(T initialValue, {bool? autoDispose, String? name}) + : this._internal(Some(initialValue), autoDispose: autoDispose, name: name); Signal._internal( Option initialValue, { - this.name, + String? name, bool? autoDispose, }) : autoDispose = autoDispose ?? SolidartConifg.autoDispose, + identifier = Identifier._(name), super( flags: system.ReactiveFlags.mutable, currentValue: initialValue, @@ -79,7 +88,7 @@ class Signal extends preset.SignalNode> final bool autoDispose; @override - final String? name; + final Identifier identifier; @override T get value => super.get().unwrap(); @@ -106,15 +115,16 @@ class LazySignal extends Signal { class Computed extends preset.ComputedNode with DisponsableMixin implements ReadonlySignal { - Computed(ValueGetter getter, {this.name, bool? autoDispose}) + Computed(ValueGetter getter, {bool? autoDispose, String? name}) : autoDispose = autoDispose ?? SolidartConifg.autoDispose, + identifier = Identifier._(name), super(flags: system.ReactiveFlags.none, getter: (_) => getter()); @override final bool autoDispose; @override - final String? name; + final Identifier identifier; @override T get value => super.get(); @@ -123,8 +133,9 @@ class Computed extends preset.ComputedNode class Effect extends preset.EffectNode with DisponsableMixin implements Disposable, Configuration { - Effect(VoidCallback callback, {this.name, bool? autoDispose}) + Effect(VoidCallback callback, {bool? autoDispose, String? name}) : autoDispose = autoDispose ?? SolidartConifg.autoDispose, + identifier = Identifier._(name), super( fn: callback, flags: @@ -144,7 +155,7 @@ class Effect extends preset.EffectNode final bool autoDispose; @override - final String? name; + final Identifier identifier; } mixin DisponsableMixin implements Disposable { From 915d958a9e6b7b23a3159c7ee6dd18ff58532418 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:10:36 +0800 Subject: [PATCH 014/121] Update import path to use solidart/v3.dart --- packages/solidart/example/main.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/solidart/example/main.dart b/packages/solidart/example/main.dart index 7b76a506..d38e9bb6 100644 --- a/packages/solidart/example/main.dart +++ b/packages/solidart/example/main.dart @@ -1,6 +1,6 @@ // ignore_for_file: avoid_print -import 'package:solidart/solidart.dart'; +import 'package:solidart/v3.dart'; void main() { final count = Signal(0); From 02ac378c8d3c88b0adff1be7636efc9fc3432fb2 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:14:08 +0800 Subject: [PATCH 015/121] Export SolidartConifg and remove unused exports from v3 --- packages/solidart/lib/v3.dart | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/solidart/lib/v3.dart b/packages/solidart/lib/v3.dart index 286642cc..1a95b7dd 100644 --- a/packages/solidart/lib/v3.dart +++ b/packages/solidart/lib/v3.dart @@ -1,11 +1,4 @@ // TODO: rename the v3.dart to solidart.dart filename. export 'src/v3.dart' - show - Computed, - Configuration, - Disposable, - Effect, - LazySignal, - ReadonlySignal, - Signal; + show Computed, Effect, LazySignal, ReadonlySignal, Signal, SolidartConifg; From bf8c73494dad0b659ee85ae7df796ab03e10e351 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:14:13 +0800 Subject: [PATCH 016/121] Add advanced exports for v3 features --- packages/solidart/lib/advanced.dart | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 packages/solidart/lib/advanced.dart diff --git a/packages/solidart/lib/advanced.dart b/packages/solidart/lib/advanced.dart new file mode 100644 index 00000000..18878c08 --- /dev/null +++ b/packages/solidart/lib/advanced.dart @@ -0,0 +1,9 @@ +export 'src/v3.dart' + show + Configuration, + DisponsableMixin, + Disposable, + Identifier, + None, + Option, + Some; From 48bddcb38058e06596142e8cfdf18d79bdf79a1b Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:17:01 +0800 Subject: [PATCH 017/121] Add isInitialized getter to LazySignal Check isInitialized in value getter to throw a clearer error. The error message now instructs to set `.value = `. --- packages/solidart/lib/src/v3.dart | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index 81a9b44f..4a24afdd 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -100,15 +100,17 @@ class LazySignal extends Signal { LazySignal({String? name, bool? autoDispose}) : super._internal(const None(), name: name, autoDispose: autoDispose); + bool get isInitialized => currentValue is Some; + @override T get value { - if (currentValue is None) { - throw StateError( - 'LazySignal is not initialized, Please call `.value` first.', - ); + if (isInitialized) { + return super.value; } - return super.value; + throw StateError( + 'LazySignal is not initialized, Please call `.value = ` first.', + ); } } From 74920358dba0ac0b736e04d36842b2bfad051cbe Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:19:07 +0800 Subject: [PATCH 018/121] Add toReadonly method to Signal class --- packages/solidart/lib/src/v3.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index 4a24afdd..9820afd8 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -94,6 +94,8 @@ class Signal extends preset.SignalNode> T get value => super.get().unwrap(); set value(T newValue) => set(Some(newValue)); + + ReadonlySignal toReadonly() => this; } class LazySignal extends Signal { From 8290c557bf8dcc620cc34eb785034aa691d0216d Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:26:19 +0800 Subject: [PATCH 019/121] Update TODO comments for naming discussions --- packages/solidart/lib/src/v3.dart | 5 +++-- packages/solidart/lib/v3.dart | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index 9820afd8..2104b1ab 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -57,8 +57,7 @@ abstract class Disposable { void dispose(); } -/// Maybe rename to `ReadSignal` ? -/// CC @nank1ro +// TODO(nank1ro): Maybe rename to `ReadSignal`? medz: I still recommend `ReadonlySignal` because it is semantically clearer., https://github.com/nank1ro/solidart/pull/166#issuecomment-3623175977 abstract interface class ReadonlySignal implements system.ReactiveNode, Disposable, Configuration { T get value; @@ -95,6 +94,8 @@ class Signal extends preset.SignalNode> set value(T newValue) => set(Some(newValue)); + // TODO(nank1ro): See ReadonlySignal TODO, If `ReadonlySignal` rename + // to `ReadSignal`, the `.toReadonly` method should be rename? ReadonlySignal toReadonly() => this; } diff --git a/packages/solidart/lib/v3.dart b/packages/solidart/lib/v3.dart index 1a95b7dd..66233ebb 100644 --- a/packages/solidart/lib/v3.dart +++ b/packages/solidart/lib/v3.dart @@ -1,4 +1,4 @@ -// TODO: rename the v3.dart to solidart.dart filename. +// TODO(medz): rename the v3.dart to solidart.dart filename. export 'src/v3.dart' show Computed, Effect, LazySignal, ReadonlySignal, Signal, SolidartConifg; From 185d600a761560ab8117318588cab9eab1b53ff8 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:29:15 +0800 Subject: [PATCH 020/121] Add TODO for code comments in v3.dart --- packages/solidart/lib/src/v3.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index 2104b1ab..3d3fb8db 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -1,4 +1,5 @@ // ignore_for_file: public_member_api_docs +// TODO(medz): Add code comments import 'package:meta/meta.dart'; import 'package:solidart/deps/preset.dart' as preset; From 3ba4ad4df8705b6da83a2eb8c96bc7633d6e641d Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:30:15 +0800 Subject: [PATCH 021/121] Simplify LazySignal.value getter by removing braces --- packages/solidart/lib/src/v3.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index 3d3fb8db..e90f0f06 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -108,10 +108,7 @@ class LazySignal extends Signal { @override T get value { - if (isInitialized) { - return super.value; - } - + if (isInitialized) return super.value; throw StateError( 'LazySignal is not initialized, Please call `.value = ` first.', ); From 898bed3effc178be048009c5252a4f9b6c4938d5 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:44:50 +0800 Subject: [PATCH 022/121] Add dispose method to Effect class Ensure Effect is properly stopped when disposed by calling preset.stop before super.dispose. --- packages/solidart/lib/src/v3.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index e90f0f06..c092abf3 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -159,6 +159,13 @@ class Effect extends preset.EffectNode @override final Identifier identifier; + + @override + void dispose() { + if (isDisposed) return; + preset.stop(this); + super.dispose(); + } } mixin DisponsableMixin implements Disposable { From 550c19476a892f3364d1f9ee36a3f945fa51f66a Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 20:43:04 +0800 Subject: [PATCH 023/121] Enable auto-dispose by default and implement cascade disposal The default value of `SolidartConifg.autoDispose` is changed from `false` to `true`. New static methods `Disposable.canAutoDispose`, `unlinkDeps`, and `unlinkSubs` manage dependency cleanup. Signal, Computed, and Effect dispose methods now cascade disposal to dependent nodes when auto-dispose is enabled. --- packages/solidart/lib/src/v3.dart | 52 ++++++- .../solidart/test/v3_auto_dispose_test.dart | 130 ++++++++++++++++++ 2 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 packages/solidart/test/v3_auto_dispose_test.dart diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index c092abf3..f1f5a124 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -35,7 +35,7 @@ final class None extends Option { final class SolidartConifg { const SolidartConifg._(); - static bool autoDispose = false; + static bool autoDispose = true; } class Identifier { @@ -56,6 +56,38 @@ abstract class Disposable { void onDispose(VoidCallback callback); void dispose(); + + static bool canAutoDispose(system.ReactiveNode node) => switch (node) { + Disposable(:final isDisposed) && Configuration(:final autoDispose) => + !isDisposed && autoDispose, + _ => false, + }; + + static void unlinkDeps(system.ReactiveNode node) { + var link = node.deps; + while (link != null) { + final next = link.nextDep; + final dep = link.dep; + preset.unlink(link, node); + if (canAutoDispose(dep) && dep.subs == null) { + (dep as Disposable).dispose(); + } + link = next; + } + } + + static void unlinkSubs(system.ReactiveNode node) { + var link = node.subs; + while (link != null) { + final next = link.nextSub; + final sub = link.sub; + preset.unlink(link, sub); + if (canAutoDispose(sub) && sub.deps == null) { + (sub as Disposable).dispose(); + } + link = next; + } + } } // TODO(nank1ro): Maybe rename to `ReadSignal`? medz: I still recommend `ReadonlySignal` because it is semantically clearer., https://github.com/nank1ro/solidart/pull/166#issuecomment-3623175977 @@ -98,6 +130,14 @@ class Signal extends preset.SignalNode> // TODO(nank1ro): See ReadonlySignal TODO, If `ReadonlySignal` rename // to `ReadSignal`, the `.toReadonly` method should be rename? ReadonlySignal toReadonly() => this; + + @override + void dispose() { + if (isDisposed) return; + Disposable.unlinkSubs(this); + preset.stop(this); + super.dispose(); + } } class LazySignal extends Signal { @@ -131,6 +171,15 @@ class Computed extends preset.ComputedNode @override T get value => super.get(); + + @override + void dispose() { + if (isDisposed) return; + Disposable.unlinkSubs(this); + Disposable.unlinkDeps(this); + preset.stop(this); + super.dispose(); + } } class Effect extends preset.EffectNode @@ -163,6 +212,7 @@ class Effect extends preset.EffectNode @override void dispose() { if (isDisposed) return; + Disposable.unlinkDeps(this); preset.stop(this); super.dispose(); } diff --git a/packages/solidart/test/v3_auto_dispose_test.dart b/packages/solidart/test/v3_auto_dispose_test.dart new file mode 100644 index 00000000..c19a6188 --- /dev/null +++ b/packages/solidart/test/v3_auto_dispose_test.dart @@ -0,0 +1,130 @@ +import 'package:solidart/deps/system.dart' as system; +import 'package:solidart/v3.dart'; +import 'package:test/test.dart'; + +void main() { + late bool previousAutoDispose; + + setUp(() { + previousAutoDispose = SolidartConifg.autoDispose; + SolidartConifg.autoDispose = true; + }); + + tearDown(() { + SolidartConifg.autoDispose = previousAutoDispose; + }); + + test('disposing a signal cascades to its dependents', () { + final a = Signal(0); + final b = Computed(() => a.value * 2); + final c = Effect(() { + b.value; + }); + final d = Signal(0); + final e = Effect(() { + a.value; + d.value; + }); + + a.dispose(); + + expect(a.isDisposed, isTrue); + expect(b.isDisposed, isTrue); + expect(c.isDisposed, isTrue); + expect(e.isDisposed, isFalse); + expect(d.isDisposed, isFalse); + + final deps = _depsOf(e); + expect(deps.contains(a), isFalse); + expect(deps.contains(d), isTrue); + }); + + test( + 'disposing a computed cleans subscribers but keeps shared deps alive', + () { + final a = Signal(0); + final b = Computed(() => a.value * 2); + final c = Effect(() { + b.value; + }); + final e = Effect(() { + a.value; + }); + + b.dispose(); + + expect(b.isDisposed, isTrue); + expect(c.isDisposed, isTrue); + expect(a.isDisposed, isFalse); + expect(e.isDisposed, isFalse); + }, + ); + + test( + 'disposing an effect detaches dependencies and triggers auto dispose', + () { + final a = Signal(0); + final b = Computed(() => a.value + 1); + final c = Effect(() { + b.value; + }); + final e = Effect(() { + a.value; + }); + + c.dispose(); + + expect(c.isDisposed, isTrue); + expect(b.isDisposed, isTrue); + expect(a.isDisposed, isFalse); + expect(e.isDisposed, isFalse); + expect(_depsOf(e).contains(a), isTrue); + }, + ); + + test('respects explicit autoDispose false', () { + final a = Signal(0); + final b = Computed(() => a.value + 1, autoDispose: false); + final c = Effect(() { + b.value; + }, autoDispose: false); + + a.dispose(); + + expect(a.isDisposed, isTrue); + expect(b.isDisposed, isFalse); + expect(c.isDisposed, isFalse); + expect(_depsOf(b), isEmpty); + expect(_depsOf(c), isNotEmpty); + }); + + test('global autoDispose off but explicit opt-in still disposes', () { + SolidartConifg.autoDispose = false; + final a = Signal(0, autoDispose: true); + final b = Computed(() => a.value + 1, autoDispose: true); + final c = Effect(() { + b.value; + }, autoDispose: true); + final d = Effect(() { + a.value; + }, autoDispose: false); + + a.dispose(); + + expect(a.isDisposed, isTrue); + expect(b.isDisposed, isTrue); + expect(c.isDisposed, isTrue); + expect(d.isDisposed, isFalse); + expect(_depsOf(d), isEmpty); + }); +} + +List _depsOf(system.ReactiveNode node) { + final deps = []; + var link = node.deps; + while (link != null) { + deps.add(link.dep); + link = link.nextDep; + } + return deps; +} From a254bf20649425091945deee5d1a33168bd70f5f Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 21:17:06 +0800 Subject: [PATCH 024/121] Fix typo in DisposableMixin and SolidartConfig class names --- packages/solidart/lib/advanced.dart | 2 +- packages/solidart/lib/src/v3.dart | 18 +++++++++--------- packages/solidart/lib/v3.dart | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/solidart/lib/advanced.dart b/packages/solidart/lib/advanced.dart index 18878c08..41a35635 100644 --- a/packages/solidart/lib/advanced.dart +++ b/packages/solidart/lib/advanced.dart @@ -1,8 +1,8 @@ export 'src/v3.dart' show Configuration, - DisponsableMixin, Disposable, + DisposableMixin, Identifier, None, Option, diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index f1f5a124..bc2b337f 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -32,8 +32,8 @@ final class None extends Option { const None(); } -final class SolidartConifg { - const SolidartConifg._(); +final class SolidartConfig { + const SolidartConfig._(); static bool autoDispose = true; } @@ -97,7 +97,7 @@ abstract interface class ReadonlySignal } class Signal extends preset.SignalNode> - with DisponsableMixin + with DisposableMixin implements ReadonlySignal { Signal(T initialValue, {bool? autoDispose, String? name}) : this._internal(Some(initialValue), autoDispose: autoDispose, name: name); @@ -106,7 +106,7 @@ class Signal extends preset.SignalNode> Option initialValue, { String? name, bool? autoDispose, - }) : autoDispose = autoDispose ?? SolidartConifg.autoDispose, + }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, identifier = Identifier._(name), super( flags: system.ReactiveFlags.mutable, @@ -156,10 +156,10 @@ class LazySignal extends Signal { } class Computed extends preset.ComputedNode - with DisponsableMixin + with DisposableMixin implements ReadonlySignal { Computed(ValueGetter getter, {bool? autoDispose, String? name}) - : autoDispose = autoDispose ?? SolidartConifg.autoDispose, + : autoDispose = autoDispose ?? SolidartConfig.autoDispose, identifier = Identifier._(name), super(flags: system.ReactiveFlags.none, getter: (_) => getter()); @@ -183,10 +183,10 @@ class Computed extends preset.ComputedNode } class Effect extends preset.EffectNode - with DisponsableMixin + with DisposableMixin implements Disposable, Configuration { Effect(VoidCallback callback, {bool? autoDispose, String? name}) - : autoDispose = autoDispose ?? SolidartConifg.autoDispose, + : autoDispose = autoDispose ?? SolidartConfig.autoDispose, identifier = Identifier._(name), super( fn: callback, @@ -218,7 +218,7 @@ class Effect extends preset.EffectNode } } -mixin DisponsableMixin implements Disposable { +mixin DisposableMixin implements Disposable { @internal late final cleanups = []; diff --git a/packages/solidart/lib/v3.dart b/packages/solidart/lib/v3.dart index 66233ebb..dad0e6c6 100644 --- a/packages/solidart/lib/v3.dart +++ b/packages/solidart/lib/v3.dart @@ -1,4 +1,4 @@ // TODO(medz): rename the v3.dart to solidart.dart filename. export 'src/v3.dart' - show Computed, Effect, LazySignal, ReadonlySignal, Signal, SolidartConifg; + show Computed, Effect, LazySignal, ReadonlySignal, Signal, SolidartConfig; From bda37473aa21fc91324bd389f835dbdc2d569e1a Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 21:21:37 +0800 Subject: [PATCH 025/121] Fix typo in SolidartConfig class name --- packages/solidart/test/v3_auto_dispose_test.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/solidart/test/v3_auto_dispose_test.dart b/packages/solidart/test/v3_auto_dispose_test.dart index c19a6188..14bd8a6d 100644 --- a/packages/solidart/test/v3_auto_dispose_test.dart +++ b/packages/solidart/test/v3_auto_dispose_test.dart @@ -6,12 +6,12 @@ void main() { late bool previousAutoDispose; setUp(() { - previousAutoDispose = SolidartConifg.autoDispose; - SolidartConifg.autoDispose = true; + previousAutoDispose = SolidartConfig.autoDispose; + SolidartConfig.autoDispose = true; }); tearDown(() { - SolidartConifg.autoDispose = previousAutoDispose; + SolidartConfig.autoDispose = previousAutoDispose; }); test('disposing a signal cascades to its dependents', () { @@ -99,7 +99,7 @@ void main() { }); test('global autoDispose off but explicit opt-in still disposes', () { - SolidartConifg.autoDispose = false; + SolidartConfig.autoDispose = false; final a = Signal(0, autoDispose: true); final b = Computed(() => a.value + 1, autoDispose: true); final c = Effect(() { From 8bb413a3ea913764dfa68dd942bf5a9acf1f876d Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 23:35:32 +0800 Subject: [PATCH 026/121] Add untrackedValue getter to ReadonlySignal Adds an `untrackedValue` getter to the `ReadonlySignal` interface and implements it in `Signal` and `Computed`. For `Signal`, it returns the current value without tracking. For `Computed`, it returns the cached value if available, otherwise it computes the value without tracking. --- packages/solidart/lib/src/v3.dart | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index bc2b337f..4b9b9a7c 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -94,6 +94,7 @@ abstract class Disposable { abstract interface class ReadonlySignal implements system.ReactiveNode, Disposable, Configuration { T get value; + T get untrackedValue; } class Signal extends preset.SignalNode> @@ -127,6 +128,9 @@ class Signal extends preset.SignalNode> set value(T newValue) => set(Some(newValue)); + @override + T get untrackedValue => super.currentValue.unwrap(); + // TODO(nank1ro): See ReadonlySignal TODO, If `ReadonlySignal` rename // to `ReadSignal`, the `.toReadonly` method should be rename? ReadonlySignal toReadonly() => this; @@ -172,6 +176,20 @@ class Computed extends preset.ComputedNode @override T get value => super.get(); + @override + T get untrackedValue { + if (currentValue != null || null is T) { + return currentValue as T; + } + + final prevSub = preset.setActiveSub(); + try { + return value; + } finally { + preset.activeSub = prevSub; + } + } + @override void dispose() { if (isDisposed) return; From 8699c1fb2b2112fb2ba7e41b5f3dcd990434d591 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 8 Dec 2025 23:40:31 +0800 Subject: [PATCH 027/121] Add disposal assertions to Signal and Computed value getters Signal and Computed now assert they are not disposed when accessing their value property, preventing use-after-dispose bugs. --- packages/solidart/lib/src/v3.dart | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index 4b9b9a7c..b4f85467 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -124,9 +124,15 @@ class Signal extends preset.SignalNode> final Identifier identifier; @override - T get value => super.get().unwrap(); + T get value { + assert(!isDisposed, 'Signal is disposed'); + return super.get().unwrap(); + } - set value(T newValue) => set(Some(newValue)); + set value(T newValue) { + assert(!isDisposed, 'Signal is disposed'); + set(Some(newValue)); + } @override T get untrackedValue => super.currentValue.unwrap(); @@ -174,7 +180,10 @@ class Computed extends preset.ComputedNode final Identifier identifier; @override - T get value => super.get(); + T get value { + assert(!isDisposed, 'Computed is disposed'); + return get(); + } @override T get untrackedValue { From d9d4e0ad67d7f7b4ab4a9fcedc72949be002df05 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 9 Dec 2025 00:03:13 +0800 Subject: [PATCH 028/121] Add custom equality comparator to signals and computed Introduce `ValueComparator` typedef and `SignalConfiguration` interface. Add `equals` parameter to `Signal`, `LazySignal`, and `Computed` constructors, defaulting to `identical`. Update `didUpdate` methods to use the comparator for equality checks before triggering updates. --- packages/solidart/lib/src/v3.dart | 103 ++++++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 11 deletions(-) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index b4f85467..21febbd8 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -5,6 +5,7 @@ import 'package:meta/meta.dart'; import 'package:solidart/deps/preset.dart' as preset; import 'package:solidart/deps/system.dart' as system; +typedef ValueComparator = bool Function(T? a, T? b); typedef ValueGetter = T Function(); typedef VoidCallback = ValueGetter; @@ -46,7 +47,7 @@ class Identifier { final int value; } -abstract class Configuration { +abstract interface class Configuration { Identifier get identifier; bool get autoDispose; } @@ -90,9 +91,13 @@ abstract class Disposable { } } +abstract interface class SignalConfiguration implements Configuration { + ValueComparator get equals; +} + // TODO(nank1ro): Maybe rename to `ReadSignal`? medz: I still recommend `ReadonlySignal` because it is semantically clearer., https://github.com/nank1ro/solidart/pull/166#issuecomment-3623175977 abstract interface class ReadonlySignal - implements system.ReactiveNode, Disposable, Configuration { + implements system.ReactiveNode, Disposable, SignalConfiguration { T get value; T get untrackedValue; } @@ -100,11 +105,21 @@ abstract interface class ReadonlySignal class Signal extends preset.SignalNode> with DisposableMixin implements ReadonlySignal { - Signal(T initialValue, {bool? autoDispose, String? name}) - : this._internal(Some(initialValue), autoDispose: autoDispose, name: name); + Signal( + T initialValue, { + bool? autoDispose, + String? name, + ValueComparator equals = identical, + }) : this._internal( + Some(initialValue), + autoDispose: autoDispose, + name: name, + equals: equals, + ); Signal._internal( Option initialValue, { + this.equals = identical, String? name, bool? autoDispose, }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, @@ -115,7 +130,11 @@ class Signal extends preset.SignalNode> pendingValue: initialValue, ); - factory Signal.lazy({String? name, bool? autoDispose}) = LazySignal; + factory Signal.lazy({ + String? name, + bool? autoDispose, + ValueComparator equals, + }) = LazySignal; @override final bool autoDispose; @@ -123,6 +142,9 @@ class Signal extends preset.SignalNode> @override final Identifier identifier; + @override + final ValueComparator equals; + @override T get value { assert(!isDisposed, 'Signal is disposed'); @@ -148,11 +170,30 @@ class Signal extends preset.SignalNode> preset.stop(this); super.dispose(); } + + @override + bool didUpdate() { + flags = system.ReactiveFlags.mutable; + if (equals(pendingValue.unwrap(), currentValue.unwrap())) { + return false; + } + + currentValue = pendingValue; + return true; + } } class LazySignal extends Signal { - LazySignal({String? name, bool? autoDispose}) - : super._internal(const None(), name: name, autoDispose: autoDispose); + LazySignal({ + String? name, + bool? autoDispose, + ValueComparator equals = identical, + }) : super._internal( + const None(), + name: name, + autoDispose: autoDispose, + equals: equals, + ); bool get isInitialized => currentValue is Some; @@ -163,15 +204,30 @@ class LazySignal extends Signal { 'LazySignal is not initialized, Please call `.value = ` first.', ); } + + @override + bool didUpdate() { + if (!isInitialized) { + flags = system.ReactiveFlags.mutable; + currentValue = pendingValue; + return true; + } + + return super.didUpdate(); + } } class Computed extends preset.ComputedNode with DisposableMixin implements ReadonlySignal { - Computed(ValueGetter getter, {bool? autoDispose, String? name}) - : autoDispose = autoDispose ?? SolidartConfig.autoDispose, - identifier = Identifier._(name), - super(flags: system.ReactiveFlags.none, getter: (_) => getter()); + Computed( + ValueGetter getter, { + this.equals = identical, + bool? autoDispose, + String? name, + }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, + identifier = Identifier._(name), + super(flags: system.ReactiveFlags.none, getter: (_) => getter()); @override final bool autoDispose; @@ -179,6 +235,9 @@ class Computed extends preset.ComputedNode @override final Identifier identifier; + @override + final ValueComparator equals; + @override T get value { assert(!isDisposed, 'Computed is disposed'); @@ -207,6 +266,28 @@ class Computed extends preset.ComputedNode preset.stop(this); super.dispose(); } + + @override + bool didUpdate() { + preset.cycle++; + depsTail = null; + flags = system.ReactiveFlags.mutable | system.ReactiveFlags.recursedCheck; + + final prevSub = preset.setActiveSub(this); + try { + final pendingValue = getter(currentValue); + if (equals(currentValue, pendingValue)) { + return false; + } + + currentValue = pendingValue; + return true; + } finally { + preset.activeSub = prevSub; + flags &= ~system.ReactiveFlags.recursedCheck; + preset.purgeDeps(this); + } + } } class Effect extends preset.EffectNode From e29ad72d86c2950c90452761dfe33669469763f0 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 9 Dec 2025 00:08:47 +0800 Subject: [PATCH 029/121] Add tests for custom equals comparators in Signal and Computed --- packages/solidart/test/v3_equals_test.dart | 53 ++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 packages/solidart/test/v3_equals_test.dart diff --git a/packages/solidart/test/v3_equals_test.dart b/packages/solidart/test/v3_equals_test.dart new file mode 100644 index 00000000..f861d2a6 --- /dev/null +++ b/packages/solidart/test/v3_equals_test.dart @@ -0,0 +1,53 @@ +import 'package:solidart/v3.dart'; +import 'package:test/test.dart'; + +void main() { + test('Signal respects custom equals comparator', () { + var runs = 0; + final s = Signal( + 0, + equals: (a, b) => a == b, + ); + Effect(() { + s.value; + runs++; + }); + + expect(runs, 1, reason: 'effect runs once on creation'); + + s.value = 0; // same value, should be ignored + expect(runs, 1); + + s.value = 1; // different value, should rerun + expect(runs, 2); + + s.value = 1; // same again, ignored + expect(runs, 2); + }); + + test('Computed respects custom equals comparator', () { + var runs = 0; + final source = Signal(0); + final comp = Computed( + () => source.value, + equals: (a, b) { + final prevParity = (a ?? 0).isEven; + final nextParity = (b ?? 0).isEven; + return prevParity == nextParity; + }, + ); + + Effect(() { + comp.value; + runs++; + }); + + expect(runs, 1); + + source.value = 2; // parity unchanged (even), skip recompute + expect(runs, 1); + + source.value = 3; // parity changed (odd), recompute + expect(runs, 2); + }); +} From 40827053639da283a01b818d2c28e3917895b355 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 9 Dec 2025 00:18:57 +0800 Subject: [PATCH 030/121] Change default autoDispose to false and add detach flag - Set SolidartConfig.autoDispose default to false - Add SolidartConfig.detachEffects config flag - Add detach parameter to Effect constructor - Conditionally link effects in parent chain based on detach flag --- packages/solidart/lib/src/v3.dart | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index 21febbd8..5e29ca50 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -36,7 +36,8 @@ final class None extends Option { final class SolidartConfig { const SolidartConfig._(); - static bool autoDispose = true; + static bool autoDispose = false; + static bool detachEffects = false; } class Identifier { @@ -293,16 +294,24 @@ class Computed extends preset.ComputedNode class Effect extends preset.EffectNode with DisposableMixin implements Disposable, Configuration { - Effect(VoidCallback callback, {bool? autoDispose, String? name}) - : autoDispose = autoDispose ?? SolidartConfig.autoDispose, - identifier = Identifier._(name), - super( - fn: callback, - flags: - system.ReactiveFlags.watching | system.ReactiveFlags.recursedCheck, - ) { + Effect( + VoidCallback callback, { + bool? autoDispose, + String? name, + bool? detach, + }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, + identifier = Identifier._(name), + detach = detach ?? SolidartConfig.detachEffects, + super( + fn: callback, + flags: + system.ReactiveFlags.watching | system.ReactiveFlags.recursedCheck, + ) { final prevSub = preset.setActiveSub(this); - if (prevSub != null) preset.link(this, prevSub, 0); + if (prevSub != null && !this.detach) { + preset.link(this, prevSub, 0); + } + try { callback(); } finally { @@ -317,6 +326,8 @@ class Effect extends preset.EffectNode @override final Identifier identifier; + final bool detach; + @override void dispose() { if (isDisposed) return; From 57414c30be047f32f6c45e60754c6b94fa074e15 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 9 Dec 2025 01:31:17 +0800 Subject: [PATCH 031/121] Add Effect.delay factory and refactor Effect.run The Effect.delay factory creates an effect that runs its callback after a specified duration, with optional eager initialization. The Effect.run method is extracted from the constructor to support manual execution. --- packages/solidart/lib/src/v3.dart | 80 ++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 11 deletions(-) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index 5e29ca50..f9af4766 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -1,6 +1,8 @@ // ignore_for_file: public_member_api_docs // TODO(medz): Add code comments +import 'dart:async'; + import 'package:meta/meta.dart'; import 'package:solidart/deps/preset.dart' as preset; import 'package:solidart/deps/system.dart' as system; @@ -8,6 +10,10 @@ import 'package:solidart/deps/system.dart' as system; typedef ValueComparator = bool Function(T? a, T? b); typedef ValueGetter = T Function(); typedef VoidCallback = ValueGetter; +typedef DelayEffectCallback = + ValueGetter> Function( + T Function(T Function() callback) on, + ); sealed class Option { const Option(); @@ -294,7 +300,19 @@ class Computed extends preset.ComputedNode class Effect extends preset.EffectNode with DisposableMixin implements Disposable, Configuration { - Effect( + factory Effect( + VoidCallback callback, { + bool? autoDispose, + String? name, + bool? detach, + }) => Effect.manual( + callback, + autoDispose: autoDispose, + name: name, + detach: detach, + )..run(); + + Effect.manual( VoidCallback callback, { bool? autoDispose, String? name, @@ -306,18 +324,44 @@ class Effect extends preset.EffectNode fn: callback, flags: system.ReactiveFlags.watching | system.ReactiveFlags.recursedCheck, - ) { - final prevSub = preset.setActiveSub(this); - if (prevSub != null && !this.detach) { - preset.link(this, prevSub, 0); - } + ); - try { - callback(); - } finally { - preset.activeSub = prevSub; - flags &= ~system.ReactiveFlags.recursedCheck; + // TODO(nank1ro): How about this plan? + factory Effect.delay( + DelayEffectCallback factory, { + required Duration duration, + bool? autoDispose, + String? name, + bool? detach, + bool eager = true, + }) { + late final Effect effect; + T on(T Function() callback) { + final prevSub = preset.setActiveSub(effect); + try { + return callback(); + } finally { + preset.activeSub = prevSub; + effect.flags &= ~system.ReactiveFlags.recursedCheck; + } } + + final callback = factory(on); + Timer? timer; + effect = Effect.manual( + autoDispose: autoDispose, + name: name, + detach: detach, + () { + timer?.cancel(); + timer = .new(duration, () { + unawaited(Future.microtask(callback)) + }); + }, + ); + + if (eager) effect.run(); + return effect; } @override @@ -328,6 +372,20 @@ class Effect extends preset.EffectNode final bool detach; + void run() { + final prevSub = preset.setActiveSub(this); + if (!detach && prevSub != null) { + preset.link(this, prevSub, 0); + } + + try { + fn(); + } finally { + preset.activeSub = prevSub; + flags &= ~system.ReactiveFlags.recursedCheck; + } + } + @override void dispose() { if (isDisposed) return; From c63bb361e3ee247f54f84b552716a97e34014f6f Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 9 Dec 2025 01:31:33 +0800 Subject: [PATCH 032/121] Fix missing semicolon in Effect timer callback --- packages/solidart/lib/src/v3.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index f9af4766..e95b3c9e 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -355,7 +355,7 @@ class Effect extends preset.EffectNode () { timer?.cancel(); timer = .new(duration, () { - unawaited(Future.microtask(callback)) + unawaited(Future.microtask(callback)); }); }, ); From 2a1cf09b8c75bbe973d6bd6d8175862796b5ce97 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 9 Dec 2025 02:14:33 +0800 Subject: [PATCH 033/121] Add fake_async dev dependency and test Effect.delay --- packages/solidart/pubspec.yaml | 1 + packages/solidart/test/v3_effect_test.dart | 251 +++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 packages/solidart/test/v3_effect_test.dart diff --git a/packages/solidart/pubspec.yaml b/packages/solidart/pubspec.yaml index 4e65c5a3..8eaf9774 100644 --- a/packages/solidart/pubspec.yaml +++ b/packages/solidart/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: meta: ^1.11.0 dev_dependencies: + fake_async: ^1.3.3 mockito: ^5.5.1 test: ^1.25.3 very_good_analysis: ^10.0.0 diff --git a/packages/solidart/test/v3_effect_test.dart b/packages/solidart/test/v3_effect_test.dart new file mode 100644 index 00000000..5310bb22 --- /dev/null +++ b/packages/solidart/test/v3_effect_test.dart @@ -0,0 +1,251 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:solidart/deps/system.dart' as system; +import 'package:solidart/v3.dart'; +import 'package:test/test.dart'; + +void main() { + test('Effect runs immediately and reacts to dependency changes', () { + final count = Signal(0); + var runs = 0; + + Effect(() { + count.value; + runs++; + }); + + expect(runs, 1); + + count.value = 1; + expect(runs, 2); + + // Setting the same value should be ignored + count.value = 1; + expect(runs, 2); + }); + + test('Effect.manual is lazy until run is called', () { + final count = Signal(0); + var runs = 0; + + final effect = Effect.manual(() { + count.value; + runs++; + }); + + // No run yet, so nothing tracked + count.value = 1; + expect(runs, 0); + + effect.run(); + expect(runs, 1); + + count.value = 2; + expect(runs, 2); + }); + + test('dispose stops reactions and detaches dependencies', () { + final count = Signal(0); + var runs = 0; + + final effect = Effect(() { + count.value; + runs++; + }); + + expect(_depsOf(effect), contains(count)); + + effect.dispose(); + + expect(effect.isDisposed, isTrue); + expect(_depsOf(effect), isEmpty); + + count.value = 1; + expect(runs, 1); + }); + + test( + 'nested effects attach to parent by default and auto dispose with it', + () { + final previousAutoDispose = SolidartConfig.autoDispose; + SolidartConfig.autoDispose = true; + addTearDown(() { + SolidartConfig.autoDispose = previousAutoDispose; + }); + + late Effect child; + final parent = Effect(() { + child = Effect(() {}); + }); + + expect(_depsOf(parent), contains(child)); + + parent.dispose(); + + expect(parent.isDisposed, isTrue); + expect(child.isDisposed, isTrue); + }, + ); + + test('detached effects stay alive when parent is disposed', () { + final previousAutoDispose = SolidartConfig.autoDispose; + final previousDetachEffects = SolidartConfig.detachEffects; + SolidartConfig.autoDispose = true; + SolidartConfig.detachEffects = false; + addTearDown(() { + SolidartConfig.autoDispose = previousAutoDispose; + SolidartConfig.detachEffects = previousDetachEffects; + }); + + final source = Signal(0); + var childRuns = 0; + late Effect child; + final parent = Effect(() { + child = Effect(() { + source.value; + childRuns++; + }, detach: true); + source.value; + }); + + expect(_depsOf(parent), isNot(contains(child))); + + parent.dispose(); + + expect(parent.isDisposed, isTrue); + expect(child.isDisposed, isFalse); + + source.value = 1; + expect(childRuns, 2); // initial run + one more after change + + child.dispose(); + }); + + test('global detachEffects detaches nested effects by default', () { + final previousAutoDispose = SolidartConfig.autoDispose; + final previousDetachEffects = SolidartConfig.detachEffects; + SolidartConfig.autoDispose = true; + SolidartConfig.detachEffects = true; + addTearDown(() { + SolidartConfig.autoDispose = previousAutoDispose; + SolidartConfig.detachEffects = previousDetachEffects; + }); + + late Effect child; + final parent = Effect(() { + child = Effect(() {}); + }); + + expect(_depsOf(parent), isNot(contains(child))); + + parent.dispose(); + expect(parent.isDisposed, isTrue); + expect(child.isDisposed, isFalse); + + child.dispose(); + }); + + group('Effect.delay', () { + test('debounces execution and tracks dependencies via on()', () { + fakeAsync((async) { + final source = Signal(0); + var runs = 0; + + final effect = Effect.delay( + (on) => () { + on(() => source.value); + runs++; + }, + duration: const Duration(milliseconds: 100), + ); + + expect(runs, 0); + + async + ..elapse(const Duration(milliseconds: 99)) + ..flushMicrotasks(); + expect(runs, 0); + + async + ..elapse(const Duration(milliseconds: 1)) + ..flushMicrotasks(); + expect(runs, 1); + + source + ..value = 1 + ..value = 2; // additional changes before the delay expires + async.flushMicrotasks(); + expect(runs, 1); + + async + ..elapse(const Duration(milliseconds: 80)) + ..flushMicrotasks(); + expect(runs, 1); + + async + ..elapse(const Duration(milliseconds: 30)) + ..flushMicrotasks(); + expect( + runs, + 2, + reason: 'Only one delayed run despite two quick changes', + ); + + source.value = 3; + async + ..elapse(const Duration(milliseconds: 100)) + ..flushMicrotasks(); + expect(runs, 3); + + effect.dispose(); + }); + }); + + test('respects eager: false and only starts after manual run', () { + fakeAsync((async) { + final source = Signal(0); + var runs = 0; + + final effect = Effect.delay( + (on) => () { + on(() => source.value); + runs++; + }, + eager: false, + duration: const Duration(milliseconds: 10), + ); + + async + ..elapse(const Duration(milliseconds: 20)) + ..flushMicrotasks(); + expect(runs, 0); + + effect.run(); // start tracking + async + ..elapse(const Duration(milliseconds: 10)) + ..flushMicrotasks(); + expect(runs, 1); + + source.value = 1; + async + ..elapse(const Duration(milliseconds: 9)) + ..flushMicrotasks(); + expect(runs, 1); + + async + ..elapse(const Duration(milliseconds: 1)) + ..flushMicrotasks(); + expect(runs, 2); + }); + }); + }); +} + +List _depsOf(system.ReactiveNode node) { + final deps = []; + var link = node.deps; + while (link != null) { + deps.add(link.dep); + link = link.nextDep; + } + return deps; +} From 06d1d88b732954d0ea9c53641492c1101c798f17 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 9 Dec 2025 02:37:16 +0800 Subject: [PATCH 034/121] WIP: * --- packages/solidart/lib/src/v3.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index e95b3c9e..39a8f8b8 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -130,7 +130,7 @@ class Signal extends preset.SignalNode> String? name, bool? autoDispose, }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, - identifier = Identifier._(name), + identifier = ._(name), super( flags: system.ReactiveFlags.mutable, currentValue: initialValue, @@ -233,7 +233,7 @@ class Computed extends preset.ComputedNode bool? autoDispose, String? name, }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, - identifier = Identifier._(name), + identifier = ._(name), super(flags: system.ReactiveFlags.none, getter: (_) => getter()); @override @@ -305,7 +305,7 @@ class Effect extends preset.EffectNode bool? autoDispose, String? name, bool? detach, - }) => Effect.manual( + }) => .manual( callback, autoDispose: autoDispose, name: name, @@ -318,7 +318,7 @@ class Effect extends preset.EffectNode String? name, bool? detach, }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, - identifier = Identifier._(name), + identifier = ._(name), detach = detach ?? SolidartConfig.detachEffects, super( fn: callback, @@ -348,14 +348,14 @@ class Effect extends preset.EffectNode final callback = factory(on); Timer? timer; - effect = Effect.manual( + effect = .manual( autoDispose: autoDispose, name: name, detach: detach, () { timer?.cancel(); timer = .new(duration, () { - unawaited(Future.microtask(callback)); + unawaited(.microtask(callback)); }); }, ); From f72074e324589ffa2039240a937128a5927178fe Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 11 Dec 2025 00:46:22 +0800 Subject: [PATCH 035/121] Remove delayed effect support from Effect colse #168 --- packages/solidart/lib/src/core/effect.dart | 20 +---- packages/solidart/lib/src/utils.dart | 4 - packages/solidart/lib/src/v3.dart | 44 ---------- packages/solidart/test/solidart_test.dart | 16 ---- packages/solidart/test/v3_effect_test.dart | 95 ---------------------- 5 files changed, 1 insertion(+), 178 deletions(-) diff --git a/packages/solidart/lib/src/core/effect.dart b/packages/solidart/lib/src/core/effect.dart index d90f33f1..db6bf45a 100644 --- a/packages/solidart/lib/src/core/effect.dart +++ b/packages/solidart/lib/src/core/effect.dart @@ -73,9 +73,6 @@ class Effect extends preset.EffectNode implements ReactionInterface { /// The name of the effect, useful for logging String? name, - /// Delay each effect reaction - Duration? delay, - /// Whether to automatically dispose the effect (defaults to true). /// /// This happens automatically when all the tracked dependencies are @@ -93,24 +90,9 @@ class Effect extends preset.EffectNode implements ReactionInterface { try { final effectiveName = name ?? ReactiveName.nameFor('Effect'); final effectiveAutoDispose = autoDispose ?? SolidartConfig.autoDispose; - Timer? timer; - void delayedCallback() { - // coverage:ignore-start - timer?.cancel(); - // coverage:ignore-end - timer = createDelayedScheduler(delay!)(() { - if (!effect.disposed) { - callback(); - } else { - // coverage:ignore-start - timer?.cancel(); - // coverage:ignore-end - } - }); - } return effect = Effect._internal( - callback: delay == null ? callback : delayedCallback, + callback: callback, onError: onError, name: effectiveName, autoDispose: effectiveAutoDispose, diff --git a/packages/solidart/lib/src/utils.dart b/packages/solidart/lib/src/utils.dart index 50413fa8..c1c36113 100644 --- a/packages/solidart/lib/src/utils.dart +++ b/packages/solidart/lib/src/utils.dart @@ -57,10 +57,6 @@ class SolidartCaughtException extends SolidartException { StackTrace? get stackTrace => _stackTrace; } -/// Creates a delayer scheduler with the given [duration]. -Timer Function(void Function()) createDelayedScheduler(Duration duration) => - (fn) => Timer(duration, fn); - /// The `Option` class represents an optional value. /// It is either `Some` or `None`. diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index 39a8f8b8..b932aa12 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -1,8 +1,6 @@ // ignore_for_file: public_member_api_docs // TODO(medz): Add code comments -import 'dart:async'; - import 'package:meta/meta.dart'; import 'package:solidart/deps/preset.dart' as preset; import 'package:solidart/deps/system.dart' as system; @@ -10,10 +8,6 @@ import 'package:solidart/deps/system.dart' as system; typedef ValueComparator = bool Function(T? a, T? b); typedef ValueGetter = T Function(); typedef VoidCallback = ValueGetter; -typedef DelayEffectCallback = - ValueGetter> Function( - T Function(T Function() callback) on, - ); sealed class Option { const Option(); @@ -326,44 +320,6 @@ class Effect extends preset.EffectNode system.ReactiveFlags.watching | system.ReactiveFlags.recursedCheck, ); - // TODO(nank1ro): How about this plan? - factory Effect.delay( - DelayEffectCallback factory, { - required Duration duration, - bool? autoDispose, - String? name, - bool? detach, - bool eager = true, - }) { - late final Effect effect; - T on(T Function() callback) { - final prevSub = preset.setActiveSub(effect); - try { - return callback(); - } finally { - preset.activeSub = prevSub; - effect.flags &= ~system.ReactiveFlags.recursedCheck; - } - } - - final callback = factory(on); - Timer? timer; - effect = .manual( - autoDispose: autoDispose, - name: name, - detach: detach, - () { - timer?.cancel(); - timer = .new(duration, () { - unawaited(.microtask(callback)); - }); - }, - ); - - if (eager) effect.run(); - return effect; - } - @override final bool autoDispose; diff --git a/packages/solidart/test/solidart_test.dart b/packages/solidart/test/solidart_test.dart index 121880e5..43a4a290 100644 --- a/packages/solidart/test/solidart_test.dart +++ b/packages/solidart/test/solidart_test.dart @@ -467,22 +467,6 @@ void main() { verify(cb(4)).called(2); }); - test('check effect reaction with delay', () async { - final cb = MockCallbackFunction(); - final disposeEffect = Effect( - cb, - delay: const Duration(milliseconds: 500), - autoDispose: false, - onError: (error) { - // ignore - }, - ); - addTearDown(disposeEffect.dispose); - verifyNever(cb()); - await Future.delayed(const Duration(milliseconds: 501)); - verify(cb()).called(1); - }); - test('check effect onError', () async { Object? detectedError; Effect( diff --git a/packages/solidart/test/v3_effect_test.dart b/packages/solidart/test/v3_effect_test.dart index 5310bb22..d2b9f4bb 100644 --- a/packages/solidart/test/v3_effect_test.dart +++ b/packages/solidart/test/v3_effect_test.dart @@ -1,4 +1,3 @@ -import 'package:fake_async/fake_async.dart'; import 'package:solidart/deps/system.dart' as system; import 'package:solidart/v3.dart'; import 'package:test/test.dart'; @@ -144,100 +143,6 @@ void main() { child.dispose(); }); - group('Effect.delay', () { - test('debounces execution and tracks dependencies via on()', () { - fakeAsync((async) { - final source = Signal(0); - var runs = 0; - - final effect = Effect.delay( - (on) => () { - on(() => source.value); - runs++; - }, - duration: const Duration(milliseconds: 100), - ); - - expect(runs, 0); - - async - ..elapse(const Duration(milliseconds: 99)) - ..flushMicrotasks(); - expect(runs, 0); - - async - ..elapse(const Duration(milliseconds: 1)) - ..flushMicrotasks(); - expect(runs, 1); - - source - ..value = 1 - ..value = 2; // additional changes before the delay expires - async.flushMicrotasks(); - expect(runs, 1); - - async - ..elapse(const Duration(milliseconds: 80)) - ..flushMicrotasks(); - expect(runs, 1); - - async - ..elapse(const Duration(milliseconds: 30)) - ..flushMicrotasks(); - expect( - runs, - 2, - reason: 'Only one delayed run despite two quick changes', - ); - - source.value = 3; - async - ..elapse(const Duration(milliseconds: 100)) - ..flushMicrotasks(); - expect(runs, 3); - - effect.dispose(); - }); - }); - - test('respects eager: false and only starts after manual run', () { - fakeAsync((async) { - final source = Signal(0); - var runs = 0; - - final effect = Effect.delay( - (on) => () { - on(() => source.value); - runs++; - }, - eager: false, - duration: const Duration(milliseconds: 10), - ); - - async - ..elapse(const Duration(milliseconds: 20)) - ..flushMicrotasks(); - expect(runs, 0); - - effect.run(); // start tracking - async - ..elapse(const Duration(milliseconds: 10)) - ..flushMicrotasks(); - expect(runs, 1); - - source.value = 1; - async - ..elapse(const Duration(milliseconds: 9)) - ..flushMicrotasks(); - expect(runs, 1); - - async - ..elapse(const Duration(milliseconds: 1)) - ..flushMicrotasks(); - expect(runs, 2); - }); - }); - }); } List _depsOf(system.ReactiveNode node) { From 3ef0fdeafa9715c24275f653464da5ed3850cc0b Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 01:46:08 +0800 Subject: [PATCH 036/121] Add previous value tracking to signals and computeds - Add `trackPreviousValue` static config and instance option - Add `previousValue` and `untrackedPreviousValue` getters - Update `didUpdate` and compute logic to store previous values - Add comprehensive tests for new functionality --- packages/solidart/lib/src/v3.dart | 72 ++++++++++++++- .../solidart/test/v3_previous_value_test.dart | 91 +++++++++++++++++++ 2 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 packages/solidart/test/v3_previous_value_test.dart diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index b932aa12..c559d257 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -38,6 +38,7 @@ final class SolidartConfig { static bool autoDispose = false; static bool detachEffects = false; + static bool trackPreviousValue = true; } class Identifier { @@ -94,6 +95,7 @@ abstract class Disposable { abstract interface class SignalConfiguration implements Configuration { ValueComparator get equals; + bool get trackPreviousValue; } // TODO(nank1ro): Maybe rename to `ReadSignal`? medz: I still recommend `ReadonlySignal` because it is semantically clearer., https://github.com/nank1ro/solidart/pull/166#issuecomment-3623175977 @@ -101,6 +103,8 @@ abstract interface class ReadonlySignal implements system.ReactiveNode, Disposable, SignalConfiguration { T get value; T get untrackedValue; + T? get previousValue; + T? get untrackedPreviousValue; } class Signal extends preset.SignalNode> @@ -111,11 +115,13 @@ class Signal extends preset.SignalNode> bool? autoDispose, String? name, ValueComparator equals = identical, + bool? trackPreviousValue, }) : this._internal( Some(initialValue), autoDispose: autoDispose, name: name, equals: equals, + trackPreviousValue: trackPreviousValue, ); Signal._internal( @@ -123,7 +129,10 @@ class Signal extends preset.SignalNode> this.equals = identical, String? name, bool? autoDispose, + bool? trackPreviousValue, }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, + trackPreviousValue = + trackPreviousValue ?? SolidartConfig.trackPreviousValue, identifier = ._(name), super( flags: system.ReactiveFlags.mutable, @@ -135,6 +144,7 @@ class Signal extends preset.SignalNode> String? name, bool? autoDispose, ValueComparator equals, + bool? trackPreviousValue, }) = LazySignal; @override @@ -146,6 +156,11 @@ class Signal extends preset.SignalNode> @override final ValueComparator equals; + @override + final bool trackPreviousValue; + + Option _previousValue = const None(); + @override T get value { assert(!isDisposed, 'Signal is disposed'); @@ -160,6 +175,19 @@ class Signal extends preset.SignalNode> @override T get untrackedValue => super.currentValue.unwrap(); + @override + T? get previousValue { + if (!trackPreviousValue) return null; + value; + return _previousValue.safeUnwrap(); + } + + @override + T? get untrackedPreviousValue { + if (!trackPreviousValue) return null; + return _previousValue.safeUnwrap(); + } + // TODO(nank1ro): See ReadonlySignal TODO, If `ReadonlySignal` rename // to `ReadSignal`, the `.toReadonly` method should be rename? ReadonlySignal toReadonly() => this; @@ -175,11 +203,19 @@ class Signal extends preset.SignalNode> @override bool didUpdate() { flags = system.ReactiveFlags.mutable; - if (equals(pendingValue.unwrap(), currentValue.unwrap())) { + final current = currentValue; + final pending = pendingValue; + if (current is Some && + pending is Some && + equals(pending.value, current.value)) { return false; } - currentValue = pendingValue; + if (trackPreviousValue && current is Some) { + _previousValue = current; + } + + currentValue = pending; return true; } } @@ -189,11 +225,13 @@ class LazySignal extends Signal { String? name, bool? autoDispose, ValueComparator equals = identical, + bool? trackPreviousValue, }) : super._internal( const None(), name: name, autoDispose: autoDispose, equals: equals, + trackPreviousValue: trackPreviousValue, ); bool get isInitialized => currentValue is Some; @@ -226,7 +264,10 @@ class Computed extends preset.ComputedNode this.equals = identical, bool? autoDispose, String? name, + bool? trackPreviousValue, }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, + trackPreviousValue = + trackPreviousValue ?? SolidartConfig.trackPreviousValue, identifier = ._(name), super(flags: system.ReactiveFlags.none, getter: (_) => getter()); @@ -239,6 +280,11 @@ class Computed extends preset.ComputedNode @override final ValueComparator equals; + @override + final bool trackPreviousValue; + + Option _previousValue = const None(); + @override T get value { assert(!isDisposed, 'Computed is disposed'); @@ -259,6 +305,19 @@ class Computed extends preset.ComputedNode } } + @override + T? get previousValue { + if (!trackPreviousValue) return null; + value; + return _previousValue.safeUnwrap(); + } + + @override + T? get untrackedPreviousValue { + if (!trackPreviousValue) return null; + return _previousValue.safeUnwrap(); + } + @override void dispose() { if (isDisposed) return; @@ -276,11 +335,16 @@ class Computed extends preset.ComputedNode final prevSub = preset.setActiveSub(this); try { - final pendingValue = getter(currentValue); - if (equals(currentValue, pendingValue)) { + final previousValue = currentValue; + final pendingValue = getter(previousValue); + if (equals(previousValue, pendingValue)) { return false; } + if (trackPreviousValue && (previousValue is T)) { + _previousValue = Some(previousValue); + } + currentValue = pendingValue; return true; } finally { diff --git a/packages/solidart/test/v3_previous_value_test.dart b/packages/solidart/test/v3_previous_value_test.dart new file mode 100644 index 00000000..ed0d238c --- /dev/null +++ b/packages/solidart/test/v3_previous_value_test.dart @@ -0,0 +1,91 @@ +import 'package:solidart/v3.dart'; +import 'package:test/test.dart'; + +void main() { + group('Signal previous value', () { + test('tracks previousValue and untrackedPreviousValue', () { + final signal = Signal(0); + + expect(signal.previousValue, isNull); + expect(signal.untrackedPreviousValue, isNull); + + signal.value = 1; + + expect(signal.previousValue, 0); + expect(signal.untrackedPreviousValue, 0); + + signal.value = 2; + + expect(signal.previousValue, 1); + expect(signal.untrackedPreviousValue, 1); + }); + + test('updates previous only after read', () { + final signal = Signal(0); + + signal.value = 1; + + expect(signal.untrackedPreviousValue, isNull); + + signal.value; + + expect(signal.untrackedPreviousValue, 0); + }); + + test('respects trackPreviousValue false', () { + final signal = Signal(0, trackPreviousValue: false); + + signal.value = 1; + signal.value; + + expect(signal.previousValue, isNull); + expect(signal.untrackedPreviousValue, isNull); + }); + }); + + group('Computed previous value', () { + test('tracks previousValue and untrackedPreviousValue', () { + final source = Signal(1); + final computed = Computed(() => source.value * 2); + + expect(computed.previousValue, isNull); + expect(computed.value, 2); + + source.value = 2; + + expect(computed.previousValue, 2); + expect(computed.untrackedPreviousValue, 2); + expect(computed.value, 4); + }); + + test('updates previous only after read', () { + final source = Signal(1); + final computed = Computed(() => source.value * 2); + + computed.value; + + source.value = 2; + + expect(computed.untrackedPreviousValue, isNull); + + computed.value; + + expect(computed.untrackedPreviousValue, 2); + }); + + test('respects trackPreviousValue false', () { + final source = Signal(1); + final computed = Computed( + () => source.value * 2, + trackPreviousValue: false, + ); + + computed.value; + source.value = 2; + computed.value; + + expect(computed.previousValue, isNull); + expect(computed.untrackedPreviousValue, isNull); + }); + }); +} From c6d3ea21d252487a40152c93d69252d51ec91dba Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 02:00:47 +0800 Subject: [PATCH 037/121] Add batch and untracked utility functions --- packages/solidart/lib/src/v3.dart | 18 +++++++++++ packages/solidart/lib/v3.dart | 10 +++++- packages/solidart/test/v3_batch_test.dart | 31 +++++++++++++++++++ packages/solidart/test/v3_untracked_test.dart | 28 +++++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 packages/solidart/test/v3_batch_test.dart create mode 100644 packages/solidart/test/v3_untracked_test.dart diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index c559d257..2fe26128 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -41,6 +41,24 @@ final class SolidartConfig { static bool trackPreviousValue = true; } +T untracked(T Function() callback) { + final prevSub = preset.setActiveSub(); + try { + return callback(); + } finally { + preset.setActiveSub(prevSub); + } +} + +T batch(T Function() fn) { + preset.startBatch(); + try { + return fn(); + } finally { + preset.endBatch(); + } +} + class Identifier { Identifier._(this.name) : value = _counter++; static int _counter = 0; diff --git a/packages/solidart/lib/v3.dart b/packages/solidart/lib/v3.dart index dad0e6c6..d2ee064c 100644 --- a/packages/solidart/lib/v3.dart +++ b/packages/solidart/lib/v3.dart @@ -1,4 +1,12 @@ // TODO(medz): rename the v3.dart to solidart.dart filename. export 'src/v3.dart' - show Computed, Effect, LazySignal, ReadonlySignal, Signal, SolidartConfig; + show + Computed, + Effect, + LazySignal, + ReadonlySignal, + Signal, + SolidartConfig, + batch, + untracked; diff --git a/packages/solidart/test/v3_batch_test.dart b/packages/solidart/test/v3_batch_test.dart new file mode 100644 index 00000000..3c89ec15 --- /dev/null +++ b/packages/solidart/test/v3_batch_test.dart @@ -0,0 +1,31 @@ +import 'package:solidart/v3.dart'; +import 'package:test/test.dart'; + +void main() { + test('batch groups updates and flushes once', () { + final x = Signal(10); + final y = Signal(20); + final total = Signal(30); + + final calls = <({int x, int y, int total})>[]; + + Effect(() { + calls.add((x: x.value, y: y.value, total: total.value)); + }); + + expect(calls, [ + (x: 10, y: 20, total: 30), + ]); + + batch(() { + x.value++; + y.value++; + total.value = x.value + y.value; + }); + + expect(calls, [ + (x: 10, y: 20, total: 30), + (x: 11, y: 21, total: 32), + ]); + }); +} diff --git a/packages/solidart/test/v3_untracked_test.dart b/packages/solidart/test/v3_untracked_test.dart new file mode 100644 index 00000000..c14fd951 --- /dev/null +++ b/packages/solidart/test/v3_untracked_test.dart @@ -0,0 +1,28 @@ +import 'package:solidart/v3.dart'; +import 'package:test/test.dart'; + +void main() { + test('untracked prevents dependency tracking', () { + final count = Signal(0); + final effectCount = Signal(0); + var runs = 0; + + Effect(() { + count.value; + runs++; + effectCount.value = untracked(() => effectCount.value + 1); + }); + + expect(runs, 1); + expect(effectCount.value, 1); + + count.value = 1; + + expect(runs, 2); + expect(effectCount.value, 2); + + effectCount.value = 3; + + expect(runs, 2); + }); +} From 3eb2dd47a3768624a0ba9f1d6d46b5fd001d080e Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 02:45:50 +0800 Subject: [PATCH 038/121] Add reactive collections and resource API - Add ReactiveList, ReactiveMap, ReactiveSet with mutation tracking - Add Resource API with fetcher and stream support - Add useRefreshing config option for resources - Add comprehensive test coverage for new features --- packages/solidart/lib/src/v3.dart | 925 ++++++++++++++++++ packages/solidart/lib/v3.dart | 9 + .../solidart/test/v3_collections_test.dart | 120 +++ packages/solidart/test/v3_resource_test.dart | 312 ++++++ 4 files changed, 1366 insertions(+) create mode 100644 packages/solidart/test/v3_collections_test.dart create mode 100644 packages/solidart/test/v3_resource_test.dart diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index 2fe26128..36737e0f 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -1,6 +1,10 @@ // ignore_for_file: public_member_api_docs // TODO(medz): Add code comments +import 'dart:async'; +import 'dart:collection'; +import 'dart:math'; + import 'package:meta/meta.dart'; import 'package:solidart/deps/preset.dart' as preset; import 'package:solidart/deps/system.dart' as system; @@ -39,6 +43,7 @@ final class SolidartConfig { static bool autoDispose = false; static bool detachEffects = false; static bool trackPreviousValue = true; + static bool useRefreshing = true; } T untracked(T Function() callback) { @@ -274,6 +279,467 @@ class LazySignal extends Signal { } } +class ReactiveList extends Signal> with ListMixin { + ReactiveList( + Iterable initialValue, { + bool? autoDispose, + String? name, + ValueComparator> equals = identical, + bool? trackPreviousValue, + }) : super( + List.of(initialValue), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: trackPreviousValue, + ); + + List _copy() => List.of(untrackedValue); + + bool _listEquals(List a, List b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + + @override + int get length => value.length; + + @override + set length(int newLength) { + final current = untrackedValue; + if (current.length == newLength) return; + final next = List.of(current); + next.length = newLength; + value = next; + } + + @override + E operator [](int index) => value[index]; + + @override + void operator []=(int index, E element) { + final current = untrackedValue; + if (current[index] == element) return; + final next = List.of(current); + next[index] = element; + value = next; + } + + @override + void add(E element) { + final next = _copy()..add(element); + value = next; + } + + @override + void addAll(Iterable iterable) { + if (iterable.isEmpty) return; + final next = _copy()..addAll(iterable); + value = next; + } + + @override + void insert(int index, E element) { + final next = _copy()..insert(index, element); + value = next; + } + + @override + void insertAll(int index, Iterable iterable) { + if (iterable.isEmpty) return; + final next = _copy()..insertAll(index, iterable); + value = next; + } + + @override + bool remove(Object? element) { + final current = untrackedValue; + final index = current.indexWhere((value) => value == element); + if (index == -1) return false; + final next = List.of(current)..removeAt(index); + value = next; + return true; + } + + @override + E removeAt(int index) { + final current = untrackedValue; + final removed = current[index]; + final next = List.of(current)..removeAt(index); + value = next; + return removed; + } + + @override + E removeLast() { + final current = untrackedValue; + final removed = current.last; + final next = List.of(current)..removeLast(); + value = next; + return removed; + } + + @override + void removeRange(int start, int end) { + if (end <= start) return; + final next = _copy()..removeRange(start, end); + value = next; + } + + @override + void replaceRange(int start, int end, Iterable replacements) { + final next = _copy()..replaceRange(start, end, replacements); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + + @override + void setAll(int index, Iterable iterable) { + final next = _copy()..setAll(index, iterable); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + + @override + void setRange(int start, int end, Iterable iterable, [int skipCount = 0]) { + final next = _copy()..setRange(start, end, iterable, skipCount); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + + @override + void fillRange(int start, int end, [E? fillValue]) { + if (end <= start) return; + final next = _copy()..fillRange(start, end, fillValue); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + + @override + void clear() { + if (untrackedValue.isEmpty) return; + value = []; + } + + @override + void sort([int Function(E a, E b)? compare]) { + if (untrackedValue.length < 2) return; + final next = _copy()..sort(compare); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + + @override + void shuffle([Random? random]) { + if (untrackedValue.length < 2) return; + final next = _copy()..shuffle(random); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + + @override + void removeWhere(bool Function(E element) test) { + final current = untrackedValue; + final next = List.of(current)..removeWhere(test); + if (next.length == current.length) return; + value = next; + } + + @override + void retainWhere(bool Function(E element) test) { + final current = untrackedValue; + final next = List.of(current)..retainWhere(test); + if (next.length == current.length) return; + value = next; + } + + @override + List cast() => ReactiveList(untrackedValue.cast()); + + @override + String toString() => + 'ReactiveList<$E>(value: ${untrackedValue}, previousValue: ${untrackedPreviousValue})'; +} + +class ReactiveSet extends Signal> with SetMixin { + ReactiveSet( + Iterable initialValue, { + bool? autoDispose, + String? name, + ValueComparator> equals = identical, + bool? trackPreviousValue, + }) : super( + Set.of(initialValue), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: trackPreviousValue, + ); + + Set _copy() => Set.of(untrackedValue); + + @override + int get length => value.length; + + @override + Iterator get iterator => value.iterator; + + @override + bool contains(Object? element) { + value; + return untrackedValue.contains(element); + } + + @override + E? lookup(Object? element) { + value; + return untrackedValue.lookup(element); + } + + @override + bool add(E value) { + final current = untrackedValue; + if (current.contains(value)) return false; + final next = Set.of(current)..add(value); + this.value = next; + return true; + } + + @override + void addAll(Iterable elements) { + if (elements.isEmpty) return; + final next = _copy()..addAll(elements); + if (next.length == untrackedValue.length) return; + value = next; + } + + @override + bool remove(Object? value) { + final current = untrackedValue; + if (!current.contains(value)) return false; + final next = Set.of(current)..remove(value); + this.value = next; + return true; + } + + @override + void removeAll(Iterable elements) { + if (elements.isEmpty) return; + final current = untrackedValue; + final next = Set.of(current)..removeAll(elements); + if (next.length == current.length) return; + value = next; + } + + @override + void retainAll(Iterable elements) { + final current = untrackedValue; + final next = Set.of(current)..retainAll(elements); + if (next.length == current.length) return; + value = next; + } + + @override + void removeWhere(bool Function(E element) test) { + final current = untrackedValue; + final next = Set.of(current)..removeWhere(test); + if (next.length == current.length) return; + value = next; + } + + @override + void retainWhere(bool Function(E element) test) { + final current = untrackedValue; + final next = Set.of(current)..retainWhere(test); + if (next.length == current.length) return; + value = next; + } + + @override + void clear() { + if (untrackedValue.isEmpty) return; + value = {}; + } + + @override + Set toSet() => Set.of(untrackedValue); + + @override + Set cast() => ReactiveSet(untrackedValue.cast()); + + @override + String toString() => + 'ReactiveSet<$E>(value: ${untrackedValue}, previousValue: ${untrackedPreviousValue})'; +} + +class ReactiveMap extends Signal> with MapMixin { + ReactiveMap( + Map initialValue, { + bool? autoDispose, + String? name, + ValueComparator> equals = identical, + bool? trackPreviousValue, + }) : super( + Map.of(initialValue), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: trackPreviousValue, + ); + + Map _copy() => Map.of(untrackedValue); + + @override + V? operator [](Object? key) { + value; + return untrackedValue[key]; + } + + @override + void operator []=(K key, V value) { + final current = untrackedValue; + final existing = current[key]; + if (current.containsKey(key) && existing == value) return; + final next = Map.of(current); + next[key] = value; + this.value = next; + } + + @override + void clear() { + if (untrackedValue.isEmpty) return; + value = {}; + } + + @override + Iterable get keys { + value; + return untrackedValue.keys; + } + + @override + V? remove(Object? key) { + final current = untrackedValue; + if (!current.containsKey(key)) return null; + final next = Map.of(current); + final removed = next.remove(key); + value = next; + return removed; + } + + @override + int get length { + value; + return untrackedValue.length; + } + + @override + bool get isEmpty { + value; + return untrackedValue.isEmpty; + } + + @override + bool get isNotEmpty { + value; + return untrackedValue.isNotEmpty; + } + + @override + bool containsKey(Object? key) { + value; + return untrackedValue.containsKey(key); + } + + @override + bool containsValue(Object? candidate) { + this.value; + return untrackedValue.containsValue(candidate); + } + + @override + void addAll(Map other) { + if (other.isEmpty) return; + final next = _copy()..addAll(other); + if (next.length == untrackedValue.length) return; + value = next; + } + + @override + V putIfAbsent(K key, V Function() ifAbsent) { + final current = untrackedValue; + if (current.containsKey(key)) { + return current[key] as V; + } + final next = Map.of(current); + final value = ifAbsent(); + next[key] = value; + this.value = next; + return value; + } + + @override + V update( + K key, + V Function(V value) update, { + V Function()? ifAbsent, + }) { + final current = untrackedValue; + if (!current.containsKey(key)) { + if (ifAbsent == null) { + throw ArgumentError.value(key, 'key', 'Key not in map.'); + } + final next = Map.of(current); + final value = ifAbsent(); + next[key] = value; + this.value = next; + return value; + } + + final next = Map.of(current); + final value = update(next[key] as V); + next[key] = value; + this.value = next; + return value; + } + + @override + void updateAll(V Function(K key, V value) update) { + final current = untrackedValue; + if (current.isEmpty) return; + final next = Map.of(current); + next.updateAll(update); + if (next.length == current.length && + next.keys.every((key) { + return current.containsKey(key) && current[key] == next[key]; + })) { + return; + } + value = next; + } + + @override + void removeWhere(bool Function(K key, V value) test) { + final current = untrackedValue; + if (current.isEmpty) return; + final next = Map.of(current)..removeWhere(test); + if (next.length == current.length) return; + value = next; + } + + @override + Map cast() => + ReactiveMap(untrackedValue.cast()); + + @override + String toString() => + 'ReactiveMap<$K, $V>(value: ${untrackedValue}, previousValue: ${untrackedPreviousValue})'; +} + class Computed extends preset.ComputedNode with DisposableMixin implements ReadonlySignal { @@ -433,6 +899,465 @@ class Effect extends preset.EffectNode } } +class Resource extends Signal> { + Resource( + this.fetcher, { + this.source, + this.lazy = true, + bool? useRefreshing, + bool? trackPreviousState, + this.debounceDelay, + bool? autoDispose, + String? name, + ValueComparator> equals = identical, + }) : stream = null, + useRefreshing = useRefreshing ?? SolidartConfig.useRefreshing, + super( + ResourceState.loading(), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: + trackPreviousState ?? SolidartConfig.trackPreviousValue, + ) { + if (!lazy) { + _resolveIfNeeded(); + } + } + + Resource.stream( + this.stream, { + this.source, + this.lazy = true, + bool? useRefreshing, + bool? trackPreviousState, + this.debounceDelay, + bool? autoDispose, + String? name, + ValueComparator> equals = identical, + }) : fetcher = null, + useRefreshing = useRefreshing ?? SolidartConfig.useRefreshing, + super( + ResourceState.loading(), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: + trackPreviousState ?? SolidartConfig.trackPreviousValue, + ) { + if (!lazy) { + _resolveIfNeeded(); + } + } + + final ReadonlySignal? source; + final Future Function()? fetcher; + final Stream Function()? stream; + final bool lazy; + final bool useRefreshing; + final Duration? debounceDelay; + + bool _resolved = false; + int _version = 0; + Future? _resolveFuture; + Effect? _sourceEffect; + StreamSubscription? _streamSubscription; + Timer? _debounceTimer; + + ResourceState get state { + _resolveIfNeeded(); + return value; + } + + set state(ResourceState next) => value = next; + + ResourceState? get previousState { + _resolveIfNeeded(); + if (!_resolved) return null; + return previousValue; + } + + ResourceState get untrackedState => untrackedValue; + + ResourceState? get untrackedPreviousState => untrackedPreviousValue; + + Future resolve() async { + if (isDisposed) return; + if (_resolveFuture != null) return _resolveFuture!; + if (_resolved) return; + + _resolved = true; + _resolveFuture = _doResolve().whenComplete(() { + _resolveFuture = null; + }); + + return _resolveFuture!; + } + + Future refresh() async { + if (!_resolved) { + await resolve(); + return; + } + + if (fetcher != null) { + return _refetch(); + } + + if (stream != null) { + _resubscribe(); + return; + } + } + + @override + void dispose() { + _debounceTimer?.cancel(); + _debounceTimer = null; + _sourceEffect?.dispose(); + _sourceEffect = null; + _streamSubscription?.cancel(); + _streamSubscription = null; + super.dispose(); + } + + void _resolveIfNeeded() { + if (!_resolved) { + unawaited(resolve()); + } + } + + Future _doResolve() async { + if (fetcher != null) { + await _fetch(); + } + + if (stream != null) { + _subscribe(); + } + + if (source != null) { + _setupSourceEffect(); + } + } + + void _setupSourceEffect() { + var skipped = false; + _sourceEffect = Effect( + () { + source!.value; + if (!skipped) { + skipped = true; + return; + } + if (debounceDelay != null) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(debounceDelay!, () { + if (isDisposed) return; + untracked(refresh); + }); + } else { + untracked(refresh); + } + }, + autoDispose: false, + ); + } + + Future _fetch() async { + final requestId = ++_version; + try { + final result = await fetcher!(); + if (_isStale(requestId)) return; + state = ResourceState.ready(result); + } catch (e, s) { + if (_isStale(requestId)) return; + state = ResourceState.error(e, stackTrace: s); + } + } + + Future _refetch() async { + _transition(); + return _fetch(); + } + + void _subscribe() { + _listenStream(); + } + + void _resubscribe() { + _streamSubscription?.cancel(); + _streamSubscription = null; + _transition(); + _listenStream(); + } + + void _listenStream() { + final requestId = ++_version; + _streamSubscription = stream!().listen( + (data) { + if (_isStale(requestId)) return; + state = ResourceState.ready(data); + }, + onError: (Object error, StackTrace stackTrace) { + if (_isStale(requestId)) return; + state = ResourceState.error(error, stackTrace: stackTrace); + }, + ); + } + + bool _isStale(int requestId) => requestId != _version || isDisposed; + + void _transition() { + if (!useRefreshing) { + state = ResourceState.loading(); + return; + } + state.map( + ready: (ready) { + state = ready.copyWith(isRefreshing: true); + }, + error: (error) { + state = error.copyWith(isRefreshing: true); + }, + loading: (_) { + state = ResourceState.loading(); + }, + ); + } +} + +@sealed +@immutable +sealed class ResourceState { + const factory ResourceState.ready(T data, {bool isRefreshing}) = + ResourceReady; + const factory ResourceState.loading() = ResourceLoading; + const factory ResourceState.error( + Object error, { + StackTrace? stackTrace, + bool isRefreshing, + }) = ResourceError; + + const ResourceState(); + + R map({ + required R Function(ResourceReady ready) ready, + required R Function(ResourceError error) error, + required R Function(ResourceLoading loading) loading, + }); +} + +@immutable +class ResourceReady implements ResourceState { + const ResourceReady(this.value, {this.isRefreshing = false}); + + final T value; + final bool isRefreshing; + + @override + R map({ + required R Function(ResourceReady ready) ready, + required R Function(ResourceError error) error, + required R Function(ResourceLoading loading) loading, + }) { + return ready(this); + } + + ResourceReady copyWith({ + T? value, + bool? isRefreshing, + }) { + return ResourceReady( + value ?? this.value, + isRefreshing: isRefreshing ?? this.isRefreshing, + ); + } + + @override + String toString() { + return 'ResourceReady<$T>(value: $value, refreshing: $isRefreshing)'; + } + + @override + bool operator ==(Object other) { + return runtimeType == other.runtimeType && + other is ResourceReady && + other.value == value && + other.isRefreshing == isRefreshing; + } + + @override + int get hashCode => Object.hash(runtimeType, value, isRefreshing); +} + +@immutable +class ResourceLoading implements ResourceState { + const ResourceLoading(); + + @override + R map({ + required R Function(ResourceReady ready) ready, + required R Function(ResourceError error) error, + required R Function(ResourceLoading loading) loading, + }) { + return loading(this); + } + + @override + String toString() => 'ResourceLoading<$T>()'; + + @override + bool operator ==(Object other) => runtimeType == other.runtimeType; + + @override + int get hashCode => runtimeType.hashCode; +} + +@immutable +class ResourceError implements ResourceState { + const ResourceError( + this.error, { + this.stackTrace, + this.isRefreshing = false, + }); + + final Object error; + final StackTrace? stackTrace; + final bool isRefreshing; + + @override + R map({ + required R Function(ResourceReady ready) ready, + required R Function(ResourceError error) error, + required R Function(ResourceLoading loading) loading, + }) { + return error(this); + } + + ResourceError copyWith({ + Object? error, + StackTrace? stackTrace, + bool? isRefreshing, + }) { + return ResourceError( + error ?? this.error, + stackTrace: stackTrace ?? this.stackTrace, + isRefreshing: isRefreshing ?? this.isRefreshing, + ); + } + + @override + String toString() { + return 'ResourceError<$T>(error: $error, stackTrace: $stackTrace, ' + 'refreshing: $isRefreshing)'; + } + + @override + bool operator ==(Object other) { + return runtimeType == other.runtimeType && + other is ResourceError && + other.error == error && + other.stackTrace == stackTrace && + other.isRefreshing == isRefreshing; + } + + @override + int get hashCode => Object.hash(runtimeType, error, stackTrace, isRefreshing); +} + +extension ResourceStateExtensions on ResourceState { + bool get isLoading => this is ResourceLoading; + bool get hasError => this is ResourceError; + bool get isReady => this is ResourceReady; + bool get isRefreshing => switch (this) { + ResourceReady(:final isRefreshing) => isRefreshing, + ResourceError(:final isRefreshing) => isRefreshing, + ResourceLoading() => false, + }; + + ResourceReady? get asReady => map( + ready: (r) => r, + error: (_) => null, + loading: (_) => null, + ); + + ResourceError? get asError => map( + error: (e) => e, + ready: (_) => null, + loading: (_) => null, + ); + + T? get value => map( + ready: (r) => r.value, + // ignore: only_throw_errors + error: (r) => throw r.error, + loading: (_) => null, + ); + + Object? get error => map( + error: (r) => r.error, + ready: (_) => null, + loading: (_) => null, + ); + + R when({ + required R Function(T data) ready, + required R Function(Object error, StackTrace? stackTrace) error, + required R Function() loading, + }) { + return map( + ready: (r) => ready(r.value), + error: (e) => error(e.error, e.stackTrace), + loading: (_) => loading(), + ); + } + + R maybeWhen({ + required R Function() orElse, + R Function(T data)? ready, + R Function(Object error, StackTrace? stackTrace)? error, + R Function()? loading, + }) { + return map( + ready: (r) { + if (ready != null) return ready(r.value); + return orElse(); + }, + error: (e) { + if (error != null) return error(e.error, e.stackTrace); + return orElse(); + }, + loading: (l) { + if (loading != null) return loading(); + return orElse(); + }, + ); + } + + R maybeMap({ + required R Function() orElse, + R Function(ResourceReady ready)? ready, + R Function(ResourceError error)? error, + R Function(ResourceLoading loading)? loading, + }) { + return map( + ready: (r) { + if (ready != null) return ready(r); + return orElse(); + }, + error: (e) { + if (error != null) return error(e); + return orElse(); + }, + loading: (l) { + if (loading != null) return loading(l); + return orElse(); + }, + ); + } +} + mixin DisposableMixin implements Disposable { @internal late final cleanups = []; diff --git a/packages/solidart/lib/v3.dart b/packages/solidart/lib/v3.dart index d2ee064c..8e415ec8 100644 --- a/packages/solidart/lib/v3.dart +++ b/packages/solidart/lib/v3.dart @@ -5,7 +5,16 @@ export 'src/v3.dart' Computed, Effect, LazySignal, + ReactiveList, + ReactiveMap, + ReactiveSet, ReadonlySignal, + Resource, + ResourceError, + ResourceLoading, + ResourceReady, + ResourceState, + ResourceStateExtensions, Signal, SolidartConfig, batch, diff --git a/packages/solidart/test/v3_collections_test.dart b/packages/solidart/test/v3_collections_test.dart new file mode 100644 index 00000000..8a834d18 --- /dev/null +++ b/packages/solidart/test/v3_collections_test.dart @@ -0,0 +1,120 @@ +import 'package:solidart/v3.dart'; +import 'package:test/test.dart'; + +void main() { + group('ReactiveList', () { + test('reacts to mutations', () { + final list = ReactiveList([1, 2]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.add(3); + expect(runs, 2); + + list[0] = 1; + expect(runs, 2); + + list[0] = 5; + expect(runs, 3); + + list.remove(99); + expect(runs, 3); + + list.remove(5); + expect(runs, 4); + }); + + test('tracks previous value after read', () { + final list = ReactiveList([1, 2]); + + expect(list.previousValue, isNull); + + list.add(3); + + expect(list.previousValue, [1, 2]); + }); + + test('respects trackPreviousValue false', () { + final list = ReactiveList([1], trackPreviousValue: false); + + list.add(2); + + expect(list.previousValue, isNull); + expect(list.untrackedPreviousValue, isNull); + }); + }); + + group('ReactiveMap', () { + test('reacts to mutations', () { + final map = ReactiveMap({'a': 1}); + var runs = 0; + + Effect(() { + map['a']; + runs++; + }); + + expect(runs, 1); + + map['a'] = 1; + expect(runs, 1); + + map['a'] = 2; + expect(runs, 2); + + map.remove('missing'); + expect(runs, 2); + + map.remove('a'); + expect(runs, 3); + }); + + test('tracks previous value after read', () { + final map = ReactiveMap({'a': 1}); + + map['a'] = 2; + + expect(map.previousValue, {'a': 1}); + }); + }); + + group('ReactiveSet', () { + test('reacts to mutations', () { + final set = ReactiveSet({1}); + var runs = 0; + + Effect(() { + set.contains(1); + runs++; + }); + + expect(runs, 1); + + set.add(1); + expect(runs, 1); + + set.add(2); + expect(runs, 2); + + set.remove(3); + expect(runs, 2); + + set.remove(1); + expect(runs, 3); + }); + + test('tracks previous value after read', () { + final set = ReactiveSet({1}); + + set.add(2); + + expect(set.previousValue, {1}); + }); + }); +} diff --git a/packages/solidart/test/v3_resource_test.dart b/packages/solidart/test/v3_resource_test.dart new file mode 100644 index 00000000..38f8204f --- /dev/null +++ b/packages/solidart/test/v3_resource_test.dart @@ -0,0 +1,312 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:solidart/v3.dart'; +import 'package:test/test.dart'; + +void main() { + group('fetcher resources', () { + test('lazy resource resolves on first read', () async { + var calls = 0; + final resource = Resource(() async { + calls++; + return 1; + }); + + expect(calls, 0); + + final state = resource.state; + expect(state.isLoading, isTrue); + + await Future.delayed(Duration.zero); + + expect(calls, 1); + expect(resource.state.asReady?.value, 1); + }); + + test('refresh marks ready state as refreshing when enabled', () async { + var value = 0; + final resource = Resource( + () async => ++value, + useRefreshing: true, + lazy: false, + ); + + await resource.resolve(); + + expect(resource.state.asReady?.isRefreshing, isFalse); + + final refreshFuture = resource.refresh(); + + expect(resource.state.asReady?.isRefreshing, isTrue); + + await refreshFuture; + + expect(resource.state.asReady?.value, 2); + expect(resource.state.asReady?.isRefreshing, isFalse); + }); + + test('refresh goes to loading when useRefreshing is false', () async { + final completer1 = Completer(); + final completer2 = Completer(); + var calls = 0; + + final resource = Resource( + () { + calls++; + return calls == 1 ? completer1.future : completer2.future; + }, + useRefreshing: false, + lazy: false, + ); + + await Future.delayed(Duration.zero); + completer1.complete(1); + await Future.delayed(Duration.zero); + + expect(resource.state.asReady?.value, 1); + + final refreshFuture = resource.refresh(); + + expect(resource.state.isLoading, isTrue); + + completer2.complete(2); + await refreshFuture; + + expect(resource.state.asReady?.value, 2); + }); + + test('refresh uses latest response when requests overlap', () async { + final completer1 = Completer(); + final completer2 = Completer(); + var calls = 0; + + final resource = Resource( + () { + calls++; + return calls == 1 ? completer1.future : completer2.future; + }, + ); + + resource.state; + await Future.delayed(Duration.zero); + expect(calls, 1); + + final refreshFuture = resource.refresh(); + expect(calls, 2); + + completer2.complete(2); + await refreshFuture; + expect(resource.state.asReady?.value, 2); + + completer1.complete(1); + await Future.delayed(Duration.zero); + + expect(resource.state.asReady?.value, 2); + }); + + test('fetcher error transitions to error then recovers on refresh', () async { + var shouldThrow = true; + + final resource = Resource( + () async { + if (shouldThrow) { + throw StateError('boom'); + } + return 42; + }, + lazy: false, + ); + + await resource.resolve(); + + expect(resource.state.hasError, isTrue); + + shouldThrow = false; + await resource.refresh(); + + expect(resource.state.asReady?.value, 42); + }); + + test('source change triggers a single refresh', () async { + final source = Signal(0); + var calls = 0; + + final resource = Resource( + () async { + calls++; + return source.value; + }, + source: source, + lazy: false, + ); + + await Future.delayed(Duration.zero); + expect(calls, 1); + + source.value = 1; + await Future.delayed(Duration.zero); + + expect(calls, 2); + + await Future.delayed(Duration.zero); + + expect(calls, 2); + }); + + test('debounce groups source-triggered refreshes', () { + fakeAsync((async) { + final source = Signal(0); + var calls = 0; + + final resource = Resource( + () async { + calls++; + return source.value; + }, + source: source, + debounceDelay: const Duration(milliseconds: 50), + lazy: false, + ); + + async.flushMicrotasks(); + + expect(calls, 1); + + source.value = 1; + source.value = 2; + + async.elapse(const Duration(milliseconds: 49)); + async.flushMicrotasks(); + + expect(calls, 1); + + async.elapse(const Duration(milliseconds: 1)); + async.flushMicrotasks(); + + expect(calls, 2); + + resource.dispose(); + }); + }); + + test('previousState updates after read', () async { + var value = 0; + final resource = Resource( + () async => ++value, + lazy: false, + ); + + await resource.resolve(); + resource.state; + + expect(resource.untrackedPreviousState?.isLoading, isTrue); + + await resource.refresh(); + + expect(resource.untrackedPreviousState?.isLoading, isTrue); + + resource.state; + + expect(resource.untrackedPreviousState?.asReady?.value, 1); + }); + + test('dispose cancels debounce and prevents refresh', () { + fakeAsync((async) { + final source = Signal(0); + var calls = 0; + + final resource = Resource( + () async { + calls++; + return source.value; + }, + source: source, + debounceDelay: const Duration(milliseconds: 50), + lazy: false, + ); + + async.flushMicrotasks(); + expect(calls, 1); + + source.value = 1; + resource.dispose(); + + async.elapse(const Duration(milliseconds: 50)); + async.flushMicrotasks(); + + expect(calls, 1); + }); + }); + }); + + group('stream resources', () { + test('refresh cancels and resubscribes', () async { + var listenCount = 0; + var cancelCount = 0; + final controller = StreamController.broadcast( + onListen: () => listenCount++, + onCancel: () => cancelCount++, + ); + + final resource = Resource.stream( + () => controller.stream, + lazy: false, + ); + + await Future.delayed(Duration.zero); + expect(listenCount, 1); + + controller.add(1); + await Future.delayed(Duration.zero); + expect(resource.state.asReady?.value, 1); + + await resource.refresh(); + await Future.delayed(Duration.zero); + expect(cancelCount, 1); + expect(listenCount, 2); + + controller.add(2); + await Future.delayed(Duration.zero); + expect(resource.state.asReady?.value, 2); + + await controller.close(); + resource.dispose(); + }); + + test('stream errors update state', () async { + final controller = StreamController(); + final resource = Resource.stream( + () => controller.stream, + lazy: false, + ); + + await Future.delayed(Duration.zero); + + controller.addError(StateError('boom'), StackTrace.current); + await Future.delayed(Duration.zero); + + expect(resource.state.hasError, isTrue); + + await controller.close(); + resource.dispose(); + }); + + test('dispose stops stream updates', () async { + final controller = StreamController(); + final resource = Resource.stream( + () => controller.stream, + lazy: false, + ); + + await Future.delayed(Duration.zero); + expect(controller.hasListener, isTrue); + + resource.dispose(); + + expect(controller.hasListener, isFalse); + + await controller.close(); + }); + }); +} From 63c52851d80022df4f7ef82d850137152079aa39 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 03:24:39 +0800 Subject: [PATCH 039/121] Add DevTools integration and observer pattern for signals - Add `SolidartObserver` interface and `SolidartConfig.observers` list - Add `devToolsEnabled` config flag and `trackInDevTools` signal option - Send DevTools events for signal creation, updates, and disposal - Support JSON serialization of signal values for DevTools - Add comprehensive observer notification system - Update DevTools extension to support v3 signals alongside v2 --- packages/solidart/lib/src/v3.dart | 195 +++++++++++++++++- packages/solidart/lib/v3.dart | 1 + packages/solidart/test/v3_devtools_test.dart | 87 ++++++++ .../solidart_devtools_extension/lib/main.dart | 58 +++++- 4 files changed, 335 insertions(+), 6 deletions(-) create mode 100644 packages/solidart/test/v3_devtools_test.dart diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index 36737e0f..68b427a3 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'dart:collection'; +import 'dart:convert'; +import 'dart:developer' as dev; import 'dart:math'; import 'package:meta/meta.dart'; @@ -44,6 +46,161 @@ final class SolidartConfig { static bool detachEffects = false; static bool trackPreviousValue = true; static bool useRefreshing = true; + static bool devToolsEnabled = false; + + static final observers = []; +} + +abstract class SolidartObserver { + const SolidartObserver(); + + void didCreateSignal(ReadonlySignal signal); + void didUpdateSignal(ReadonlySignal signal); + void didDisposeSignal(ReadonlySignal signal); +} + +void _notifySignalCreation(ReadonlySignal signal) { + if (signal.trackInDevTools && SolidartConfig.observers.isNotEmpty) { + for (final observer in SolidartConfig.observers) { + observer.didCreateSignal(signal); + } + } + _notifyDevToolsAboutSignal(signal, eventType: _DevToolsEventType.created); +} + +void _notifySignalUpdate(ReadonlySignal signal) { + if (signal.trackInDevTools && SolidartConfig.observers.isNotEmpty) { + for (final observer in SolidartConfig.observers) { + observer.didUpdateSignal(signal); + } + } + _notifyDevToolsAboutSignal(signal, eventType: _DevToolsEventType.updated); +} + +void _notifySignalDisposal(ReadonlySignal signal) { + if (signal.trackInDevTools && SolidartConfig.observers.isNotEmpty) { + for (final observer in SolidartConfig.observers) { + observer.didDisposeSignal(signal); + } + } + _notifyDevToolsAboutSignal(signal, eventType: _DevToolsEventType.disposed); +} + +enum _DevToolsEventType { + created, + updated, + disposed, +} + +dynamic _toJson(Object? obj) { + try { + return jsonEncode(obj); + } catch (_) { + if (obj is List) { + return obj.map(_toJson).toList().toString(); + } + if (obj is Set) { + return obj.map(_toJson).toList().toString(); + } + if (obj is Map) { + return obj + .map((key, value) => MapEntry(_toJson(key), _toJson(value))) + .toString(); + } + return jsonEncode(obj.toString()); + } +} + +void _notifyDevToolsAboutSignal( + ReadonlySignal signal, { + required _DevToolsEventType eventType, +}) { + if (!SolidartConfig.devToolsEnabled || !signal.trackInDevTools) return; + final eventName = 'ext.solidart.v3.signal.${eventType.name}'; + final value = _signalValue(signal); + final previousValue = _signalPreviousValue(signal); + final hasPreviousValue = _hasPreviousValue(signal); + + dev.postEvent(eventName, { + '_id': signal.identifier.value.toString(), + 'name': signal.identifier.name, + 'value': _toJson(value), + 'previousValue': _toJson(previousValue), + 'hasPreviousValue': hasPreviousValue, + 'type': _signalType(signal), + 'valueType': value.runtimeType.toString(), + if (hasPreviousValue) + 'previousValueType': previousValue.runtimeType.toString(), + 'disposed': signal.isDisposed, + 'autoDispose': signal.autoDispose, + 'listenerCount': _listenerCount(signal), + 'lastUpdate': DateTime.now().toIso8601String(), + }); +} + +String _signalType(ReadonlySignal signal) => switch (signal) { + Resource() => 'Resource', + ReactiveList() => 'ReactiveList', + ReactiveMap() => 'ReactiveMap', + ReactiveSet() => 'ReactiveSet', + LazySignal() => 'LazySignal', + Signal() => 'Signal', + Computed() => 'Computed', + _ => 'ReadonlySignal', +}; + +int _listenerCount(system.ReactiveNode node) { + var count = 0; + var link = node.subs; + while (link != null) { + count++; + link = link.nextSub; + } + return count; +} + +bool _hasPreviousValue(ReadonlySignal signal) { + if (!signal.trackPreviousValue) return false; + if (signal is Signal) { + return signal._previousValue is Some; + } + if (signal is Computed) { + return signal._previousValue is Some; + } + return false; +} + +Object? _signalValue(ReadonlySignal signal) { + if (signal is Resource) { + return _resourceValue(signal.untrackedState); + } + if (signal is LazySignal && !signal.isInitialized) { + return null; + } + if (signal is Computed) { + return _computedValue(signal); + } + return signal.untrackedValue; +} + +Object? _signalPreviousValue(ReadonlySignal signal) { + if (signal is Resource) { + return _resourceValue(signal.untrackedPreviousState); + } + return signal.untrackedPreviousValue; +} + +Object? _resourceValue(ResourceState? state) { + if (state == null) return null; + return state.maybeWhen(orElse: () => null, ready: (value) => value); +} + +Object? _computedValue(Computed signal) { + final current = signal.currentValue; + if (current != null || null is T) { + return current; + } + return null; } T untracked(T Function() callback) { @@ -119,6 +276,7 @@ abstract class Disposable { abstract interface class SignalConfiguration implements Configuration { ValueComparator get equals; bool get trackPreviousValue; + bool get trackInDevTools; } // TODO(nank1ro): Maybe rename to `ReadSignal`? medz: I still recommend `ReadonlySignal` because it is semantically clearer., https://github.com/nank1ro/solidart/pull/166#issuecomment-3623175977 @@ -139,12 +297,14 @@ class Signal extends preset.SignalNode> String? name, ValueComparator equals = identical, bool? trackPreviousValue, + bool? trackInDevTools, }) : this._internal( Some(initialValue), autoDispose: autoDispose, name: name, equals: equals, trackPreviousValue: trackPreviousValue, + trackInDevTools: trackInDevTools, ); Signal._internal( @@ -153,21 +313,26 @@ class Signal extends preset.SignalNode> String? name, bool? autoDispose, bool? trackPreviousValue, + bool? trackInDevTools, }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, trackPreviousValue = trackPreviousValue ?? SolidartConfig.trackPreviousValue, + trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, identifier = ._(name), super( flags: system.ReactiveFlags.mutable, currentValue: initialValue, pendingValue: initialValue, - ); + ) { + _notifySignalCreation(this); + } factory Signal.lazy({ String? name, bool? autoDispose, ValueComparator equals, bool? trackPreviousValue, + bool? trackInDevTools, }) = LazySignal; @override @@ -182,6 +347,9 @@ class Signal extends preset.SignalNode> @override final bool trackPreviousValue; + @override + final bool trackInDevTools; + Option _previousValue = const None(); @override @@ -221,6 +389,7 @@ class Signal extends preset.SignalNode> Disposable.unlinkSubs(this); preset.stop(this); super.dispose(); + _notifySignalDisposal(this); } @override @@ -239,6 +408,7 @@ class Signal extends preset.SignalNode> } currentValue = pending; + _notifySignalUpdate(this); return true; } } @@ -249,12 +419,14 @@ class LazySignal extends Signal { bool? autoDispose, ValueComparator equals = identical, bool? trackPreviousValue, + bool? trackInDevTools, }) : super._internal( const None(), name: name, autoDispose: autoDispose, equals: equals, trackPreviousValue: trackPreviousValue, + trackInDevTools: trackInDevTools, ); bool get isInitialized => currentValue is Some; @@ -286,12 +458,14 @@ class ReactiveList extends Signal> with ListMixin { String? name, ValueComparator> equals = identical, bool? trackPreviousValue, + bool? trackInDevTools, }) : super( List.of(initialValue), autoDispose: autoDispose, name: name, equals: equals, trackPreviousValue: trackPreviousValue, + trackInDevTools: trackInDevTools, ); List _copy() => List.of(untrackedValue); @@ -472,12 +646,14 @@ class ReactiveSet extends Signal> with SetMixin { String? name, ValueComparator> equals = identical, bool? trackPreviousValue, + bool? trackInDevTools, }) : super( Set.of(initialValue), autoDispose: autoDispose, name: name, equals: equals, trackPreviousValue: trackPreviousValue, + trackInDevTools: trackInDevTools, ); Set _copy() => Set.of(untrackedValue); @@ -583,12 +759,14 @@ class ReactiveMap extends Signal> with MapMixin { String? name, ValueComparator> equals = identical, bool? trackPreviousValue, + bool? trackInDevTools, }) : super( Map.of(initialValue), autoDispose: autoDispose, name: name, equals: equals, trackPreviousValue: trackPreviousValue, + trackInDevTools: trackInDevTools, ); Map _copy() => Map.of(untrackedValue); @@ -749,11 +927,15 @@ class Computed extends preset.ComputedNode bool? autoDispose, String? name, bool? trackPreviousValue, + bool? trackInDevTools, }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, trackPreviousValue = trackPreviousValue ?? SolidartConfig.trackPreviousValue, + trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, identifier = ._(name), - super(flags: system.ReactiveFlags.none, getter: (_) => getter()); + super(flags: system.ReactiveFlags.none, getter: (_) => getter()) { + _notifySignalCreation(this); + } @override final bool autoDispose; @@ -767,6 +949,9 @@ class Computed extends preset.ComputedNode @override final bool trackPreviousValue; + @override + final bool trackInDevTools; + Option _previousValue = const None(); @override @@ -809,6 +994,7 @@ class Computed extends preset.ComputedNode Disposable.unlinkDeps(this); preset.stop(this); super.dispose(); + _notifySignalDisposal(this); } @override @@ -830,6 +1016,7 @@ class Computed extends preset.ComputedNode } currentValue = pendingValue; + _notifySignalUpdate(this); return true; } finally { preset.activeSub = prevSub; @@ -909,6 +1096,7 @@ class Resource extends Signal> { this.debounceDelay, bool? autoDispose, String? name, + bool? trackInDevTools, ValueComparator> equals = identical, }) : stream = null, useRefreshing = useRefreshing ?? SolidartConfig.useRefreshing, @@ -919,6 +1107,7 @@ class Resource extends Signal> { equals: equals, trackPreviousValue: trackPreviousState ?? SolidartConfig.trackPreviousValue, + trackInDevTools: trackInDevTools, ) { if (!lazy) { _resolveIfNeeded(); @@ -934,6 +1123,7 @@ class Resource extends Signal> { this.debounceDelay, bool? autoDispose, String? name, + bool? trackInDevTools, ValueComparator> equals = identical, }) : fetcher = null, useRefreshing = useRefreshing ?? SolidartConfig.useRefreshing, @@ -944,6 +1134,7 @@ class Resource extends Signal> { equals: equals, trackPreviousValue: trackPreviousState ?? SolidartConfig.trackPreviousValue, + trackInDevTools: trackInDevTools, ) { if (!lazy) { _resolveIfNeeded(); diff --git a/packages/solidart/lib/v3.dart b/packages/solidart/lib/v3.dart index 8e415ec8..7fbed42d 100644 --- a/packages/solidart/lib/v3.dart +++ b/packages/solidart/lib/v3.dart @@ -17,5 +17,6 @@ export 'src/v3.dart' ResourceStateExtensions, Signal, SolidartConfig, + SolidartObserver, batch, untracked; diff --git a/packages/solidart/test/v3_devtools_test.dart b/packages/solidart/test/v3_devtools_test.dart new file mode 100644 index 00000000..195afca4 --- /dev/null +++ b/packages/solidart/test/v3_devtools_test.dart @@ -0,0 +1,87 @@ +import 'package:solidart/v3.dart'; +import 'package:test/test.dart'; + +class _Observer implements SolidartObserver { + int created = 0; + int updated = 0; + int disposed = 0; + + @override + void didCreateSignal(ReadonlySignal signal) { + created++; + } + + @override + void didUpdateSignal(ReadonlySignal signal) { + updated++; + } + + @override + void didDisposeSignal(ReadonlySignal signal) { + disposed++; + } +} + +void main() { + late bool previousDevToolsEnabled; + late List previousObservers; + + setUp(() { + previousDevToolsEnabled = SolidartConfig.devToolsEnabled; + previousObservers = List.of(SolidartConfig.observers); + SolidartConfig.devToolsEnabled = true; + SolidartConfig.observers.clear(); + }); + + tearDown(() { + SolidartConfig.devToolsEnabled = previousDevToolsEnabled; + SolidartConfig.observers + ..clear() + ..addAll(previousObservers); + }); + + test('notifies observers on create/update/dispose', () { + final observer = _Observer(); + SolidartConfig.observers.add(observer); + + final signal = Signal(0); + + expect(observer.created, 1); + expect(observer.updated, 0); + expect(observer.disposed, 0); + + signal.value = 1; + signal.value; + + expect(observer.updated, 1); + + signal.dispose(); + + expect(observer.disposed, 1); + }); + + test('trackInDevTools false disables notifications', () { + final observer = _Observer(); + SolidartConfig.observers.add(observer); + + final signal = Signal(0, trackInDevTools: false); + + signal.value = 1; + signal.value; + signal.dispose(); + + expect(observer.created, 0); + expect(observer.updated, 0); + expect(observer.disposed, 0); + }); + + test('trackInDevTools true overrides global disabled default', () { + SolidartConfig.devToolsEnabled = false; + final observer = _Observer(); + SolidartConfig.observers.add(observer); + + final signal = Signal(0, trackInDevTools: true); + + expect(observer.created, 1); + }); +} diff --git a/packages/solidart_devtools_extension/lib/main.dart b/packages/solidart_devtools_extension/lib/main.dart index 597b52e4..57efba1b 100644 --- a/packages/solidart_devtools_extension/lib/main.dart +++ b/packages/solidart_devtools_extension/lib/main.dart @@ -41,33 +41,46 @@ class Signals extends StatefulWidget { enum SignalType { readSignal, + readonlySignal, signal, + lazySignal, computed, resource, listSignal, + reactiveList, mapSignal, - setSignal; + reactiveMap, + setSignal, + reactiveSet; static SignalType byName(String name) { return switch (name) { 'ReadSignal' => SignalType.readSignal, + 'ReadonlySignal' => SignalType.readonlySignal, 'Signal' => SignalType.signal, + 'LazySignal' => SignalType.lazySignal, 'Computed' => SignalType.computed, 'Resource' => SignalType.resource, 'ListSignal' => SignalType.listSignal, + 'ReactiveList' => SignalType.reactiveList, 'MapSignal' => SignalType.mapSignal, + 'ReactiveMap' => SignalType.reactiveMap, 'SetSignal' => SignalType.setSignal, + 'ReactiveSet' => SignalType.reactiveSet, _ => SignalType.signal, }; } } +enum SignalVersion { v2, v3 } + class SignalData { const SignalData({ required this.value, required this.hasPreviousValue, required this.previousValue, required this.type, + required this.version, required this.disposed, required this.autoDispose, required this.listenerCount, @@ -82,6 +95,7 @@ class SignalData { final bool hasPreviousValue; final Object? previousValue; final SignalType type; + final SignalVersion version; final bool disposed; final bool autoDispose; final int listenerCount; @@ -93,6 +107,7 @@ class SignalData { return value.toString().toLowerCase().contains(search) || previousValue.toString().toLowerCase().contains(search) || valueType.toLowerCase().contains(search) || + version.name.toLowerCase().contains(search) || (previousValueType != null && previousValueType!.toLowerCase().contains(search)); } @@ -134,21 +149,33 @@ class _SignalsState extends State { final vmService = await serviceManager.onServiceAvailable; sub = vmService.onExtensionEvent .where((e) { - return e.extensionKind?.startsWith('ext.solidart.signal') ?? false; + final kind = e.extensionKind; + return kind != null && + (kind.startsWith('ext.solidart.signal') || + kind.startsWith('ext.solidart.v3.signal')); }) .listen((event) { final data = event.extensionData?.data; if (data == null) return; - switch (event.extensionKind) { + final kind = event.extensionKind; + final version = (kind?.startsWith('ext.solidart.v3.signal') ?? false) + ? SignalVersion.v3 + : SignalVersion.v2; + switch (kind) { case 'ext.solidart.signal.created': case 'ext.solidart.signal.updated': case 'ext.solidart.signal.disposed': - signals[data['_id']] = SignalData( + case 'ext.solidart.v3.signal.created': + case 'ext.solidart.v3.signal.updated': + case 'ext.solidart.v3.signal.disposed': + final signalId = '${version.name}:${data['_id']}'; + signals[signalId] = SignalData( name: data['name'] ?? data['_id'], value: jsonDecode(data['value'] ?? 'null'), hasPreviousValue: data['hasPreviousValue'], previousValue: jsonDecode(data['previousValue'] ?? 'null'), type: SignalType.byName(data['type']), + version: version, disposed: data['disposed'], autoDispose: data['autoDispose'], listenerCount: data['listenerCount'], @@ -394,6 +421,25 @@ class _SignalsState extends State { entry.key; }, ), + ShadBadge( + child: Text( + signal.version.name + .toUpperCase(), + style: shadTheme + .textTheme + .small + .copyWith( + fontSize: 10, + color: shadTheme + .primaryBadgeTheme + .foregroundColor, + ), + ), + onPressed: () { + selectedSignalId.value = + entry.key; + }, + ), ShadBadge( child: Text( DateFormat( @@ -497,6 +543,10 @@ class _SignalsState extends State { name: 'type', value: signal.type.name.capitalizeFirst(), ), + ParameterView( + name: 'version', + value: signal.version.name, + ), ParameterView( name: 'value', value: signal.value, From 7417d0083545f4baadbb5d0ad2996f7761d7b82b Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 03:41:10 +0800 Subject: [PATCH 040/121] Fix LazySignal value check and add no-op mutation tests - LazySignal now returns value when pendingValue is Some - Add tests for no-op mutations in ReactiveList, ReactiveMap, ReactiveSet - Add tests for LazySignal previous value tracking - Add tests for Resource concurrency and state extensions --- packages/solidart/lib/src/v3.dart | 4 +- .../solidart/test/v3_collections_test.dart | 120 ++++++++++++ .../solidart/test/v3_previous_value_test.dart | 24 +++ packages/solidart/test/v3_resource_test.dart | 183 ++++++++++++++++++ 4 files changed, 330 insertions(+), 1 deletion(-) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index 68b427a3..a18b94c9 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -433,7 +433,9 @@ class LazySignal extends Signal { @override T get value { - if (isInitialized) return super.value; + if (isInitialized || pendingValue is Some) { + return super.value; + } throw StateError( 'LazySignal is not initialized, Please call `.value = ` first.', ); diff --git a/packages/solidart/test/v3_collections_test.dart b/packages/solidart/test/v3_collections_test.dart index 8a834d18..71d665c0 100644 --- a/packages/solidart/test/v3_collections_test.dart +++ b/packages/solidart/test/v3_collections_test.dart @@ -48,6 +48,49 @@ void main() { expect(list.previousValue, isNull); expect(list.untrackedPreviousValue, isNull); }); + + test('no-op mutations do not notify', () { + final list = ReactiveList([1, 2, 3]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.addAll([]); + list.insertAll(1, []); + list.replaceRange(0, 0, []); + list.setAll(0, [1, 2, 3]); + list.setRange(0, 3, [1, 2, 3]); + list.fillRange(1, 1); + list.removeWhere((_) => false); + list.retainWhere((_) => true); + list.sort(); + + expect(runs, 1); + }); + + test('empty list no-op mutations do not notify', () { + final list = ReactiveList([]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.clear(); + list.removeWhere((_) => true); + list.sort(); + list.shuffle(); + + expect(runs, 1); + }); }); group('ReactiveMap', () { @@ -82,6 +125,44 @@ void main() { expect(map.previousValue, {'a': 1}); }); + + test('no-op mutations do not notify', () { + final map = ReactiveMap({'a': 1, 'b': 2}); + var runs = 0; + + Effect(() { + map.length; + runs++; + }); + + expect(runs, 1); + + map.addAll({}); + map.updateAll((key, value) => value); + map.removeWhere((_, __) => false); + map.putIfAbsent('a', () => 99); + + expect(runs, 1); + }); + + test('empty map no-op mutations do not notify', () { + final map = ReactiveMap({}); + var runs = 0; + + Effect(() { + map.length; + runs++; + }); + + expect(runs, 1); + + map.clear(); + map.addAll({}); + map.removeWhere((_, __) => true); + map.updateAll((_, value) => value); + + expect(runs, 1); + }); }); group('ReactiveSet', () { @@ -116,5 +197,44 @@ void main() { expect(set.previousValue, {1}); }); + + test('no-op mutations do not notify', () { + final set = ReactiveSet({1, 2}); + var runs = 0; + + Effect(() { + set.length; + runs++; + }); + + expect(runs, 1); + + set.addAll([]); + set.removeAll([]); + set.retainAll({1, 2}); + set.removeWhere((_) => false); + set.retainWhere((_) => true); + + expect(runs, 1); + }); + + test('empty set no-op mutations do not notify', () { + final set = ReactiveSet({}); + var runs = 0; + + Effect(() { + set.length; + runs++; + }); + + expect(runs, 1); + + set.clear(); + set.addAll([]); + set.removeAll([]); + set.retainAll({}); + + expect(runs, 1); + }); }); } diff --git a/packages/solidart/test/v3_previous_value_test.dart b/packages/solidart/test/v3_previous_value_test.dart index ed0d238c..c87796ed 100644 --- a/packages/solidart/test/v3_previous_value_test.dart +++ b/packages/solidart/test/v3_previous_value_test.dart @@ -88,4 +88,28 @@ void main() { expect(computed.untrackedPreviousValue, isNull); }); }); + + group('LazySignal previous value', () { + test('throws when read before initialization', () { + final lazy = LazySignal(); + expect(() => lazy.value, throwsStateError); + }); + + test('tracks previous only after initialized and read', () { + final lazy = LazySignal(); + + lazy.value = 1; + + expect(lazy.previousValue, isNull); + expect(lazy.isInitialized, isTrue); + + lazy.value = 2; + + expect(lazy.untrackedPreviousValue, isNull); + + lazy.value; + + expect(lazy.untrackedPreviousValue, 1); + }); + }); } diff --git a/packages/solidart/test/v3_resource_test.dart b/packages/solidart/test/v3_resource_test.dart index 38f8204f..ab22a4fd 100644 --- a/packages/solidart/test/v3_resource_test.dart +++ b/packages/solidart/test/v3_resource_test.dart @@ -6,6 +6,44 @@ import 'package:test/test.dart'; void main() { group('fetcher resources', () { + test('coalesces concurrent resolve calls', () async { + final completer = Completer(); + var calls = 0; + final resource = Resource(() { + calls++; + return completer.future; + }); + + final first = resource.resolve(); + final second = resource.resolve(); + + expect(calls, 1); + + completer.complete(10); + await Future.wait([first, second]); + + expect(resource.state.asReady?.value, 10); + expect(calls, 1); + }); + + test('refresh before resolve triggers a single fetch', () async { + final completer = Completer(); + var calls = 0; + final resource = Resource(() { + calls++; + return completer.future; + }); + + final refreshFuture = resource.refresh(); + expect(calls, 1); + + completer.complete(5); + await refreshFuture; + + expect(resource.state.asReady?.value, 5); + expect(calls, 1); + }); + test('lazy resource resolves on first read', () async { var calls = 0; final resource = Resource(() async { @@ -238,6 +276,26 @@ void main() { expect(calls, 1); }); }); + + test('dispose ignores in-flight fetch result', () async { + final completer = Completer(); + var calls = 0; + final resource = Resource(() { + calls++; + return completer.future; + }); + + final resolveFuture = resource.resolve(); + expect(calls, 1); + + resource.dispose(); + + completer.complete(42); + await resolveFuture; + await Future.delayed(Duration.zero); + + expect(resource.untrackedState.isLoading, isTrue); + }); }); group('stream resources', () { @@ -274,6 +332,38 @@ void main() { resource.dispose(); }); + test('refresh ignores events from previous stream', () async { + final controller1 = StreamController(); + final controller2 = StreamController(); + var index = 0; + final streams = [controller1.stream, controller2.stream]; + + final resource = Resource.stream( + () => streams[index++], + lazy: false, + ); + + await Future.delayed(Duration.zero); + controller1.add(1); + await Future.delayed(Duration.zero); + expect(resource.state.asReady?.value, 1); + + await resource.refresh(); + await Future.delayed(Duration.zero); + + controller1.add(99); + await Future.delayed(Duration.zero); + expect(resource.state.asReady?.value, 1); + + controller2.add(2); + await Future.delayed(Duration.zero); + expect(resource.state.asReady?.value, 2); + + await controller1.close(); + await controller2.close(); + resource.dispose(); + }); + test('stream errors update state', () async { final controller = StreamController(); final resource = Resource.stream( @@ -309,4 +399,97 @@ void main() { await controller.close(); }); }); + + group('resource state extensions', () { + test('flags and accessors for ready/loading/error', () { + final ready = ResourceState.ready(1, isRefreshing: true); + final loading = ResourceState.loading(); + final error = ResourceState.error( + StateError('boom'), + stackTrace: StackTrace.current, + isRefreshing: true, + ); + + expect(ready.isReady, isTrue); + expect(ready.isLoading, isFalse); + expect(ready.hasError, isFalse); + expect(ready.isRefreshing, isTrue); + expect(ready.asReady?.value, 1); + expect(ready.asError, isNull); + expect(ready.value, 1); + expect(ready.error, isNull); + + expect(loading.isLoading, isTrue); + expect(loading.isReady, isFalse); + expect(loading.hasError, isFalse); + expect(loading.isRefreshing, isFalse); + expect(loading.asReady, isNull); + expect(loading.asError, isNull); + expect(loading.value, isNull); + expect(loading.error, isNull); + + expect(error.hasError, isTrue); + expect(error.isReady, isFalse); + expect(error.isLoading, isFalse); + expect(error.isRefreshing, isTrue); + expect(error.asReady, isNull); + expect(error.asError?.error, isA()); + expect(error.error, isA()); + expect(() => error.value, throwsA(isA())); + }); + + test('when/maybeWhen/maybeMap behave as expected', () { + final ready = ResourceState.ready(2); + final error = ResourceState.error(StateError('boom')); + final loading = ResourceState.loading(); + + expect( + ready.when( + ready: (value) => 'ready $value', + error: (_, __) => 'error', + loading: () => 'loading', + ), + 'ready 2', + ); + + expect( + error.when( + ready: (_) => 'ready', + error: (err, __) => err.toString(), + loading: () => 'loading', + ), + 'Bad state: boom', + ); + + expect( + loading.when( + ready: (_) => 'ready', + error: (_, __) => 'error', + loading: () => 'loading', + ), + 'loading', + ); + + expect( + ready.maybeWhen(orElse: () => 'fallback'), + 'fallback', + ); + + expect( + error.maybeWhen( + orElse: () => 'fallback', + error: (err, __) => err.runtimeType.toString(), + ), + 'StateError', + ); + + expect( + loading.maybeMap( + orElse: () => 'fallback', + loading: (_) => 'loading', + ), + 'loading', + ); + }); + }); } From 28b3735e23e8a1bc02adee19ab4c28bc707706a1 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 04:10:54 +0800 Subject: [PATCH 041/121] Add documentation for v3 reactive primitives --- packages/solidart/lib/src/v3.dart | 517 ++++++++++++++++++++++++++++-- 1 file changed, 498 insertions(+), 19 deletions(-) diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/v3.dart index a18b94c9..78e0019f 100644 --- a/packages/solidart/lib/src/v3.dart +++ b/packages/solidart/lib/src/v3.dart @@ -1,6 +1,3 @@ -// ignore_for_file: public_member_api_docs -// TODO(medz): Add code comments - import 'dart:async'; import 'dart:collection'; import 'dart:convert'; @@ -11,51 +8,130 @@ import 'package:meta/meta.dart'; import 'package:solidart/deps/preset.dart' as preset; import 'package:solidart/deps/system.dart' as system; +/// Compares two values for equality. +/// +/// Return `true` when the update should be skipped because values are +/// considered equivalent. typedef ValueComparator = bool Function(T? a, T? b); + +/// Lazily produces a value. typedef ValueGetter = T Function(); + +/// A callback that returns no value. typedef VoidCallback = ValueGetter; +/// An optional value container. +/// +/// Use [Some] to represent presence and [None] to represent absence without +/// relying on `null`. sealed class Option { + /// Base constructor for option values. const Option(); + /// Returns the contained value or throws if this is [None]. T unwrap() => switch (this) { Some(:final value) => value, _ => throw StateError('Option is None'), }; + /// Returns the contained value or `null` if this is [None]. T? safeUnwrap() => switch (this) { Some(:final value) => value, _ => null, }; } +/// A present optional value. final class Some extends Option { + /// Creates an option that wraps [value]. const Some(this.value); + /// The wrapped value. final T value; } +/// An absent optional value. final class None extends Option { + /// Creates an option with no value. const None(); } +/// {@template v3-config} +/// Global configuration for v3 reactive primitives. +/// +/// These flags provide defaults for newly created signals/effects/resources. +/// You can override them per-instance via constructor parameters. +/// {@endtemplate} final class SolidartConfig { const SolidartConfig._(); + /// Whether nodes auto-dispose when they lose all subscribers. + /// + /// When enabled, signals/computeds/effects may dispose themselves once + /// nothing depends on them. static bool autoDispose = false; + + /// Whether nested effects detach from parent subscriptions. + /// + /// When `true`, inner effects do not become dependencies of their parent + /// effect unless explicitly linked. static bool detachEffects = false; + + /// Whether to track previous values by default. + /// + /// Previous values are captured only after a signal has been read at least + /// once. static bool trackPreviousValue = true; + + /// Whether to keep values while refreshing resources. + /// + /// When `true`, a refresh marks the state as `isRefreshing` instead of + /// replacing it with `loading`. static bool useRefreshing = true; + + /// Whether DevTools tracking is enabled. + /// + /// Signals only emit DevTools events when both this flag and + /// `trackInDevTools` are `true`. static bool devToolsEnabled = false; + /// Registered observers for signal lifecycle events. + /// + /// Observers are notified only when `trackInDevTools` is enabled for the + /// signal instance. static final observers = []; } +/// {@template v3-observer} +/// Observer for signal lifecycle events. +/// +/// Use this for logging or instrumentation without depending on DevTools: +/// ```dart +/// class Logger extends SolidartObserver { +/// @override +/// void didCreateSignal(ReadonlySignal signal) { +/// print('created: ${signal.identifier.value}'); +/// } +/// @override +/// void didUpdateSignal(ReadonlySignal signal) {} +/// @override +/// void didDisposeSignal(ReadonlySignal signal) {} +/// } +/// +/// SolidartConfig.observers.add(Logger()); +/// ``` +/// {@endtemplate} abstract class SolidartObserver { + /// {@macro v3-observer} const SolidartObserver(); + /// Called when a signal is created. void didCreateSignal(ReadonlySignal signal); + + /// Called when a signal updates. void didUpdateSignal(ReadonlySignal signal); + + /// Called when a signal is disposed. void didDisposeSignal(ReadonlySignal signal); } @@ -203,6 +279,18 @@ Object? _computedValue(Computed signal) { return null; } +/// Runs [callback] without tracking dependencies. +/// +/// This is useful when you want to read or write signals inside an effect +/// without establishing a dependency. +/// +/// ```dart +/// final count = Signal(0); +/// Effect(() { +/// print(count.value); +/// untracked(() => count.value = count.value + 1); +/// }); +/// ``` T untracked(T Function() callback) { final prevSub = preset.setActiveSub(); try { @@ -212,6 +300,21 @@ T untracked(T Function() callback) { } } +/// Batches signal updates and flushes once at the end. +/// +/// Nested batches are supported; the final flush happens when the outermost +/// batch completes. +/// +/// ```dart +/// final a = Signal(1); +/// final b = Signal(2); +/// Effect(() => print('sum: ${a.value + b.value}')); +/// +/// batch(() { +/// a.value = 3; +/// b.value = 4; +/// }); +/// ``` T batch(T Function() fn) { preset.startBatch(); try { @@ -221,31 +324,50 @@ T batch(T Function() fn) { } } +/// A unique identifier with an optional name. +/// +/// Used by DevTools and diagnostics to track instances. class Identifier { Identifier._(this.name) : value = _counter++; static int _counter = 0; + /// Optional human-readable name. final String? name; + + /// Unique numeric identifier. final int value; } +/// Base configuration shared by reactive primitives. abstract interface class Configuration { + /// Identifier for the instance. Identifier get identifier; + + /// Whether the instance auto-disposes. bool get autoDispose; } +/// Disposable behavior for reactive primitives. abstract class Disposable { + /// Whether this instance has been disposed. bool get isDisposed; + /// Registers a callback to run on dispose. void onDispose(VoidCallback callback); + + /// Disposes the instance. void dispose(); + /// Whether the node can be auto-disposed. static bool canAutoDispose(system.ReactiveNode node) => switch (node) { Disposable(:final isDisposed) && Configuration(:final autoDispose) => !isDisposed && autoDispose, _ => false, }; + /// Unlinks dependencies from a node. + /// + /// This is used to break reactive links during disposal. static void unlinkDeps(system.ReactiveNode node) { var link = node.deps; while (link != null) { @@ -259,6 +381,9 @@ abstract class Disposable { } } + /// Unlinks subscribers from a node. + /// + /// This is used to break reactive links during disposal. static void unlinkSubs(system.ReactiveNode node) { var link = node.subs; while (link != null) { @@ -273,24 +398,98 @@ abstract class Disposable { } } +/// Common configuration for signals. abstract interface class SignalConfiguration implements Configuration { + /// Comparator used to skip equal updates. + /// + /// When it returns `true`, the new value is treated as equal and the update + /// is skipped. ValueComparator get equals; + + /// Whether to track previous values. + /// + /// Previous values are captured on successful updates after a tracked read. bool get trackPreviousValue; + + /// Whether to report to DevTools. bool get trackInDevTools; } +/// Read-only reactive value. +/// +/// Reading [value] establishes a dependency; [untrackedValue] does not. +/// This interface is implemented by [Signal], [Computed], and [Resource]. +/// +/// ```dart +/// final count = Signal(0); +/// ReadonlySignal readonly = count.toReadonly(); +/// ``` // TODO(nank1ro): Maybe rename to `ReadSignal`? medz: I still recommend `ReadonlySignal` because it is semantically clearer., https://github.com/nank1ro/solidart/pull/166#issuecomment-3623175977 abstract interface class ReadonlySignal implements system.ReactiveNode, Disposable, SignalConfiguration { + /// Returns the current value and tracks dependencies. T get value; + + /// Returns the current value without tracking. T get untrackedValue; + + /// Returns the previous value (tracked read). + /// + /// This may return `null` if tracking is disabled or the signal has not been + /// read since the last update. T? get previousValue; + + /// Returns the previous value without tracking. T? get untrackedPreviousValue; } +/// {@template v3-signal} +/// # Signals +/// Signals are the cornerstone of reactivity in v3. They store values that +/// change over time, and any reactive computation that reads a signal will +/// automatically update when the signal changes. +/// +/// Create a signal with an initial value: +/// ```dart +/// final counter = Signal(0); +/// ``` +/// +/// Read the current value: +/// ```dart +/// counter.value; // 0 +/// ``` +/// +/// Update the value: +/// ```dart +/// counter.value++; +/// // or +/// counter.value = 10; +/// ``` +/// +/// Signals support previous value tracking. When enabled, `previousValue` +/// updates only after the signal has been read at least once: +/// ```dart +/// final count = Signal(0); +/// count.value = 1; +/// count.previousValue; // null (not read yet) +/// count.value; // establishes tracking +/// count.previousValue; // 0 +/// ``` +/// +/// Signals can be created lazily using [Signal.lazy]. A lazy signal does not +/// have a value until it is first assigned, and reading it early throws +/// [StateError]. +/// {@endtemplate} +/// {@template v3-signal-equals} +/// Updates are skipped when [equals] reports the new value is equivalent to +/// the previous one. +/// {@endtemplate} class Signal extends preset.SignalNode> with DisposableMixin implements ReadonlySignal { + /// {@macro v3-signal} + /// + /// {@macro v3-signal-equals} Signal( T initialValue, { bool? autoDispose, @@ -327,6 +526,10 @@ class Signal extends preset.SignalNode> _notifySignalCreation(this); } + /// {@macro v3-signal} + /// + /// This is a lazy signal: it has no value at construction time. + /// Reading [value] before the first assignment throws [StateError]. factory Signal.lazy({ String? name, bool? autoDispose, @@ -358,6 +561,9 @@ class Signal extends preset.SignalNode> return super.get().unwrap(); } + /// Sets the current value. + /// + /// {@macro v3-signal-equals} set value(T newValue) { assert(!isDisposed, 'Signal is disposed'); set(Some(newValue)); @@ -381,6 +587,7 @@ class Signal extends preset.SignalNode> // TODO(nank1ro): See ReadonlySignal TODO, If `ReadonlySignal` rename // to `ReadSignal`, the `.toReadonly` method should be rename? + /// Returns a read-only view of this signal. ReadonlySignal toReadonly() => this; @override @@ -413,7 +620,18 @@ class Signal extends preset.SignalNode> } } +/// A signal that starts uninitialized until first set. +/// +/// This is the concrete type behind [Signal.lazy]. Reading [value] before the +/// first assignment throws [StateError]. +/// +/// ```dart +/// final lazy = Signal.lazy(); +/// lazy.value = 1; +/// print(lazy.value); // 1 +/// ``` class LazySignal extends Signal { + /// Creates a lazy signal. LazySignal({ String? name, bool? autoDispose, @@ -429,6 +647,9 @@ class LazySignal extends Signal { trackInDevTools: trackInDevTools, ); + /// Whether the signal has been initialized. + /// + /// This becomes `true` after the first assignment. bool get isInitialized => currentValue is Some; @override @@ -453,7 +674,23 @@ class LazySignal extends Signal { } } +/// {@template v3-reactive-list} +/// A reactive wrapper around a [List] that copies on write. +/// +/// Mutations create a new list instance so that updates are observable: +/// ```dart +/// final list = ReactiveList([1, 2]); +/// Effect(() => print(list.length)); +/// list.add(3); // triggers effect +/// ``` +/// +/// Reads (like `length` or index access) establish dependencies; the usual +/// list API is supported. +/// {@endtemplate} class ReactiveList extends Signal> with ListMixin { + /// {@macro v3-reactive-list} + /// + /// Creates a reactive list with the provided initial values. ReactiveList( Iterable initialValue, { bool? autoDispose, @@ -488,9 +725,7 @@ class ReactiveList extends Signal> with ListMixin { set length(int newLength) { final current = untrackedValue; if (current.length == newLength) return; - final next = List.of(current); - next.length = newLength; - value = next; + value = List.of(current)..length = newLength; } @override @@ -567,8 +802,8 @@ class ReactiveList extends Signal> with ListMixin { } @override - void replaceRange(int start, int end, Iterable replacements) { - final next = _copy()..replaceRange(start, end, replacements); + void replaceRange(int start, int end, Iterable newContents) { + final next = _copy()..replaceRange(start, end, newContents); if (_listEquals(untrackedValue, next)) return; value = next; } @@ -588,9 +823,9 @@ class ReactiveList extends Signal> with ListMixin { } @override - void fillRange(int start, int end, [E? fillValue]) { + void fillRange(int start, int end, [E? fill]) { if (end <= start) return; - final next = _copy()..fillRange(start, end, fillValue); + final next = _copy()..fillRange(start, end, fill); if (_listEquals(untrackedValue, next)) return; value = next; } @@ -638,10 +873,26 @@ class ReactiveList extends Signal> with ListMixin { @override String toString() => - 'ReactiveList<$E>(value: ${untrackedValue}, previousValue: ${untrackedPreviousValue})'; + 'ReactiveList<$E>(value: $untrackedValue, ' + 'previousValue: $untrackedPreviousValue)'; } +/// {@template v3-reactive-set} +/// A reactive wrapper around a [Set] that copies on write. +/// +/// Mutations create a new set instance so that updates are observable: +/// ```dart +/// final set = ReactiveSet({1}); +/// Effect(() => print(set.length)); +/// set.add(2); // triggers effect +/// ``` +/// +/// Reads (like `length` or `contains`) establish dependencies. +/// {@endtemplate} class ReactiveSet extends Signal> with SetMixin { + /// {@macro v3-reactive-set} + /// + /// Creates a reactive set with the provided initial values. ReactiveSet( Iterable initialValue, { bool? autoDispose, @@ -751,10 +1002,26 @@ class ReactiveSet extends Signal> with SetMixin { @override String toString() => - 'ReactiveSet<$E>(value: ${untrackedValue}, previousValue: ${untrackedPreviousValue})'; + 'ReactiveSet<$E>(value: $untrackedValue, ' + 'previousValue: $untrackedPreviousValue)'; } +/// {@template v3-reactive-map} +/// A reactive wrapper around a [Map] that copies on write. +/// +/// Mutations create a new map instance so that updates are observable: +/// ```dart +/// final map = ReactiveMap({'a': 1}); +/// Effect(() => print(map['a'])); +/// map['a'] = 2; // triggers effect +/// ``` +/// +/// Reads (like `[]`, `keys`, or `length`) establish dependencies. +/// {@endtemplate} class ReactiveMap extends Signal> with MapMixin { + /// {@macro v3-reactive-map} + /// + /// Creates a reactive map with the provided initial values. ReactiveMap( Map initialValue, { bool? autoDispose, @@ -836,9 +1103,9 @@ class ReactiveMap extends Signal> with MapMixin { } @override - bool containsValue(Object? candidate) { + bool containsValue(Object? value) { this.value; - return untrackedValue.containsValue(candidate); + return untrackedValue.containsValue(value); } @override @@ -891,8 +1158,7 @@ class ReactiveMap extends Signal> with MapMixin { void updateAll(V Function(K key, V value) update) { final current = untrackedValue; if (current.isEmpty) return; - final next = Map.of(current); - next.updateAll(update); + final next = Map.of(current)..updateAll(update); if (next.length == current.length && next.keys.every((key) { return current.containsKey(key) && current[key] == next[key]; @@ -917,12 +1183,31 @@ class ReactiveMap extends Signal> with MapMixin { @override String toString() => - 'ReactiveMap<$K, $V>(value: ${untrackedValue}, previousValue: ${untrackedPreviousValue})'; + 'ReactiveMap<$K, $V>(value: $untrackedValue, ' + 'previousValue: $untrackedPreviousValue)'; } +/// {@template v3-computed} +/// # Computed +/// A computed signal derives its value from other signals. It is read-only +/// and recalculates whenever any dependency changes. +/// +/// Use `Computed` to derive state or combine multiple signals: +/// ```dart +/// final firstName = Signal('Josh'); +/// final lastName = Signal('Brown'); +/// final fullName = Computed(() => '${firstName.value} ${lastName.value}'); +/// ``` +/// +/// Computeds only notify when the derived value changes. You can customize +/// equality via [equals] to skip updates for equivalent values. +/// +/// Like signals, computeds can track previous values once they have been read. +/// {@endtemplate} class Computed extends preset.ComputedNode with DisposableMixin implements ReadonlySignal { + /// {@macro v3-computed} Computed( ValueGetter getter, { this.equals = identical, @@ -1028,9 +1313,29 @@ class Computed extends preset.ComputedNode } } +/// {@template v3-effect} +/// # Effect +/// Effects run a side-effect whenever any signal they read changes. +/// +/// ```dart +/// final counter = Signal(0); +/// Effect(() { +/// print('count: ${counter.value}'); +/// }); +/// ``` +/// +/// Effects run once immediately when created. If you need a lazy effect, +/// create it with [Effect.manual] and call [run] yourself. +/// +/// Nested effects can either attach to their parent (default) or detach by +/// passing `detach: true` or by enabling [SolidartConfig.detachEffects]. +/// +/// Call [dispose] to stop the effect and release dependencies. +/// {@endtemplate} class Effect extends preset.EffectNode with DisposableMixin implements Disposable, Configuration { + /// {@macro v3-effect} factory Effect( VoidCallback callback, { bool? autoDispose, @@ -1043,6 +1348,26 @@ class Effect extends preset.EffectNode detach: detach, )..run(); + /// Creates an effect without running it. + /// + /// Use this when you need to *delay* the first run or decide *when* the + /// effect should start tracking dependencies. Common cases: + /// - you must create several signals first and only then start the effect + /// - you want to control the first run in tests + /// - you need conditional startup (e.g. after async setup) + /// + /// The effect will not track anything until you call [run]: + /// ```dart + /// final count = Signal(0); + /// final effect = Effect.manual(() { + /// print('count: ${count.value}'); + /// }); + /// + /// count.value = 1; // no output yet + /// effect.run(); // prints "count: 1" and starts tracking + /// ``` + /// + /// If you want the effect to run immediately, use the [Effect] factory. Effect.manual( VoidCallback callback, { bool? autoDispose, @@ -1063,8 +1388,10 @@ class Effect extends preset.EffectNode @override final Identifier identifier; + /// Whether this effect detaches from parent subscriptions. final bool detach; + /// Runs the effect and tracks dependencies. void run() { final prevSub = preset.setActiveSub(this); if (!detach && prevSub != null) { @@ -1088,7 +1415,40 @@ class Effect extends preset.EffectNode } } +/// {@template v3-resource} +/// # Resource +/// A resource is a signal designed for async data. It wraps the common states +/// of asynchronous work: `ready`, `loading`, and `error`. +/// +/// Resources can be driven by: +/// - a `fetcher` that returns a `Future` +/// - a `stream` that yields values over time +/// - an optional `source` signal that triggers refreshes +/// +/// Example using a fetcher: +/// ```dart +/// final userId = Signal(1); +/// +/// Future fetchUser() async { +/// final id = userId.value; +/// return 'user:$id'; +/// } +/// +/// final user = Resource(fetchUser, source: userId); +/// ``` +/// +/// The current state is available via [state] and provides helpers like +/// `when`, `maybeWhen`, `asReady`, `asError`, `isLoading`, and `isRefreshing`. +/// +/// The [resolve] method starts the resource once. The [refresh] method forces +/// a new fetch or re-subscribes to the stream. When [useRefreshing] is true, +/// refresh updates the current state with `isRefreshing` instead of resetting +/// to `loading`. +/// {@endtemplate} class Resource extends Signal> { + /// {@macro v3-resource} + /// + /// Creates a resource backed by a future-producing [fetcher]. Resource( this.fetcher, { this.source, @@ -1116,6 +1476,23 @@ class Resource extends Signal> { } } + /// {@macro v3-resource} + /// + /// Creates a resource backed by a stream factory. + /// + /// Use this when your data source is an ongoing stream (e.g. sockets, + /// Firestore snapshots, or SSE). The stream is subscribed on resolve and + /// re-subscribed when [refresh] is called or when [source] changes. + /// + /// ```dart + /// final ticks = Resource.stream( + /// () => Stream.periodic(const Duration(seconds: 1), (i) => i), + /// lazy: false, + /// ); + /// ``` + /// + /// When a refresh happens, the previous subscription is cancelled and + /// events from older subscriptions are ignored. Resource.stream( this.stream, { this.source, @@ -1143,11 +1520,31 @@ class Resource extends Signal> { } } + /// Optional source signal that triggers refreshes when it changes. + /// + /// When [source] updates, the resource refreshes. If [debounceDelay] is set, + /// multiple source changes are coalesced. final ReadonlySignal? source; + + /// Fetches the resource value. final Future Function()? fetcher; + + /// Provides a stream of resource values. final Stream Function()? stream; + + /// Whether the resource is resolved lazily. + /// + /// When `true`, the resource resolves on first read or when [resolve] is + /// called explicitly. final bool lazy; + + /// Whether to keep previous value while refreshing. + /// + /// When `true`, refresh updates the current state with `isRefreshing` rather + /// than replacing it with `loading`. final bool useRefreshing; + + /// Optional debounce duration for source-triggered refreshes. final Duration? debounceDelay; bool _resolved = false; @@ -1157,23 +1554,33 @@ class Resource extends Signal> { StreamSubscription? _streamSubscription; Timer? _debounceTimer; + /// Returns the current state, resolving lazily if needed. ResourceState get state { _resolveIfNeeded(); return value; } + /// Sets the current state. set state(ResourceState next) => value = next; + /// Returns the previous state (tracked read), or `null`. + /// + /// Previous state is available only after a tracked read. ResourceState? get previousState { _resolveIfNeeded(); if (!_resolved) return null; return previousValue; } + /// Returns the current state without tracking. ResourceState get untrackedState => untrackedValue; + /// Returns the previous state without tracking. ResourceState? get untrackedPreviousState => untrackedPreviousValue; + /// Resolves the resource if it has not been resolved yet. + /// + /// Multiple calls are coalesced into a single in-flight resolve. Future resolve() async { if (isDisposed) return; if (_resolveFuture != null) return _resolveFuture!; @@ -1187,6 +1594,10 @@ class Resource extends Signal> { return _resolveFuture!; } + /// Re-fetches or re-subscribes to the resource. + /// + /// If the resource has not been resolved yet, this triggers [resolve] + /// instead. Future refresh() async { if (!_resolved) { await resolve(); @@ -1320,20 +1731,51 @@ class Resource extends Signal> { } } +/// {@template v3-resource-state} +/// Represents the state of a [Resource]. +/// +/// A resource is always in one of: +/// - `ready(data)` when a value is available +/// - `loading()` while work is in progress +/// - `error(error)` when a failure occurs +/// +/// Use [ResourceStateExtensions] helpers to map or pattern-match: +/// ```dart +/// final state = resource.state; +/// final label = state.when( +/// ready: (data) => 'ready: $data', +/// error: (err, _) => 'error: $err', +/// loading: () => 'loading', +/// ); +/// ``` +/// {@endtemplate} @sealed @immutable sealed class ResourceState { + /// Base constructor for resource states. + const ResourceState(); + + /// {@macro v3-resource-state} + /// + /// Creates a ready state with [data]. const factory ResourceState.ready(T data, {bool isRefreshing}) = ResourceReady; + + /// {@macro v3-resource-state} + /// + /// Creates a loading state. const factory ResourceState.loading() = ResourceLoading; + + /// {@macro v3-resource-state} + /// + /// Creates an error state. const factory ResourceState.error( Object error, { StackTrace? stackTrace, bool isRefreshing, }) = ResourceError; - const ResourceState(); - + /// Maps each concrete state to a value. R map({ required R Function(ResourceReady ready) ready, required R Function(ResourceError error) error, @@ -1341,11 +1783,16 @@ sealed class ResourceState { }); } +/// Ready state containing data. @immutable class ResourceReady implements ResourceState { + /// Creates a ready state with [value]. const ResourceReady(this.value, {this.isRefreshing = false}); + /// The resource value. final T value; + + /// Whether the resource is refreshing. final bool isRefreshing; @override @@ -1357,6 +1804,7 @@ class ResourceReady implements ResourceState { return ready(this); } + /// Returns a copy with updated fields. ResourceReady copyWith({ T? value, bool? isRefreshing, @@ -1384,8 +1832,10 @@ class ResourceReady implements ResourceState { int get hashCode => Object.hash(runtimeType, value, isRefreshing); } +/// Loading state. @immutable class ResourceLoading implements ResourceState { + /// Creates a loading state. const ResourceLoading(); @override @@ -1407,16 +1857,23 @@ class ResourceLoading implements ResourceState { int get hashCode => runtimeType.hashCode; } +/// Error state containing an error and optional stack trace. @immutable class ResourceError implements ResourceState { + /// Creates an error state. const ResourceError( this.error, { this.stackTrace, this.isRefreshing = false, }); + /// The error object. final Object error; + + /// Optional stack trace. final StackTrace? stackTrace; + + /// Whether the resource is refreshing. final bool isRefreshing; @override @@ -1428,6 +1885,7 @@ class ResourceError implements ResourceState { return error(this); } + /// Returns a copy with updated fields. ResourceError copyWith({ Object? error, StackTrace? stackTrace, @@ -1459,28 +1917,43 @@ class ResourceError implements ResourceState { int get hashCode => Object.hash(runtimeType, error, stackTrace, isRefreshing); } +/// Convenience accessors for [ResourceState]. +/// +/// Includes common flags (`isLoading`, `isReady`, `hasError`), casting helpers +/// (`asReady`, `asError`), and pattern matching helpers (`when`, `maybeWhen`, +/// `maybeMap`). extension ResourceStateExtensions on ResourceState { + /// Whether this state is loading. bool get isLoading => this is ResourceLoading; + + /// Whether this state is an error. bool get hasError => this is ResourceError; + + /// Whether this state is ready. bool get isReady => this is ResourceReady; + + /// Whether this state is marked as refreshing. bool get isRefreshing => switch (this) { ResourceReady(:final isRefreshing) => isRefreshing, ResourceError(:final isRefreshing) => isRefreshing, ResourceLoading() => false, }; + /// Casts to [ResourceReady] if possible. ResourceReady? get asReady => map( ready: (r) => r, error: (_) => null, loading: (_) => null, ); + /// Casts to [ResourceError] if possible. ResourceError? get asError => map( error: (e) => e, ready: (_) => null, loading: (_) => null, ); + /// Returns the value for ready state, throws for error state. T? get value => map( ready: (r) => r.value, // ignore: only_throw_errors @@ -1488,12 +1961,14 @@ extension ResourceStateExtensions on ResourceState { loading: (_) => null, ); + /// Returns the error for error state. Object? get error => map( error: (r) => r.error, ready: (_) => null, loading: (_) => null, ); + /// Executes callbacks for each state. R when({ required R Function(T data) ready, required R Function(Object error, StackTrace? stackTrace) error, @@ -1506,6 +1981,7 @@ extension ResourceStateExtensions on ResourceState { ); } + /// Executes callbacks for available handlers, otherwise [orElse]. R maybeWhen({ required R Function() orElse, R Function(T data)? ready, @@ -1528,6 +2004,7 @@ extension ResourceStateExtensions on ResourceState { ); } + /// Executes callbacks for available handlers, otherwise [orElse]. R maybeMap({ required R Function() orElse, R Function(ResourceReady ready)? ready, @@ -1551,8 +2028,10 @@ extension ResourceStateExtensions on ResourceState { } } +/// Default [Disposable] implementation using cleanup callbacks. mixin DisposableMixin implements Disposable { @internal + /// Registered cleanup callbacks invoked on dispose. late final cleanups = []; @override From 11eacab915c0f58adbe2f4178cbc5d6efabb9ca9 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 04:45:15 +0800 Subject: [PATCH 042/121] Bump solidart version to 3.0.0-dev.1 Update the package version in pubspec.yaml to 3.0.0-dev.1 for the next development release. --- packages/solidart/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/solidart/pubspec.yaml b/packages/solidart/pubspec.yaml index 8eaf9774..2cb92dc2 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: 2.8.3 +version: 3.0.0-dev.1 repository: https://github.com/nank1ro/solidart documentation: https://solidart.mariuti.com topics: From abfd39cef6b4a01ee0719d53d76b601d44f214b7 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 05:19:49 +0800 Subject: [PATCH 043/121] Update to solidart v3.0.0-dev.1 - Replace custom signal classes with direct solidart exports - Update observer API to use ReadonlySignal and identifier - Rename DisposeEffect to Effect and hasValue to isInitialized - Remove toggle method and updateValue in favor of direct assignment - Improve ValueListenable extensions with better disposal handling --- packages/flutter_solidart/README.md | 17 +- .../flutter_solidart/example/lib/main.dart | 27 ++- .../example/lib/pages/effects.dart | 6 +- .../example/lib/pages/lazy_counter.dart | 6 +- .../example/lib/pages/list_signal.dart | 14 +- .../example/lib/pages/map_signal.dart | 14 +- .../example/lib/pages/set_signal.dart | 14 +- .../example/lib/pages/show.dart | 2 +- .../lib/flutter_solidart.dart | 21 +-- .../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 ----- .../lib/src/utils/extensions.dart | 67 +++++--- .../lib/src/widgets/show.dart | 42 +---- .../lib/src/widgets/signal_builder.dart | 47 +++-- packages/flutter_solidart/pubspec.yaml | 2 +- .../test/flutter_solidart_test.dart | 161 +++++++----------- packages/solidart/lib/v3.dart | 1 + 24 files changed, 183 insertions(+), 523 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/README.md b/packages/flutter_solidart/README.md index 414ccaf1..45292e9b 100644 --- a/packages/flutter_solidart/README.md +++ b/packages/flutter_solidart/README.md @@ -53,7 +53,7 @@ To change the value, you can use: // Set the value to 2 counter.value = 2; // Update the value based on the current value -counter.updateValue((value) => value * 2); +counter.value = counter.value * 2; ``` ### Effect @@ -65,9 +65,11 @@ The effect automatically subscribes to any signal and reruns when any of them ch So let's create an Effect that reruns whenever `counter` changes: ```dart -final disposeFn = Effect(() { +final effect = Effect(() { print("The count is now ${counter.value}"); }); +// Later... +effect.dispose(); ``` ### Computed @@ -81,11 +83,11 @@ A `Computed` automatically subscribes to any signal provided and reruns when any final name = Signal('John'); final lastName = Signal('Doe'); final fullName = Computed(() => '${name.value} ${lastName.value}'); -print(fullName()); // prints "John Doe" +print(fullName.value); // prints "John Doe" // Update the name -name.set('Jane'); -print(fullName()); // prints "Jane Doe" +name.value = 'Jane'; +print(fullName.value); // prints "Jane Doe" ``` ### Resource @@ -123,7 +125,8 @@ If you're using `SignalBuilder` you can react to the state of the resource: ```dart SignalBuilder( builder: (_, __) { - return user.state.when( + final userState = user.state; + return userState.when( ready: (data) { return Column( mainAxisSize: MainAxisSize.min, @@ -166,7 +169,7 @@ SignalBuilder( ) ``` -The `on` method forces you to handle all the states of a Resource (_ready_, _error_ and _loading_). +The `when` method forces you to handle all the states of a Resource (_ready_, _error_ and _loading_). The are also other convenience methods to handle only specific states. ### Dependency Injection diff --git a/packages/flutter_solidart/example/lib/main.dart b/packages/flutter_solidart/example/lib/main.dart index c3cefefc..4140ede2 100644 --- a/packages/flutter_solidart/example/lib/main.dart +++ b/packages/flutter_solidart/example/lib/main.dart @@ -17,24 +17,37 @@ import 'package:flutter_solidart/flutter_solidart.dart'; /// class Logger implements SolidartObserver { @override - void didCreateSignal(SignalBase signal) { - final value = signal.hasValue ? signal.value : 'undefined'; - dev.log('didCreateSignal(name: ${signal.name}, value: $value)'); + void didCreateSignal(ReadonlySignal signal) { + final value = _safeValue(signal); + dev.log( + 'didCreateSignal(name: ${signal.identifier.name}, value: $value)', + ); } @override - void didDisposeSignal(SignalBase signal) { - dev.log('didDisposeSignal(name: ${signal.name})'); + void didDisposeSignal(ReadonlySignal signal) { + dev.log('didDisposeSignal(name: ${signal.identifier.name})'); } @override - void didUpdateSignal(SignalBase signal) { + void didUpdateSignal(ReadonlySignal signal) { dev.log( - 'didUpdateSignal(name: ${signal.name}, previousValue: ${signal.previousValue}, value: ${signal.value})', + 'didUpdateSignal(name: ${signal.identifier.name}, previousValue: ${signal.previousValue}, value: ${_safeValue(signal)})', ); } } +Object? _safeValue(ReadonlySignal signal) { + if (signal is LazySignal && !signal.isInitialized) { + return 'uninitialized'; + } + try { + return signal.value; + } on StateError { + return 'uninitialized'; + } +} + void main() { SolidartConfig.observers.add(Logger()); runApp(const MyApp()); diff --git a/packages/flutter_solidart/example/lib/pages/effects.dart b/packages/flutter_solidart/example/lib/pages/effects.dart index ce1a523f..8a146eda 100644 --- a/packages/flutter_solidart/example/lib/pages/effects.dart +++ b/packages/flutter_solidart/example/lib/pages/effects.dart @@ -10,12 +10,12 @@ class EffectsPage extends StatefulWidget { class _EffectsPageState extends State { final count = Signal(0, name: 'count'); - late final DisposeEffect disposeEffect; + late final Effect effect; @override void initState() { super.initState(); - disposeEffect = Effect(() { + effect = Effect(() { // ignore: avoid_print print("The count is now ${count.value}"); }); @@ -23,7 +23,7 @@ class _EffectsPageState extends State { @override void dispose() { - disposeEffect(); + effect.dispose(); super.dispose(); } diff --git a/packages/flutter_solidart/example/lib/pages/lazy_counter.dart b/packages/flutter_solidart/example/lib/pages/lazy_counter.dart index 0748aa3c..4efeba24 100644 --- a/packages/flutter_solidart/example/lib/pages/lazy_counter.dart +++ b/packages/flutter_solidart/example/lib/pages/lazy_counter.dart @@ -18,7 +18,7 @@ class _LazyCounterPageState extends State { body: Center( child: SignalBuilder( builder: (_, _) { - return switch (counter.hasValue) { + return switch (counter.isInitialized) { true => Text('Counter: ${counter.value}'), false => const Text('Counter: not initialized'), }; @@ -32,7 +32,7 @@ class _LazyCounterPageState extends State { heroTag: "subtract hero", child: const Icon(Icons.remove), onPressed: () { - counter.hasValue ? counter.value -= 1 : counter.value = 0; + counter.isInitialized ? counter.value -= 1 : counter.value = 0; }, ), const SizedBox(width: 8), @@ -40,7 +40,7 @@ class _LazyCounterPageState extends State { heroTag: "add hero", child: const Icon(Icons.add), onPressed: () { - counter.hasValue ? counter.value += 1 : counter.value = 0; + counter.isInitialized ? counter.value += 1 : counter.value = 0; }, ), ], diff --git a/packages/flutter_solidart/example/lib/pages/list_signal.dart b/packages/flutter_solidart/example/lib/pages/list_signal.dart index c87901f1..863b8ee8 100644 --- a/packages/flutter_solidart/example/lib/pages/list_signal.dart +++ b/packages/flutter_solidart/example/lib/pages/list_signal.dart @@ -1,5 +1,3 @@ -// ignore_for_file: avoid_print - import 'dart:math'; import 'package:flutter/material.dart'; @@ -13,15 +11,7 @@ class ListSignalPage extends StatefulWidget { } class _ListSignalPageState extends State { - final items = ListSignal([1, 2], name: 'items'); - - @override - void initState() { - super.initState(); - items.observe((previousValue, value) { - print("Items changed: $previousValue -> $value"); - }); - } + final items = ReactiveList([1, 2], name: 'items'); @override void dispose() { @@ -32,7 +22,7 @@ class _ListSignalPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('ListSignal')), + appBar: AppBar(title: const Text('ReactiveList')), body: Padding( padding: const EdgeInsets.all(24), child: Column( diff --git a/packages/flutter_solidart/example/lib/pages/map_signal.dart b/packages/flutter_solidart/example/lib/pages/map_signal.dart index ac2d0bd0..961c7e2b 100644 --- a/packages/flutter_solidart/example/lib/pages/map_signal.dart +++ b/packages/flutter_solidart/example/lib/pages/map_signal.dart @@ -1,5 +1,3 @@ -// ignore_for_file: avoid_print - import 'dart:math'; import 'package:flutter/material.dart'; @@ -15,15 +13,7 @@ class MapSignalPage extends StatefulWidget { } class _MapSignalPageState extends State { - final items = MapSignal({'a': 1, 'b': 2}, name: 'items'); - - @override - void initState() { - super.initState(); - items.observe((previousValue, value) { - print("Items changed: $previousValue -> $value"); - }); - } + final items = ReactiveMap({'a': 1, 'b': 2}, name: 'items'); @override void dispose() { @@ -41,7 +31,7 @@ class _MapSignalPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('SetSignal')), + appBar: AppBar(title: const Text('ReactiveMap')), body: Padding( padding: const EdgeInsets.all(24), child: Column( diff --git a/packages/flutter_solidart/example/lib/pages/set_signal.dart b/packages/flutter_solidart/example/lib/pages/set_signal.dart index 12a30118..067cdea8 100644 --- a/packages/flutter_solidart/example/lib/pages/set_signal.dart +++ b/packages/flutter_solidart/example/lib/pages/set_signal.dart @@ -1,5 +1,3 @@ -// ignore_for_file: avoid_print - import 'dart:math'; import 'package:flutter/material.dart'; @@ -13,15 +11,7 @@ class SetSignalPage extends StatefulWidget { } class _SetSignalPageState extends State { - final items = SetSignal({1, 2}, name: 'items'); - - @override - void initState() { - super.initState(); - items.observe((previousValue, value) { - print("Items changed: $previousValue -> $value"); - }); - } + final items = ReactiveSet({1, 2}, name: 'items'); @override void dispose() { @@ -32,7 +22,7 @@ class _SetSignalPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('SetSignal')), + appBar: AppBar(title: const Text('ReactiveSet')), body: Padding( padding: const EdgeInsets.all(24), child: Column( diff --git a/packages/flutter_solidart/example/lib/pages/show.dart b/packages/flutter_solidart/example/lib/pages/show.dart index ff486d3b..888c75bd 100644 --- a/packages/flutter_solidart/example/lib/pages/show.dart +++ b/packages/flutter_solidart/example/lib/pages/show.dart @@ -20,7 +20,7 @@ class _ShowPageState extends State { actions: [ TextButton( style: TextButton.styleFrom(foregroundColor: Colors.white), - onPressed: loggedIn.toggle, + onPressed: () => loggedIn.value = !loggedIn.value, child: Show( when: () => loggedIn.value, builder: (_) => const Text('LOGIN'), diff --git a/packages/flutter_solidart/lib/flutter_solidart.dart b/packages/flutter_solidart/lib/flutter_solidart.dart index a85edcf6..aaa36c59 100644 --- a/packages/flutter_solidart/lib/flutter_solidart.dart +++ b/packages/flutter_solidart/lib/flutter_solidart.dart @@ -2,26 +2,7 @@ /// Flutter solidart library. library; -export 'package:solidart/solidart.dart' - hide - Computed, - ListSignal, - MapSignal, - ReadableSignal, - Resource, - SetSignal, - Signal, - ToggleBoolSignal; - -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 'package:solidart/v3.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/lib/src/utils/extensions.dart b/packages/flutter_solidart/lib/src/utils/extensions.dart index 800a02a2..cc2a98cb 100644 --- a/packages/flutter_solidart/lib/src/utils/extensions.dart +++ b/packages/flutter_solidart/lib/src/utils/extensions.dart @@ -1,45 +1,64 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:flutter/foundation.dart'; +import 'package:solidart/v3.dart'; -/// {@template signal-to-value-notifier} -/// Converts a [SignalBase] into a [ValueNotifier]; +/// {@template readonly-signal-to-value-notifier} +/// Converts a [ReadonlySignal] into a [ValueNotifier]. +/// +/// The returned notifier stays in sync with the signal and disposes its +/// internal effect when the notifier or the signal is disposed. /// {@endtemplate} -extension SignalToValueNotifier on SignalBase { - /// {@macro signal-to-value-notifier} - ValueNotifier toValueNotifier() { - final notifier = ValueNotifier(value); - final unobserve = Effect(() => notifier.value = value); - onDispose(unobserve.call); - return notifier; +extension ReadonlySignalToValueNotifier on ReadonlySignal { + /// {@macro readonly-signal-to-value-notifier} + ValueNotifier toValueNotifier() => _SignalValueNotifier(this); +} + +class _SignalValueNotifier extends ValueNotifier { + _SignalValueNotifier(this._signal) : super(_signal.value) { + _effect = Effect( + () => value = _signal.value, + autoDispose: false, + detach: true, + ); + _signal.onDispose(_effect.dispose); + } + + final ReadonlySignal _signal; + late final Effect _effect; + + @override + void dispose() { + _effect.dispose(); + super.dispose(); } } -/// {@template value-notifier-to-signal} -/// Converts a [ValueNotifier] into a [Signal]; +/// {@template value-listenable-to-signal} +/// Converts a [ValueListenable] into a [Signal] that mirrors its value. +/// +/// Updates flow from the [ValueListenable] into the signal. Disposing the +/// signal removes the listener. /// {@endtemplate} -extension ValueNotifierToSignal on ValueNotifier { - /// {@macro value-notifier-to-signal} +extension ValueListenableToSignal on ValueListenable { + /// {@macro value-listenable-to-signal} Signal toSignal({ - /// {macro SignalBase.name} String? name, - - /// {macro SignalBase.autoDispose} bool? autoDispose, - - /// {macro SignalBase.trackInDevTools} + bool? trackPreviousValue, bool? trackInDevTools, + ValueComparator equals = identical, }) { final signal = Signal( value, - equals: true, name: name, autoDispose: autoDispose, + trackPreviousValue: trackPreviousValue, trackInDevTools: trackInDevTools, + equals: equals, ); - void setValue() => signal.value = value; - addListener(setValue); - signal.onDispose(() => removeListener(setValue)); + void sync() => signal.value = value; + addListener(sync); + signal.onDispose(() => removeListener(sync)); return signal; } } diff --git a/packages/flutter_solidart/lib/src/widgets/show.dart b/packages/flutter_solidart/lib/src/widgets/show.dart index 36366976..3f5e1ebc 100644 --- a/packages/flutter_solidart/lib/src/widgets/show.dart +++ b/packages/flutter_solidart/lib/src/widgets/show.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_solidart/src/widgets/signal_builder.dart'; -import 'package:solidart/solidart.dart'; /// {@template show} /// Conditionally render its [builder] or an optional [fallback] component @@ -20,7 +19,7 @@ import 'package:solidart/solidart.dart'; /// Widget build(BuildContext context) { /// return SignalBuilder( /// builder: (context, child) { -/// if (loggedIn()) return const Text('Logged in'); +/// if (loggedIn.value) return const Text('Logged in'); /// return const Text('Logged out'); /// }, /// ); @@ -34,7 +33,7 @@ import 'package:solidart/solidart.dart'; /// @override /// Widget build(BuildContext context) { /// return Show( -/// when: loggedIn, +/// when: () => loggedIn.value, /// builder: (context) => const Text('Logged In'), /// fallback: (context) => const Text('Logged out'), /// ); @@ -54,14 +53,14 @@ import 'package:solidart/solidart.dart'; /// @override /// Widget build(BuildContext context) { /// return Show( -/// when: () => count() > 5, +/// when: () => count.value > 5, /// builder: (context) => const Text('Count is greater than 5'), /// fallback: (context) => const Text('Count is lower than 6'), /// ); /// } /// ``` /// {@endtemplate} -class Show extends StatefulWidget { +class Show extends StatelessWidget { /// {@macro show} const Show({ super.key, @@ -84,38 +83,15 @@ class Show extends StatefulWidget { /// `false` final WidgetBuilder? fallback; - @override - State> createState() => _ShowState(); -} - -class _ShowState extends State> { - final show = ValueNotifier(false); - late final DisposeEffect disposeEffect; - - @override - void initState() { - super.initState(); - disposeEffect = Effect(() { - show.value = widget.when(); - }).call; - } - - @override - void dispose() { - show.dispose(); - disposeEffect(); - super.dispose(); - } - @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: show, - builder: (context, condition, _) { + return SignalBuilder( + builder: (context, _) { + final condition = when(); if (!condition) { - return widget.fallback?.call(context) ?? const SizedBox.shrink(); + return fallback?.call(context) ?? const SizedBox.shrink(); } - return widget.builder(context); + return builder(context); }, ); } diff --git a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart index 8abef8b3..c06a5be2 100644 --- a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart +++ b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart @@ -1,10 +1,8 @@ -// ignore_for_file: document_ignores - import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; import 'package:solidart/deps/preset.dart' as preset; import 'package:solidart/deps/system.dart' as system; -import 'package:solidart/solidart.dart'; +import 'package:solidart/v3.dart'; /// {@template signalbuilder} /// Reacts to the signals automatically found in the [builder] function. @@ -65,46 +63,45 @@ class SignalBuilder extends StatelessWidget { class _SignalBuilderElement extends StatelessElement { _SignalBuilderElement(SignalBuilder super.widget); - late final effect = Effect( - scheduler, - detach: true, + late final Effect _effect = Effect.manual( + _runEffect, autoDispose: false, - autorun: false, + detach: true, ); - void scheduler() { - if (dirty) return; + bool _isBuilding = false; + system.Link? _depsTail; + + void _runEffect() { + _effect.depsTail = _depsTail; + if (_isBuilding || dirty) { + return; + } markNeedsBuild(); } @override void unmount() { - effect.dispose(); + _effect.dispose(); super.unmount(); } @override Widget build() { - final prevSub = preset.getActiveSub(); - final node = effect; - preset.setActiveSub(node); - + _isBuilding = true; + final prevSub = preset.setActiveSub(_effect); + _effect.depsTail = null; + preset.cycle++; try { final built = super.build(); - if (SolidartConfig.assertSignalBuilderWithoutDependencies) { - assert(node.deps != null, ''' -SignalBuilder must detect at least one Signal, Computed, or Resource during the build. -This may mean your reactive values were disposed. -You can disable this check by setting `SolidartConfig.assertSignalBuilderWithoutDependencies = false` before `runApp()`' - '''); - } - // ignore: invalid_use_of_internal_member - effect.setDependencies(node); - node.flags = system.ReactiveFlags.watching; - + preset.purgeDeps(_effect); + _depsTail = _effect.depsTail; + _effect.flags = system.ReactiveFlags.watching; return built; } finally { preset.setActiveSub(prevSub); + _effect.flags &= ~system.ReactiveFlags.recursedCheck; + _isBuilding = false; } } } diff --git a/packages/flutter_solidart/pubspec.yaml b/packages/flutter_solidart/pubspec.yaml index 6b5b5fe6..098f2c20 100644 --- a/packages/flutter_solidart/pubspec.yaml +++ b/packages/flutter_solidart/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: flutter: sdk: flutter meta: ^1.11.0 - solidart: ^2.8.3 + solidart: 3.0.0-dev.1 dev_dependencies: disco: ^1.0.0 diff --git a/packages/flutter_solidart/test/flutter_solidart_test.dart b/packages/flutter_solidart/test/flutter_solidart_test.dart index 71403e10..234d9fa2 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'; @@ -420,12 +419,12 @@ void main() { expect(counterFinder(2), findsOneWidget); }); - testWidgets('(Provider) SignalBuilder works properly (1 ReadSignal)', ( + testWidgets('(Provider) SignalBuilder works properly (1 ReadonlySignal)', ( tester, ) async { final s = Signal(0); - final counterProvider = Provider((_) => s.toReadSignal()); + final counterProvider = Provider((_) => s.toReadonly()); await tester.pumpWidget( MaterialApp( @@ -648,7 +647,7 @@ void main() { }); }); - testWidgets('(Provider) Signal.updateValue method', (tester) async { + testWidgets('(Provider) Signal update via value assignment', (tester) async { final counterProvider = Provider((_) => Signal(0)); await tester.pumpWidget( MaterialApp( @@ -665,7 +664,7 @@ void main() { Text('${counter.value}'), ElevatedButton( onPressed: () { - counter.updateValue((value) => value + 1); + counter.value = counter.value + 1; }, child: const Text('add'), ), @@ -685,7 +684,9 @@ void main() { expect(find.text('1'), findsOneWidget); }); - testWidgets('(ArgProvider) Signal.updateValue method', (tester) async { + testWidgets('(ArgProvider) Signal update via value assignment', ( + tester, + ) async { final counterProvider = Provider.withArgument((_, int n) => Signal(n)); await tester.pumpWidget( MaterialApp( @@ -702,7 +703,7 @@ void main() { Text('${counter.value}'), ElevatedButton( onPressed: () { - counter.updateValue((value) => value + 1); + counter.value = counter.value + 1; }, child: const Text('add'), ), @@ -748,6 +749,14 @@ void main() { ); group('Automatic disposal', () { + setUp(() { + SolidartConfig.autoDispose = true; + }); + + tearDown(() { + SolidartConfig.autoDispose = false; + }); + testWidgets( 'Signal autoDispose', (tester) async { @@ -763,17 +772,17 @@ void main() { ), ), ); - expect(counter.disposed, isFalse); + expect(counter.isDisposed, isFalse); await tester.pumpWidget(const SizedBox()); - expect(counter.disposed, isTrue); + expect(counter.isDisposed, isTrue); }, timeout: const Timeout(Duration(seconds: 1)), ); testWidgets( - 'ReadSignal autoDispose', + 'ReadonlySignal autoDispose', (tester) async { - final counter = Signal(0).toReadSignal(); + final counter = Signal(0).toReadonly(); await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -785,9 +794,9 @@ void main() { ), ), ); - expect(counter.disposed, isFalse); + expect(counter.isDisposed, isFalse); await tester.pumpWidget(const SizedBox()); - expect(counter.disposed, isTrue); + expect(counter.isDisposed, isTrue); }, timeout: const Timeout(Duration(seconds: 1)), ); @@ -808,11 +817,11 @@ void main() { ), ), ); - expect(counter.disposed, isFalse); - expect(doubleCounter.disposed, isFalse); + expect(counter.isDisposed, isFalse); + expect(doubleCounter.isDisposed, isFalse); await tester.pumpWidget(const SizedBox()); - expect(counter.disposed, isTrue); - expect(doubleCounter.disposed, isTrue); + expect(counter.isDisposed, isTrue); + expect(doubleCounter.isDisposed, isTrue); }, timeout: const Timeout(Duration(seconds: 1)), ); @@ -833,11 +842,11 @@ void main() { ), ), ); - expect(counter.disposed, isFalse); - expect(effect.disposed, isFalse); + expect(counter.isDisposed, isFalse); + expect(effect.isDisposed, isFalse); await tester.pumpWidget(const SizedBox()); counter.dispose(); - expect(effect.disposed, isTrue); + expect(effect.isDisposed, isTrue); }, timeout: const Timeout(Duration(seconds: 1)), ); @@ -857,9 +866,9 @@ void main() { ), ), ); - expect(r.disposed, isFalse); + expect(r.isDisposed, isFalse); await tester.pumpWidget(const SizedBox()); - expect(r.disposed, isTrue); + expect(r.isDisposed, isTrue); }, timeout: const Timeout(Duration(seconds: 1)), ); @@ -868,6 +877,8 @@ void main() { testWidgets( 'Effect with multiple dependencies autoDispose', (tester) async { + SolidartConfig.autoDispose = true; + addTearDown(() => SolidartConfig.autoDispose = false); final counter = Signal(0); final doubleCounter = Computed(() => counter.value * 2); final effect = Effect(() { @@ -885,116 +896,70 @@ void main() { ), ), ); - expect(counter.disposed, isFalse); - expect(doubleCounter.disposed, isFalse); - expect(effect.disposed, isFalse); + expect(counter.isDisposed, isFalse); + expect(doubleCounter.isDisposed, isFalse); + expect(effect.isDisposed, isFalse); await tester.pumpWidget(const SizedBox()); - effect(); - expect(effect.disposed, isTrue); - expect(counter.disposed, isTrue); - expect(doubleCounter.disposed, isTrue); + effect.dispose(); + expect(effect.isDisposed, isTrue); + expect(counter.isDisposed, isTrue); + expect(doubleCounter.isDisposed, isTrue); }, timeout: const Timeout(Duration(seconds: 1)), ); - testWidgets('SignalBuilder without dependencies throws an error', ( - tester, - ) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SignalBuilder( - builder: (_, _) { - return const Text('No dependencies here'); - }, - ), - ), - ), - ); - await tester.pumpAndSettle(); - expect( - tester.takeException(), - isAssertionError.having( - (error) => error.message, - ''' -SignalBuilder must detect at least one Signal, Computed, or Resource during the build. -''', - contains( - '''SignalBuilder must detect at least one Signal, Computed, or Resource during the build.''', - ), - ), - ); - }); - - test('Signal is a ValueNotifier', () { + test('Signal to ValueNotifier notifies listeners', () { final signal = Signal(0); - expect(signal, isA>()); - expect(signal.value, 0); + final notifier = signal.toValueNotifier(); var notifiedValue = -1; void listener() { - notifiedValue = signal.value; + notifiedValue = notifier.value; } - signal.addListener(listener); + notifier.addListener(listener); signal.value = 1; expect(notifiedValue, 1); - signal.removeListener(listener); + notifier.removeListener(listener); signal.value = 2; expect(notifiedValue, 1); // Not updated since listener was removed + notifier.dispose(); }); - test('Resource is a ValueNotifier', () { - final r = Resource(() => Future.value(0)); - expect(r, isA>>()); - expect(r.state, isA>()); - var notifiedState = r.state; + test('Resource to ValueNotifier updates when ready', () { + final resource = Resource(() => Future.value(0)); + final notifier = resource.toValueNotifier(); + expect(notifier.value, isA>()); + var notifiedState = notifier.value; + void listener() { - notifiedState = r.state; + notifiedState = notifier.value; } - r.addListener(listener); + notifier.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 + notifier.removeListener(listener); + notifier.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', () { + test('ReadonlySignal to ValueNotifier works for Computed', () { final baseSignal = Signal(1); final computed = Computed(() => baseSignal.value * 2); - expect(computed, isA>()); - expect(computed.value, 2); + final notifier = computed.toValueNotifier(); + expect(notifier.value, 2); var notifiedValue = -1; void listener() { - notifiedValue = computed.value; + notifiedValue = notifier.value; } - computed.addListener(listener); + notifier.addListener(listener); baseSignal.value = 2; expect(notifiedValue, 4); - computed.removeListener(listener); + notifier.removeListener(listener); baseSignal.value = 3; expect(notifiedValue, 4); // Not updated since listener was removed + notifier.dispose(); }); } diff --git a/packages/solidart/lib/v3.dart b/packages/solidart/lib/v3.dart index 7fbed42d..df7dc06b 100644 --- a/packages/solidart/lib/v3.dart +++ b/packages/solidart/lib/v3.dart @@ -18,5 +18,6 @@ export 'src/v3.dart' Signal, SolidartConfig, SolidartObserver, + ValueComparator, batch, untracked; From f1c7174505d76f808392c49bf093cc6288102830 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 05:35:19 +0800 Subject: [PATCH 044/121] Clean v2 code and align v3 entry points --- packages/flutter_solidart/CHANGELOG.md | 9 + .../lib/flutter_solidart.dart | 2 +- .../lib/src/utils/extensions.dart | 2 +- .../lib/src/widgets/signal_builder.dart | 2 +- packages/flutter_solidart/pubspec.yaml | 2 +- packages/solidart/CHANGELOG.md | 7 + packages/solidart/example/main.dart | 2 +- packages/solidart/lib/advanced.dart | 2 +- packages/solidart/lib/solidart.dart | 34 +- packages/solidart/lib/src/core/alien.dart | 21 - packages/solidart/lib/src/core/batch.dart | 30 - .../lib/src/core/collections/list.dart | 912 -------- .../lib/src/core/collections/map.dart | 433 ---- .../lib/src/core/collections/set.dart | 475 ---- packages/solidart/lib/src/core/computed.dart | 258 --- packages/solidart/lib/src/core/config.dart | 66 - packages/solidart/lib/src/core/core.dart | 29 - packages/solidart/lib/src/core/devtools.dart | 104 - packages/solidart/lib/src/core/effect.dart | 208 -- .../solidart/lib/src/core/extensions.dart | 34 - .../lib/src/core/reactive_system.dart | 44 - .../solidart/lib/src/core/read_signal.dart | 365 --- packages/solidart/lib/src/core/resource.dart | 817 ------- packages/solidart/lib/src/core/signal.dart | 172 -- .../solidart/lib/src/core/signal_base.dart | 110 - packages/solidart/lib/src/core/untracked.dart | 14 - .../solidart/lib/src/extensions/until.dart | 50 - .../lib/src/{v3.dart => solidart.dart} | 0 packages/solidart/lib/src/utils.dart | 179 -- packages/solidart/lib/v3.dart | 23 - ...spose_test.dart => auto_dispose_test.dart} | 2 +- .../{v3_batch_test.dart => batch_test.dart} | 2 +- ...ctions_test.dart => collections_test.dart} | 2 +- ..._devtools_test.dart => devtools_test.dart} | 2 +- .../{v3_effect_test.dart => effect_test.dart} | 2 +- .../{v3_equals_test.dart => equals_test.dart} | 2 +- ...lue_test.dart => previous_value_test.dart} | 2 +- ..._resource_test.dart => resource_test.dart} | 2 +- packages/solidart/test/solidart_test.dart | 2061 ----------------- ...ntracked_test.dart => untracked_test.dart} | 2 +- 40 files changed, 51 insertions(+), 6434 deletions(-) delete mode 100644 packages/solidart/lib/src/core/alien.dart delete mode 100644 packages/solidart/lib/src/core/batch.dart delete mode 100644 packages/solidart/lib/src/core/collections/list.dart delete mode 100644 packages/solidart/lib/src/core/collections/map.dart delete mode 100644 packages/solidart/lib/src/core/collections/set.dart delete mode 100644 packages/solidart/lib/src/core/computed.dart delete mode 100644 packages/solidart/lib/src/core/config.dart delete mode 100644 packages/solidart/lib/src/core/core.dart delete mode 100644 packages/solidart/lib/src/core/devtools.dart delete mode 100644 packages/solidart/lib/src/core/effect.dart delete mode 100644 packages/solidart/lib/src/core/extensions.dart delete mode 100644 packages/solidart/lib/src/core/reactive_system.dart delete mode 100644 packages/solidart/lib/src/core/read_signal.dart delete mode 100644 packages/solidart/lib/src/core/resource.dart delete mode 100644 packages/solidart/lib/src/core/signal.dart delete mode 100644 packages/solidart/lib/src/core/signal_base.dart delete mode 100644 packages/solidart/lib/src/core/untracked.dart delete mode 100644 packages/solidart/lib/src/extensions/until.dart rename packages/solidart/lib/src/{v3.dart => solidart.dart} (100%) delete mode 100644 packages/solidart/lib/src/utils.dart delete mode 100644 packages/solidart/lib/v3.dart rename packages/solidart/test/{v3_auto_dispose_test.dart => auto_dispose_test.dart} (98%) rename packages/solidart/test/{v3_batch_test.dart => batch_test.dart} (93%) rename packages/solidart/test/{v3_collections_test.dart => collections_test.dart} (99%) rename packages/solidart/test/{v3_devtools_test.dart => devtools_test.dart} (97%) rename packages/solidart/test/{v3_effect_test.dart => effect_test.dart} (98%) rename packages/solidart/test/{v3_equals_test.dart => equals_test.dart} (96%) rename packages/solidart/test/{v3_previous_value_test.dart => previous_value_test.dart} (98%) rename packages/solidart/test/{v3_resource_test.dart => resource_test.dart} (99%) delete mode 100644 packages/solidart/test/solidart_test.dart rename packages/solidart/test/{v3_untracked_test.dart => untracked_test.dart} (92%) diff --git a/packages/flutter_solidart/CHANGELOG.md b/packages/flutter_solidart/CHANGELOG.md index 6e3fb21b..d23f3819 100644 --- a/packages/flutter_solidart/CHANGELOG.md +++ b/packages/flutter_solidart/CHANGELOG.md @@ -1,3 +1,12 @@ +## 3.0.0-dev.1 + +- **BREAKING**: Remove Flutter-specific core wrappers under `src/core/*`; `flutter_solidart` now re-exports `solidart/solidart.dart` directly. +- **BREAKING**: Replace `ListSignal`/`MapSignal`/`SetSignal` with `ReactiveList`/`ReactiveMap`/`ReactiveSet`. +- **BREAKING**: Replace `toReadSignal()` with `toReadonly()` and remove legacy `SignalBase`/`ReadableSignal` surfaces. +- **FEAT**: Add v3 conversion extensions: `ReadonlySignal.toValueNotifier()` and `ValueListenable.toSignal()`. +- **REFACTOR**: Rewrite `SignalBuilder` and `Show` to use v3 effects + dependency tracking. +- **DOCS**: Update README, examples, and tests to v3 syntax (`.value`, `isInitialized`, `Effect.dispose()`). + ## 2.7.2 ### Changes from solidart diff --git a/packages/flutter_solidart/lib/flutter_solidart.dart b/packages/flutter_solidart/lib/flutter_solidart.dart index aaa36c59..0f3cf6d1 100644 --- a/packages/flutter_solidart/lib/flutter_solidart.dart +++ b/packages/flutter_solidart/lib/flutter_solidart.dart @@ -2,7 +2,7 @@ /// Flutter solidart library. library; -export 'package:solidart/v3.dart'; +export 'package:solidart/solidart.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/utils/extensions.dart b/packages/flutter_solidart/lib/src/utils/extensions.dart index cc2a98cb..5a4d4310 100644 --- a/packages/flutter_solidart/lib/src/utils/extensions.dart +++ b/packages/flutter_solidart/lib/src/utils/extensions.dart @@ -1,5 +1,5 @@ import 'package:flutter/foundation.dart'; -import 'package:solidart/v3.dart'; +import 'package:solidart/solidart.dart'; /// {@template readonly-signal-to-value-notifier} /// Converts a [ReadonlySignal] into a [ValueNotifier]. diff --git a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart index c06a5be2..5af87498 100644 --- a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart +++ b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart @@ -2,7 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; import 'package:solidart/deps/preset.dart' as preset; import 'package:solidart/deps/system.dart' as system; -import 'package:solidart/v3.dart'; +import 'package:solidart/solidart.dart'; /// {@template signalbuilder} /// Reacts to the signals automatically found in the [builder] function. diff --git a/packages/flutter_solidart/pubspec.yaml b/packages/flutter_solidart/pubspec.yaml index 098f2c20..8213b4b9 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: 2.7.2 +version: 3.0.0-dev.1 repository: https://github.com/nank1ro/solidart documentation: https://solidart.mariuti.com topics: diff --git a/packages/solidart/CHANGELOG.md b/packages/solidart/CHANGELOG.md index 030a48f0..b5d70103 100644 --- a/packages/solidart/CHANGELOG.md +++ b/packages/solidart/CHANGELOG.md @@ -1,3 +1,10 @@ +## 3.0.0-dev.1 + +- **BREAKING**: Remove all v2 implementation code (`src/core`, `src/extensions`, v2 utils). +- **BREAKING**: `package:solidart/solidart.dart` is the sole public entry and re-exports the v3 APIs. +- **REFACTOR**: Rename `src/v3.dart` to `src/solidart.dart`. +- **BREAKING**: Drop v2-only helpers like `until`, `Debouncer`, and v2 exceptions. + ## 2.8.3 - **FIX**: Handle race conditions in Resource that caused multiple calls to `resolve`. diff --git a/packages/solidart/example/main.dart b/packages/solidart/example/main.dart index d38e9bb6..7b76a506 100644 --- a/packages/solidart/example/main.dart +++ b/packages/solidart/example/main.dart @@ -1,6 +1,6 @@ // ignore_for_file: avoid_print -import 'package:solidart/v3.dart'; +import 'package:solidart/solidart.dart'; void main() { final count = Signal(0); diff --git a/packages/solidart/lib/advanced.dart b/packages/solidart/lib/advanced.dart index 41a35635..39793641 100644 --- a/packages/solidart/lib/advanced.dart +++ b/packages/solidart/lib/advanced.dart @@ -1,4 +1,4 @@ -export 'src/v3.dart' +export 'src/solidart.dart' show Configuration, Disposable, diff --git a/packages/solidart/lib/solidart.dart b/packages/solidart/lib/solidart.dart index 32eaef69..71da3dfd 100644 --- a/packages/solidart/lib/solidart.dart +++ b/packages/solidart/lib/solidart.dart @@ -1,15 +1,21 @@ -/// Support for doing something awesome. -/// -/// More dartdocs go here. -library; - -export 'src/core/core.dart' - hide ReactionErrorHandler, ReactionInterface, ReactiveName, ValueComparator; -export 'src/extensions/until.dart'; -export 'src/utils.dart' +export 'src/solidart.dart' show - DebounceOperation, - Debouncer, - SolidartCaughtException, - SolidartException, - SolidartReactionException; + Computed, + Effect, + LazySignal, + ReactiveList, + ReactiveMap, + ReactiveSet, + ReadonlySignal, + Resource, + ResourceError, + ResourceLoading, + ResourceReady, + ResourceState, + ResourceStateExtensions, + Signal, + SolidartConfig, + SolidartObserver, + ValueComparator, + batch, + untracked; diff --git a/packages/solidart/lib/src/core/alien.dart b/packages/solidart/lib/src/core/alien.dart deleted file mode 100644 index efae3b1c..00000000 --- a/packages/solidart/lib/src/core/alien.dart +++ /dev/null @@ -1,21 +0,0 @@ -part of 'core.dart'; - -class _AlienComputed extends preset.ComputedNode { - _AlienComputed(this.parent, T Function(T? oldValue) getter) - : super(flags: system.ReactiveFlags.none, getter: getter); - - final Computed parent; - - void dispose() => preset.stop(this); -} - -class _AlienSignal extends preset.SignalNode> { - _AlienSignal(this.parent, Option value) - : super( - flags: system.ReactiveFlags.mutable, - currentValue: value, - pendingValue: value, - ); - - final SignalBase parent; -} diff --git a/packages/solidart/lib/src/core/batch.dart b/packages/solidart/lib/src/core/batch.dart deleted file mode 100644 index 8cd4fcfb..00000000 --- a/packages/solidart/lib/src/core/batch.dart +++ /dev/null @@ -1,30 +0,0 @@ -part of 'core.dart'; - -/// Execute a callback that will not side-effect until its top-most batch is -/// completed. -/// -/// Example: -/// ```dart -/// final x = Signal(10); -/// final y = Signal(20); -/// -/// Effect(() => print('x = ${x.value}, y = ${y.value}')); -/// // The Effect above prints 'x = 10, y = 20' -/// -/// batch(() { -/// x.value++; -/// y.value++; -/// }); -/// // The Effect above prints 'x = 11, y = 21' -/// ``` -/// As you can see, the effect is not executed until the batch is completed. -/// So when `x` changes, the effect is paused and you never see it printing: -/// "x = 11, y = 20". -T batch(T Function() fn) { - preset.startBatch(); - try { - return fn(); - } finally { - preset.endBatch(); - } -} diff --git a/packages/solidart/lib/src/core/collections/list.dart b/packages/solidart/lib/src/core/collections/list.dart deleted file mode 100644 index 66d3ae96..00000000 --- a/packages/solidart/lib/src/core/collections/list.dart +++ /dev/null @@ -1,912 +0,0 @@ -part of '../core.dart'; - -/// {@template list-signal} -/// `ListSignal` makes easier interacting with lists in a reactive context. -/// -/// ```dart -/// final list = ListSignal([1]); -/// -/// Effect((_) { -/// print(list.first); -/// }); // prints 1 -/// -/// list[0] = 100; // the effect prints 100 -/// ``` -/// {@endtemplate} -class ListSignal extends Signal> with ListMixin { - /// {@macro list-signal} - ListSignal( - Iterable initialValue, { - - /// {@macro SignalBase.name} - super.name, - - /// {@macro SignalBase.equals} - super.equals, - - /// {@macro SignalBase.autoDispose} - super.autoDispose, - - /// {@macro SignalBase.trackInDevTools} - super.trackInDevTools, - - /// {@macro SignalBase.comparator} - super.comparator = identical, - - /// {@macro SignalBase.trackPreviousValue} - super.trackPreviousValue, - }) : super(initialValue.toList()); - - @override - List setValue(List newValue) { - if (_compare(_untrackedValue, newValue)) { - return newValue; - } - _setPreviousValue(List.of(_untrackedValue)); - _untrackedValue = _value = newValue; - _notifyChanged(); - return newValue; - } - - @override - List updateValue(List Function(List value) callback) { - return setValue(callback(List.of(_untrackedValue))); - } - - @override - bool _compare(List? oldValue, List? newValue) { - // skip if the value are equals - if (equals) { - return ListEquality().equals(oldValue, newValue); - } - - // return the [comparator] result - return comparator(oldValue, newValue); - } - - /// The number of objects in this list. - /// - /// The valid indices for a list are `0` through `length - 1`. - /// ```dart - /// final numbers = [1, 2, 3]; - /// print(numbers.length); // 3 - /// ``` - @override - int get length { - return _value.length; - } - - /// Setting the `length` changes the number of elements in the list. - /// - /// The list must be growable. - /// If [newLength] is greater than current length, - /// new entries are initialized to `null`, - /// so [newLength] must not be greater than the current length - /// if the element type [E] is non-nullable. - /// - /// ```dart - /// final maybeNumbers = [1, null, 3]; - /// maybeNumbers.length = 5; - /// print(maybeNumbers); // [1, null, 3, null, null] - /// maybeNumbers.length = 2; - /// print(maybeNumbers); // [1, null] - /// - /// final numbers = [1, 2, 3]; - /// numbers.length = 1; - /// print(numbers); // [1] - /// numbers.length = 5; // Throws, cannot add `null`s. - /// ``` - @override - set length(int newLength) { - if (newLength == _untrackedValue.length) return; - _value.length = newLength; - _notifyChanged(); - } - - /// Returns the [index]th element. - /// - /// The [index] must be non-negative and less than [length]. - /// Index zero represents the first element (so `iterable.elementAt(0)` is - /// equivalent to `iterable.first`). - /// - /// May iterate through the elements in iteration order, ignoring the - /// first [index] elements and then returning the next. - /// Some iterables may have a more efficient way to find the element. - /// - /// Example: - /// ```dart - /// final numbers = [1, 2, 3, 5, 6, 7]; - /// final elementAt = numbers.elementAt(4); // 6 - /// ``` - @override - E elementAt(int index) { - return _value.elementAt(index); - } - - /// Returns the concatenation of this list and [other]. - /// - /// Returns a new list containing the elements of this list followed by - /// the elements of [other]. - /// - /// The default behavior is to return a normal growable list. - /// Some list types may choose to return a list of the same type as themselves - /// (see Uint8List.+); - @override - List operator +(List other) { - return _value + other; - } - - /// The object at the given [index] in the list. - /// - /// The [index] must be a valid index of this list, - /// which means that `index` must be non-negative and - /// less than [length]. - @override - E operator [](int index) { - return _value[index]; - } - - /// Sets the value at the given [index] in the list to [value]. - /// - /// The [index] must be a valid index of this list, - /// which means that `index` must be non-negative and - /// less than [length]. - @override - void operator []=(int index, E value) { - final oldValue = _untrackedValue[index]; - - if (oldValue != value) { - _setPreviousValue(List.of(_untrackedValue)); - _value[index] = value; - _notifyChanged(); - } - } - - /// Adds [value] to the end of this list, - /// extending the length by one. - /// - /// The list must be growable. - /// - /// ```dart - /// final numbers = [1, 2, 3]; - /// numbers.add(4); - /// print(numbers); // [1, 2, 3, 4] - /// ``` - @override - void add(E element) { - _setPreviousValue(List.of(_untrackedValue)); - _value.add(element); - _notifyChanged(); - } - - /// Appends all objects of [iterable] to the end of this list. - /// - /// Extends the length of the list by the number of objects in [iterable]. - /// The list must be growable. - /// - /// ```dart - /// final numbers = [1, 2, 3]; - /// numbers.addAll([4, 5, 6]); - /// print(numbers); // [1, 2, 3, 4, 5, 6] - /// ``` - @override - void addAll(Iterable iterable) { - if (iterable.isNotEmpty) { - _setPreviousValue(List.of(_untrackedValue)); - _value.addAll(iterable); - _notifyChanged(); - } - } - - /// A new `Iterator` that allows iterating the elements of this `Iterable`. - /// - /// Iterable classes may specify the iteration order of their elements - /// (for example [List] always iterate in index order), - /// or they may leave it unspecified (for example a hash-based [Set] - /// may iterate in any order). - /// - /// Each time `iterator` is read, it returns a new iterator, - /// which can be used to iterate through all the elements again. - /// The iterators of the same iterable can be stepped through independently, - /// but should return the same elements in the same order, - /// as long as the underlying collection isn't changed. - /// - /// Modifying the collection may cause new iterators to produce - /// different elements, and may change the order of existing elements. - /// A [List] specifies its iteration order precisely, - /// so modifying the list changes the iteration order predictably. - /// A hash-based [Set] may change its iteration order completely - /// when adding a new element to the set. - /// - /// Modifying the underlying collection after creating the new iterator - /// may cause an error the next time [Iterator.moveNext] is called - /// on that iterator. - /// Any *modifiable* iterable class should specify which operations will - /// break iteration. - @override - Iterator get iterator { - return _value.iterator; - } - - /// The last index in the list that satisfies the provided [test]. - /// - /// Searches the list from index [start] to 0. - /// The first time an object `o` is encountered so that `test(o)` is true, - /// the index of `o` is returned. - /// If [start] is omitted, it defaults to the [length] of the list. - /// - /// ```dart - /// final notes = ['do', 're', 'mi', 're']; - /// final first = notes.lastIndexWhere((note) => note.startsWith('r')); // 3 - /// final second = notes.lastIndexWhere((note) => note.startsWith('r'), - /// 2); // 1 - /// ``` - /// - /// Returns -1 if element is not found. - /// ```dart - /// final notes = ['do', 're', 'mi', 're']; - /// final index = notes.lastIndexWhere((note) => note.startsWith('k')); - /// print(index); // -1 - /// ``` - @override - int lastIndexWhere(bool Function(E element) test, [int? start]) { - return _value.lastIndexWhere(test, start); - } - - /// The last element that satisfies the given predicate [test]. - /// - /// An iterable that can access its elements directly may check its - /// elements in any order (for example a list starts by checking the - /// last element and then moves towards the start of the list). - /// The default implementation iterates elements in iteration order, - /// checks `test(element)` for each, - /// and finally returns that last one that matched. - /// - /// Example: - /// ```dart - /// final numbers = [1, 2, 3, 5, 6, 7]; - /// var result = numbers.lastWhere((element) => element < 5); // 3 - /// result = numbers.lastWhere((element) => element > 5); // 7 - /// result = numbers.lastWhere((element) => element > 10, - /// orElse: () => -1); // -1 - /// ``` - /// - /// If no element satisfies [test], the result of invoking the [orElse] - /// function is returned. - /// If [orElse] is omitted, it defaults to throwing a [StateError]. - @override - E lastWhere(bool Function(E element) test, {E Function()? orElse}) { - return _value.lastWhere(test, orElse: orElse); - } - - /// The first element that satisfies the given predicate [test]. - /// - /// Iterates through elements and returns the first to satisfy [test]. - /// - /// Example: - /// ```dart - /// final numbers = [1, 2, 3, 5, 6, 7]; - /// var result = numbers.firstWhere((element) => element < 5); // 1 - /// result = numbers.firstWhere((element) => element > 5); // 6 - /// result = - /// numbers.firstWhere((element) => element > 10, orElse: () => -1); // -1 - /// ``` - /// - /// If no element satisfies [test], the result of invoking the [orElse] - /// function is returned. - /// If [orElse] is omitted, it defaults to throwing a [StateError]. - /// Stops iterating on the first matching element. - @override - E firstWhere(bool Function(E element) test, {E Function()? orElse}) { - return _value.firstWhere(test, orElse: orElse); - } - - /// The single element that satisfies [test]. - /// - /// Checks elements to see if `test(element)` returns true. - /// If exactly one element satisfies [test], that element is returned. - /// If more than one matching element is found, throws [StateError]. - /// If no matching element is found, returns the result of [orElse]. - /// If [orElse] is omitted, it defaults to throwing a [StateError]. - /// - /// Example: - /// ```dart - /// final numbers = [2, 2, 10]; - /// var result = numbers.singleWhere((element) => element > 5); // 10 - /// ``` - /// When no matching element is found, the result of calling [orElse] is - /// returned instead. - /// ```dart continued - /// result = numbers.singleWhere((element) => element == 1, - /// orElse: () => -1); // -1 - /// ``` - /// There must not be more than one matching element. - /// ```dart continued - /// result = numbers.singleWhere((element) => element == 2); // Throws Error. - /// ``` - @override - E singleWhere(bool Function(E element) test, {E Function()? orElse}) { - return _value.singleWhere(test, orElse: orElse); - } - - /// Checks that this iterable has only one element, and returns that element. - /// - /// Throws a [StateError] if `this` is empty or has more than one element. - /// This operation will not iterate past the second element. - @override - E get single { - return _value.single; - } - - /// The first element. - /// - /// Throws a [StateError] if `this` is empty. - /// Otherwise returns the first element in the iteration order, - /// equivalent to `this.elementAt(0)`. - @override - E get first { - return _value.first; - } - - /// The last element. - /// - /// Throws a [StateError] if `this` is empty. - /// Otherwise may iterate through the elements and returns the last one - /// seen. - /// Some iterables may have more efficient ways to find the last element - /// (for example a list can directly access the last element, - /// without iterating through the previous ones). - @override - E get last { - return _value.last; - } - - /// Returns a new list containing the elements between [start] and [end]. - /// - /// The new list is a `List` containing the elements of this list at - /// positions greater than or equal to [start] and less than [end] in the same - /// order as they occur in this list. - /// - /// ```dart - /// final colors = ['red', 'green', 'blue', 'orange', 'pink']; - /// print(colors.sublist(1, 3)); // [green, blue] - /// ``` - /// - /// If [end] is omitted, it defaults to the [length] of this list. - /// - /// ```dart - /// final colors = ['red', 'green', 'blue', 'orange', 'pink']; - /// print(colors.sublist(3)); // [orange, pink] - /// ``` - /// - /// The `start` and `end` positions must satisfy the relations - /// 0 ≤ `start` ≤ `end` ≤ [length]. - /// If `end` is equal to `start`, then the returned list is empty. - @override - List sublist(int start, [int? end]) { - return _value.sublist(start, end); - } - - /// Returns a view of this list as a list of [R] instances. - /// - /// If this list contains only instances of [R], all read operations - /// will work correctly. If any operation tries to read an element - /// that is not an instance of [R], the access will throw instead. - /// - /// Elements added to the list (e.g., by using [add] or [addAll]) - /// must be instances of [R] to be valid arguments to the adding function, - /// and they must also be instances of [E] to be accepted by - /// this list as well. - /// - /// Methods which accept `Object?` as argument, like [contains] and [remove], - /// will pass the argument directly to the this list's method - /// without any checks. - /// That means that you can do `listOfStrings.cast().remove("a")` - /// successfully, even if it looks like it shouldn't have any effect. - /// - /// Typically implemented as `List.castFrom(this)`. - @override - List cast() => ListSignal(_value.cast()); - - /// Creates a [List] containing the elements of this [Iterable]. - /// - /// The elements are in iteration order. - /// The list is fixed-length if [growable] is false. - /// - /// Example: - /// ```dart - /// final planets = {1: 'Mercury', 2: 'Venus', 3: 'Mars'}; - /// final keysList = planets.keys.toList(growable: false); // [1, 2, 3] - /// final valuesList = - /// planets.values.toList(growable: false); // [Mercury, Venus, Mars] - /// ``` - @override - List toList({bool growable = true}) { - return _value.toList(growable: growable); - } - - /// The first element of the list. - /// - /// The list must be non-empty when accessing its first element. - /// - /// The first element of a list can be modified, unlike an [Iterable]. - /// A `list.first` is equivalent to `list[0]`, - /// both for getting and setting the value. - /// - /// ```dart - /// final numbers = [1, 2, 3]; - /// print(numbers.first); // 1 - /// numbers.first = 10; - /// print(numbers.first); // 10 - /// numbers.clear(); - /// numbers.first; // Throws. - /// ``` - @override - set first(E value) { - final oldValue = _value.first; - if (oldValue != value) { - _setPreviousValue(List.of(_untrackedValue)); - _value.first = value; - _notifyChanged(); - } - } - - /// The last element of the list. - /// - /// The list must be non-empty when accessing its last element. - /// - /// The last element of a list can be modified, unlike an [Iterable]. - /// A `list.last` is equivalent to `theList[theList.length - 1]`, - /// both for getting and setting the value. - /// - /// ```dart - /// final numbers = [1, 2, 3]; - /// print(numbers.last); // 3 - /// numbers.last = 10; - /// print(numbers.last); // 10 - /// numbers.clear(); - /// numbers.last; // Throws. - /// ``` - @override - set last(E value) { - final oldValue = _value.last; - if (oldValue != value) { - _setPreviousValue(List.of(_untrackedValue)); - _value.last = value; - _notifyChanged(); - } - } - - /// Removes all objects from this list; the length of the list becomes zero. - /// - /// The list must be growable. - /// - /// ```dart - /// final numbers = [1, 2, 3]; - /// numbers.clear(); - /// print(numbers.length); // 0 - /// print(numbers); // [] - /// ``` - @override - void clear() { - if (_value.isNotEmpty) { - _setPreviousValue(List.of(_untrackedValue)); - _value.clear(); - _notifyChanged(); - } - } - - /// Overwrites a range of elements with [fillValue]. - /// - /// Sets the positions greater than or equal to [start] and less than [end], - /// to [fillValue]. - /// - /// The provided range, given by [start] and [end], must be valid. - /// A range from [start] to [end] is valid if 0 ≤ `start` ≤ `end` ≤ [length]. - /// An empty range (with `end == start`) is valid. - /// - /// If the element type is not nullable, the [fillValue] must be provided and - /// must be non-`null`. - /// - /// Example: - /// ```dart - /// final words = List.filled(5, 'old'); - /// print(words); // [old, old, old, old, old] - /// words.fillRange(1, 3, 'new'); - /// print(words); // [old, new, new, old, old] - /// ``` - @override - void fillRange(int start, int end, [E? fillValue]) { - if (end > start) { - _setPreviousValue(List.of(_untrackedValue)); - _value.fillRange(start, end, fillValue); - _notifyChanged(); - } - } - - /// Inserts [element] at position [index] in this list. - /// - /// This increases the length of the list by one and shifts all objects - /// at or after the index towards the end of the list. - /// - /// The list must be growable. - /// The [index] value must be non-negative and no greater than [length]. - /// - /// ```dart - /// final numbers = [1, 2, 3, 4]; - /// const index = 2; - /// numbers.insert(index, 10); - /// print(numbers); // [1, 2, 10, 3, 4] - /// ``` - @override - void insert(int index, E element) { - _setPreviousValue(List.of(_untrackedValue)); - _value.insert(index, element); - _notifyChanged(); - } - - /// Inserts all objects of [iterable] at position [index] in this list. - /// - /// This increases the length of the list by the length of [iterable] and - /// shifts all later objects towards the end of the list. - /// - /// The list must be growable. - /// The [index] value must be non-negative and no greater than [length]. - /// ```dart - /// final numbers = [1, 2, 3, 4]; - /// final insertItems = [10, 11]; - /// numbers.insertAll(2, insertItems); - /// print(numbers); // [1, 2, 10, 11, 3, 4] - /// ``` - @override - void insertAll(int index, Iterable iterable) { - if (iterable.isNotEmpty) { - _setPreviousValue(List.of(_untrackedValue)); - _value.insertAll(index, iterable); - _notifyChanged(); - } - } - - /// Removes the first occurrence of [value] from this list. - /// - /// Returns true if [value] was in the list, false otherwise. - /// The list must be growable. - /// - /// ```dart - /// final parts = ['head', 'shoulders', 'knees', 'toes']; - /// final retVal = parts.remove('head'); // true - /// print(parts); // [shoulders, knees, toes] - /// ``` - /// The method has no effect if [value] was not in the list. - /// ```dart - /// final parts = ['shoulders', 'knees', 'toes']; - /// // Note: 'head' has already been removed. - /// final retVal = parts.remove('head'); // false - /// print(parts); // [shoulders, knees, toes] - /// ``` - @override - bool remove(Object? element) { - var didRemove = false; - final index = _value.indexOf(element as E); - if (index >= 0) { - _setPreviousValue(List.of(_untrackedValue)); - _value.removeAt(index); - _notifyChanged(); - didRemove = true; - } - - return didRemove; - } - - /// Removes the object at position [index] from this list. - /// - /// This method reduces the length of `this` by one and moves all later - /// objects down by one position. - /// - /// Returns the removed value. - /// - /// The [index] must be in the range `0 ≤ index < length`. - /// The list must be growable. - /// ```dart - /// final parts = ['head', 'shoulder', 'knees', 'toes']; - /// final retVal = parts.removeAt(2); // knees - /// print(parts); // [head, shoulder, toes] - /// ``` - @override - E removeAt(int index) { - _setPreviousValue(List.of(_untrackedValue)); - - final removed = _value.removeAt(index); - _notifyChanged(); - - return removed; - } - - /// Removes and returns the last object in this list. - /// - /// The list must be growable and non-empty. - /// ```dart - /// final parts = ['head', 'shoulder', 'knees', 'toes']; - /// final retVal = parts.removeLast(); // toes - /// print(parts); // [head, shoulder, knees] - /// ``` - @override - E removeLast() { - _setPreviousValue(List.of(_untrackedValue)); - - final removed = _value.removeLast(); - _notifyChanged(); - - return removed; - } - - /// Removes a range of elements from the list. - /// - /// Removes the elements with positions greater than or equal to [start] - /// and less than [end], from the list. - /// This reduces the list's length by `end - start`. - /// - /// The provided range, given by [start] and [end], must be valid. - /// A range from [start] to [end] is valid if 0 ≤ `start` ≤ `end` ≤ [length]. - /// An empty range (with `end == start`) is valid. - /// - /// The list must be growable. - /// ```dart - /// final numbers = [1, 2, 3, 4, 5]; - /// numbers.removeRange(1, 4); - /// print(numbers); // [1, 5] - /// ``` - @override - void removeRange(int start, int end) { - if (end > start) { - _setPreviousValue(List.of(_untrackedValue)); - _value.removeRange(start, end); - _notifyChanged(); - } - } - - /// Removes all objects from this list that satisfy [test]. - /// - /// An object `o` satisfies [test] if `test(o)` is true. - /// ```dart - /// final numbers = ['one', 'two', 'three', 'four']; - /// numbers.removeWhere((item) => item.length == 3); - /// print(numbers); // [three, four] - /// ``` - /// The list must be growable. - @override - void removeWhere(bool Function(E element) test) { - final removedIndexes = []; - for (var i = _value.length - 1; i >= 0; --i) { - final element = _value[i]; - if (test(element)) { - removedIndexes.add(i); - } - } - if (removedIndexes.isNotEmpty) { - _setPreviousValue(List.of(_untrackedValue)); - for (final index in removedIndexes) { - _value.removeAt(index); - } - _notifyChanged(); - } - } - - /// Replaces a range of elements with the elements of [replacements]. - /// - /// Removes the objects in the range from [start] to [end], - /// then inserts the elements of [replacements] at [start]. - /// ```dart - /// final numbers = [1, 2, 3, 4, 5]; - /// final replacements = [6, 7]; - /// numbers.replaceRange(1, 4, replacements); - /// print(numbers); // [1, 6, 7, 5] - /// ``` - /// The provided range, given by [start] and [end], must be valid. - /// A range from [start] to [end] is valid if 0 ≤ `start` ≤ `end` ≤ [length]. - /// An empty range (with `end == start`) is valid. - /// - /// The operation `list.replaceRange(start, end, replacements)` - /// is roughly equivalent to: - /// ```dart - /// final numbers = [1, 2, 3, 4, 5]; - /// numbers.removeRange(1, 4); - /// final replacements = [6, 7]; - /// numbers.insertAll(1, replacements); - /// print(numbers); // [1, 6, 7, 5] - /// ``` - /// but may be more efficient. - /// - /// The list must be growable. - /// This method does not work on fixed-length lists, even when [replacements] - /// has the same number of elements as the replaced range. In that case use - /// [setRange] instead. - @override - void replaceRange(int start, int end, Iterable replacements) { - if (end > start || replacements.isNotEmpty) { - _setPreviousValue(List.of(_untrackedValue)); - _value.replaceRange(start, end, replacements); - _notifyChanged(); - } - } - - /// Removes all objects from this list that fail to satisfy [test]. - /// - /// An object `o` satisfies [test] if `test(o)` is true. - /// ```dart - /// final numbers = ['one', 'two', 'three', 'four']; - /// numbers.retainWhere((item) => item.length == 3); - /// print(numbers); // [one, two] - /// ``` - /// The list must be growable. - @override - void retainWhere(bool Function(E element) test) { - final removedIndexes = []; - for (var i = _value.length - 1; i >= 0; --i) { - final element = _value[i]; - if (!test(element)) { - removedIndexes.add(i); - } - } - if (removedIndexes.isNotEmpty) { - _setPreviousValue(List.of(_untrackedValue)); - for (final index in removedIndexes) { - _value.removeAt(index); - } - } - } - - /// Overwrites elements with the objects of [iterable]. - /// - /// The elements of [iterable] are written into this list, - /// starting at position [index]. - /// This operation does not increase the length of the list. - /// - /// The [index] must be non-negative and no greater than [length]. - /// - /// The [iterable] must not have more elements than what can fit from [index] - /// to [length]. - /// - /// If `iterable` is based on this list, its values may change _during_ the - /// `setAll` operation. - /// ```dart - /// final list = ['a', 'b', 'c', 'd']; - /// list.setAll(1, ['bee', 'sea']); - /// print(list); // [a, bee, sea, d] - /// ``` - @override - void setAll(int index, Iterable iterable) { - if (iterable.isNotEmpty) { - _setPreviousValue(List.of(_untrackedValue)); - - _value.setAll(index, iterable); - _notifyChanged(); - } - } - - /// Writes some elements of [iterable] into a range of this list. - /// - /// Copies the objects of [iterable], skipping [skipCount] objects first, - /// into the range from [start], inclusive, to [end], exclusive, of this list. - /// ```dart - /// final list1 = [1, 2, 3, 4]; - /// final list2 = [5, 6, 7, 8, 9]; - /// // Copies the 4th and 5th items in list2 as the 2nd and 3rd items - /// // of list1. - /// const skipCount = 3; - /// list1.setRange(1, 3, list2, skipCount); - /// print(list1); // [1, 8, 9, 4] - /// ``` - /// The provided range, given by [start] and [end], must be valid. - /// A range from [start] to [end] is valid if 0 ≤ `start` ≤ `end` ≤ [length]. - /// An empty range (with `end == start`) is valid. - /// - /// The [iterable] must have enough objects to fill the range from `start` - /// to `end` after skipping [skipCount] objects. - /// - /// If `iterable` is this list, the operation correctly copies the elements - /// originally in the range from `skipCount` to `skipCount + (end - start)` to - /// the range `start` to `end`, even if the two ranges overlap. - /// - /// If `iterable` depends on this list in some other way, no guarantees are - /// made. - @override - void setRange(int start, int end, Iterable iterable, [int skipCount = 0]) { - if (end > start) { - _setPreviousValue(List.of(_untrackedValue)); - _value.setRange(start, end, iterable, skipCount); - _notifyChanged(); - } - } - - /// Shuffles the elements of this list randomly. - /// ```dart - /// final numbers = [1, 2, 3, 4, 5]; - /// numbers.shuffle(); - /// print(numbers); // [1, 3, 4, 5, 2] OR some other random result. - /// ``` - @override - void shuffle([Random? random]) { - if (_value.isNotEmpty) { - final oldList = _value.toList(growable: false); - final newList = _value.toList()..shuffle(random); - var hasChanges = false; - for (var i = 0; i < newList.length; ++i) { - final oldValue = oldList[i]; - final newValue = newList[i]; - if (newValue != oldValue) { - hasChanges = true; - break; - } - } - if (hasChanges) { - _setPreviousValue(List.of(_untrackedValue)); - _value = newList; - _notifyChanged(); - } - } - } - - /// Sorts this list according to the order specified by the [compare] function - /// - /// The [compare] function must act as a [Comparator]. - /// ```dart - /// final numbers = ['two', 'three', 'four']; - /// // Sort from shortest to longest. - /// numbers.sort((a, b) => a.length.compareTo(b.length)); - /// print(numbers); // [two, four, three] - /// ``` - /// The default [List] implementations use [Comparable.compare] if - /// [compare] is omitted. - /// ```dart - /// final numbers = [13, 2, -11, 0]; - /// numbers.sort(); - /// print(numbers); // [-11, 0, 2, 13] - /// ``` - /// In that case, the elements of the list must be [Comparable] to - /// each other. - /// - /// A [Comparator] may compare objects as equal (return zero), even if they - /// are distinct objects. - /// The sort function is not guaranteed to be stable, so distinct objects - /// that compare as equal may occur in any order in the result: - /// ```dart - /// final numbers = ['one', 'two', 'three', 'four']; - /// numbers.sort((a, b) => a.length.compareTo(b.length)); - /// print(numbers); // [one, two, four, three] OR [two, one, four, three] - /// ``` - @override - void sort([int Function(E a, E b)? compare]) { - if (_value.isNotEmpty) { - final oldList = _value.toList(growable: false); - final newList = _value.toList()..sort(compare); - var hasChanges = false; - for (var i = 0; i < newList.length; ++i) { - final oldValue = oldList[i]; - final newValue = newList[i]; - if (newValue != oldValue) { - hasChanges = true; - break; - } - } - if (hasChanges) { - _setPreviousValue(List.of(_untrackedValue)); - _value = newList; - _notifyChanged(); - } - } - } - - @override - String toString() => - '''ListSignal<$E>(value: $_value, previousValue: $_previousValue)'''; - - void _notifyChanged() { - _reportChanged(); - _notifySignalUpdate(); - } - - @override - // ignore: overridden_fields - final _id = ReactiveName.nameFor('ListSignal'); -} diff --git a/packages/solidart/lib/src/core/collections/map.dart b/packages/solidart/lib/src/core/collections/map.dart deleted file mode 100644 index 9338d0c9..00000000 --- a/packages/solidart/lib/src/core/collections/map.dart +++ /dev/null @@ -1,433 +0,0 @@ -part of '../core.dart'; - -/// {@template map-signal} -/// `MapSignal` makes easier interacting with maps in a reactive context. -/// -/// ```dart -/// final map = MapSignal({'first': 1}); -/// -/// Effect((_) { -/// print(map['first']); -/// }); // prints 1 -/// -/// map['first'] = 100; // the effect prints 100 -/// ``` -/// {@endtemplate} -class MapSignal extends Signal> with MapMixin { - /// {@macro map-signal} - MapSignal( - super.initialValue, { - - /// {@macro SignalBase.name} - super.name, - - /// {@macro SignalBase.equals} - super.equals, - - /// {@macro SignalBase.autoDispose} - super.autoDispose, - - /// {@macro SignalBase.trackInDevTools} - super.trackInDevTools, - - /// {@macro SignalBase.comparator} - super.comparator = identical, - - /// {@macro SignalBase.trackPreviousValue} - super.trackPreviousValue, - }); - - @override - Map setValue(Map newValue) { - if (_compare(_value, newValue)) { - return newValue; - } - _setPreviousValue(Map.of(_value)); - _untrackedValue = _value = newValue; - _notifyChanged(); - return newValue; - } - - @override - Map updateValue(Map Function(Map value) callback) => - value = callback(Map.of(_value)); - - @override - bool _compare(Map? oldValue, Map? newValue) { - // skip if the value are equals - if (equals) { - return MapEquality().equals(oldValue, newValue); - } - - // return the [comparator] result - return comparator(oldValue, newValue); - } - - /// The value for the given [key], or `null` if [key] is not in the map. - /// - /// Some maps allow `null` as a value. - /// For those maps, a lookup using this operator cannot distinguish between a - /// key not being in the map, and the key being there with a `null` value. - /// Methods like [containsKey] or [putIfAbsent] can be used if the distinction - /// is important. - @override - V? operator [](Object? key) { - value; - - // Wrap in parentheses to avoid parsing conflicts when casting the key - return _value[(key as K?)]; - } - - /// Associates the [key] with the given [value]. - /// - /// If the key was already in the map, its associated value is changed. - /// Otherwise the key/value pair is added to the map. - @override - void operator []=(K key, V value) { - final oldValue = _value[key]; - if (!_value.containsKey(key) || value != oldValue) { - _setPreviousValue(Map.of(_value)); - _value[key] = value; - _notifyChanged(); - } - } - - /// Removes all entries from the map. - /// - /// After this, the map is empty. - /// ```dart - /// final planets = {1: 'Mercury', 2: 'Venus', 3: 'Earth'}; - /// planets.clear(); // {} - /// ``` - @override - void clear() { - if (_value.isNotEmpty) { - _setPreviousValue(Map.of(_value)); - _value.clear(); - _notifyChanged(); - } - } - - /// The keys of the Map. - /// - /// The returned iterable has efficient `length` and `contains` operations, - /// based on [length] and [containsKey] of the map. - /// - /// The order of iteration is defined by the individual `Map` implementation, - /// but must be consistent between changes to the map. - /// - /// Modifying the map while iterating the keys may break the iteration. - @override - Iterable get keys { - value; - return _value.keys; - } - - /// The values of the Map. - /// - /// The values are iterated in the order of their corresponding keys. - /// This means that iterating [keys] and [values] in parallel will - /// provide matching pairs of keys and values. - /// - /// The returned iterable has an efficient `length` method based on the - /// [length] of the map. Its [Iterable.contains] method is based on - /// `==` comparison. - /// - /// Modifying the map while iterating the values may break the iteration. - @override - Iterable get values { - value; - return _value.values; - } - - /// Removes [key] and its associated value, if present, from the map. - /// - /// Returns the value associated with `key` before it was removed. - /// Returns `null` if `key` was not in the map. - /// - /// Note that some maps allow `null` as a value, - /// so a returned `null` value doesn't always mean that the key was absent. - /// ```dart - /// final terrestrial = {1: 'Mercury', 2: 'Venus', 3: 'Earth'}; - /// final removedValue = terrestrial.remove(2); // Venus - /// print(terrestrial); // {1: Mercury, 3: Earth} - /// ``` - @override - V? remove(Object? key) { - V? value; - if (_value.containsKey(key)) { - _setPreviousValue(Map.of(_value)); - value = _value.remove(key); - _notifyChanged(); - } - return value; - } - - /// Provides a view of this map as having [RK] keys and [RV] instances, - /// if necessary. - /// - /// If this map is already a `Map`, it is returned unchanged. - /// - /// If this set contains only keys of type [RK] and values of type [RV], - /// all read operations will work correctly. - /// If any operation exposes a non-[RK] key or non-[RV] value, - /// the operation will throw instead. - /// - /// Entries added to the map must be valid for both a `Map` and a - /// `Map`. - /// - /// Methods which accept `Object?` as argument, - /// like [containsKey], [remove] and [operator []], - /// will pass the argument directly to the this map's method - /// without any checks. - /// That means that you can do `mapWithStringKeys.cast().remove("a")` - /// successfully, even if it looks like it shouldn't have any effect. - @override - Map cast() => MapSignal(_value.cast()); - - /// The number of key/value pairs in the map. - @override - int get length { - value; - return _value.length; - } - - /// Whether there is no key/value pair in the map. - @override - bool get isEmpty { - value; - return _value.isEmpty; - } - - /// Whether there is at least one key/value pair in the map. - @override - bool get isNotEmpty { - value; - return _value.isNotEmpty; - } - - /// Whether this map contains the given [key]. - /// - /// Returns true if any of the keys in the map are equal to `key` - /// according to the equality used by the map. - /// ```dart - /// final moonCount = {'Mercury': 0, 'Venus': 0, 'Earth': 1, - /// 'Mars': 2, 'Jupiter': 79, 'Saturn': 82, 'Uranus': 27, 'Neptune': 14 }; - /// final containsUranus = moonCount.containsKey('Uranus'); // true - /// final containsPluto = moonCount.containsKey('Pluto'); // false - /// ``` - @override - bool containsKey(Object? key) { - value; - return _value.containsKey(key); - } - - /// Whether this map contains the given [value]. - /// - /// Returns true if any of the values in the map are equal to `value` - /// according to the `==` operator. - /// ```dart - /// final moonCount = {'Mercury': 0, 'Venus': 0, 'Earth': 1, - /// 'Mars': 2, 'Jupiter': 79, 'Saturn': 82, 'Uranus': 27, 'Neptune': 14 }; - /// final moons3 = moonCount.containsValue(3); // false - /// final moons82 = moonCount.containsValue(82); // true - /// ``` - @override - bool containsValue(Object? value) { - this.value; - return _value.containsValue(value); - } - - /// The map entries of the Map. - @override - Iterable> get entries { - value; - return _value.entries; - } - - /// Adds all key/value pairs of [newEntries] to this map. - /// - /// If a key of [newEntries] is already in this map, - /// the corresponding value is overwritten. - /// - /// The operation is equivalent to doing `this[entry.key] = entry.value` - /// for each [MapEntry] of the iterable. - /// ```dart - /// final planets = {1: 'Mercury', 2: 'Venus', - /// 3: 'Earth', 4: 'Mars'}; - /// final gasGiants = {5: 'Jupiter', 6: 'Saturn'}; - /// final iceGiants = {7: 'Uranus', 8: 'Neptune'}; - /// planets.addEntries(gasGiants.entries); - /// planets.addEntries(iceGiants.entries); - /// print(planets); - /// // {1: Mercury, 2: Venus, 3: Earth, 4: Mars, 5: Jupiter, 6: Saturn, - /// // 7: Uranus, 8: Neptune} - /// ``` - @override - void addEntries(Iterable> newEntries) { - if (newEntries.isNotEmpty) { - _setPreviousValue(Map.of(_value)); - _value.addEntries(newEntries); - _notifyChanged(); - } - } - - /// Adds all key/value pairs of [other] to this map. - /// - /// If a key of [other] is already in this map, its value is overwritten. - /// - /// The operation is equivalent to doing `this[key] = value` for each key - /// and associated value in other. It iterates over [other], which must - /// therefore not change during the iteration. - /// ```dart - /// final planets = {1: 'Mercury', 2: 'Earth'}; - /// planets.addAll({5: 'Jupiter', 6: 'Saturn'}); - /// print(planets); // {1: Mercury, 2: Earth, 5: Jupiter, 6: Saturn} - /// ``` - @override - void addAll(Map other) { - if (other.isNotEmpty) { - _setPreviousValue(Map.of(_value)); - _value.addAll(other); - _notifyChanged(); - } - } - - /// Look up the value of [key], or add a new entry if it isn't there. - /// - /// Returns the value associated to [key], if there is one. - /// Otherwise calls [ifAbsent] to get a new value, associates [key] to - /// that value, and then returns the new value. - /// ```dart - /// final diameters = {1.0: 'Earth'}; - /// final otherDiameters = {0.383: 'Mercury', 0.949: 'Venus'}; - /// - /// for (final item in otherDiameters.entries) { - /// diameters.putIfAbsent(item.key, () => item.value); - /// } - /// print(diameters); // {1.0: Earth, 0.383: Mercury, 0.949: Venus} - /// - /// // If the key already exists, the current value is returned. - /// final result = diameters.putIfAbsent(0.383, () => 'Random'); - /// print(result); // Mercury - /// print(diameters); // {1.0: Earth, 0.383: Mercury, 0.949: Venus} - /// ``` - /// Calling [ifAbsent] must not add or remove keys from the map. - @override - V putIfAbsent(K key, V Function() ifAbsent) { - if (_value.containsKey(key)) { - value; - return _value[key] as V; - } - _setPreviousValue(Map.of(_value)); - _value[key] = ifAbsent(); - _notifyChanged(); - return _value[key] as V; - } - - /// Removes all entries of this map that satisfy the given [test]. - /// ```dart - /// final terrestrial = {1: 'Mercury', 2: 'Venus', 3: 'Earth'}; - /// terrestrial.removeWhere((key, value) => value.startsWith('E')); - /// print(terrestrial); // {1: Mercury, 2: Venus} - /// ``` - @override - void removeWhere(bool Function(K key, V value) test) { - final keysToRemove = []; - for (final key in keys) { - if (test(key, this[key] as V)) keysToRemove.add(key); - } - if (keysToRemove.isNotEmpty) { - _setPreviousValue(Map.of(_value)); - } - for (final key in keysToRemove) { - _value.remove(key); - } - if (keysToRemove.isNotEmpty) { - _notifyChanged(); - } - } - - /// Updates the value for the provided [key]. - /// - /// Returns the new value associated with the key. - /// - /// If the key is present, invokes [update] with the current value and stores - /// the new value in the map. - /// - /// If the key is not present and [ifAbsent] is provided, calls [ifAbsent] - /// and adds the key with the returned value to the map. - /// - /// If the key is not present, [ifAbsent] must be provided. - /// ```dart - /// final planetsFromSun = {1: 'Mercury', 2: 'unknown', - /// 3: 'Earth'}; - /// // Update value for known key value 2. - /// planetsFromSun.update(2, (value) => 'Venus'); - /// print(planetsFromSun); // {1: Mercury, 2: Venus, 3: Earth} - /// - /// final largestPlanets = {1: 'Jupiter', 2: 'Saturn', - /// 3: 'Neptune'}; - /// // Key value 8 is missing from list, add it using [ifAbsent]. - /// largestPlanets.update(8, (value) => 'New', ifAbsent: () => 'Mercury'); - /// print(largestPlanets); // {1: Jupiter, 2: Saturn, 3: Neptune, 8: Mercury} - /// ``` - @override - V update(K key, V Function(V value) update, {V Function()? ifAbsent}) { - if (_value.containsKey(key)) { - final oldValue = _value[key]; - final newValue = update(_value[key] as V); - if (oldValue != newValue) { - _setPreviousValue(Map.of(_value)); - _value[key] = newValue; - } - return _value[key] as V; - } - if (ifAbsent != null) { - _setPreviousValue(Map.of(_value)); - return _value[key] = ifAbsent(); - } - throw ArgumentError.value(key, 'key', 'Key not in map.'); - } - - /// Updates all values. - /// - /// Iterates over all entries in the map and updates them with the result - /// of invoking [update]. - /// ```dart - /// final terrestrial = {1: 'Mercury', 2: 'Venus', 3: 'Earth'}; - /// terrestrial.updateAll((key, value) => value.toUpperCase()); - /// print(terrestrial); // {1: MERCURY, 2: VENUS, 3: EARTH} - /// ``` - @override - void updateAll(V Function(K key, V value) update) { - final changes = {}; - for (final key in keys) { - final oldValue = _value[key]; - final newValue = update(key, _value[key] as V); - if (oldValue != newValue) { - changes[key] = newValue; - } - } - if (changes.isNotEmpty) { - _setPreviousValue(Map.of(_value)); - for (final key in keys) { - _value[key] = changes[key] as V; - } - _notifyChanged(); - } - } - - @override - String toString() => - '''MapSignal<$K, $V>(value: $_value, previousValue: $_previousValue)'''; - - void _notifyChanged() { - _reportChanged(); - _notifySignalUpdate(); - } - - @override - // ignore: overridden_fields - final _id = ReactiveName.nameFor('MapSignal'); -} diff --git a/packages/solidart/lib/src/core/collections/set.dart b/packages/solidart/lib/src/core/collections/set.dart deleted file mode 100644 index 4e7324c8..00000000 --- a/packages/solidart/lib/src/core/collections/set.dart +++ /dev/null @@ -1,475 +0,0 @@ -part of '../core.dart'; - -/// {@template set-signal} -/// `SetSignal` makes easier interacting with sets in a reactive context. -/// -/// ```dart -/// final set = SetSignal({1}); -/// -/// Effect((_) { -/// print(set.first); -/// }); // prints 1 -/// -/// set[0] = 100; // the effect prints 100 -/// ``` -/// {@endtemplate} -class SetSignal extends Signal> with SetMixin { - /// {@macro set-signal} - SetSignal( - Iterable initialValue, { - - /// {@macro SignalBase.name} - super.name, - - /// {@macro SignalBase.equals} - super.equals, - - /// {@macro SignalBase.autoDispose} - super.autoDispose, - - /// {@macro SignalBase.trackInDevTools} - super.trackInDevTools, - - /// {@macro SignalBase.comparator} - super.comparator = identical, - - /// {@macro SignalBase.trackPreviousValue} - super.trackPreviousValue, - }) : super(initialValue.toSet()); - - @override - Set setValue(Set newValue) { - if (_compare(_value, newValue)) { - return newValue; - } - _setPreviousValue(Set.of(_value)); - _untrackedValue = _value = newValue; - _notifyChanged(); - return newValue; - } - - @override - Set updateValue(Set Function(Set value) callback) => - value = callback(Set.of(_value)); - - @override - bool _compare(Set? oldValue, Set? newValue) { - // skip if the value are equals - if (equals) { - return SetEquality().equals(oldValue, newValue); - } - - // return the [comparator] result - return comparator(oldValue, newValue); - } - - /// Adds [value] to the set. - /// - /// Returns `true` if [value] (or an equal value) was not yet in the set. - /// Otherwise returns `false` and the set is not changed. - /// - /// Example: - /// ```dart - /// final dateTimes = {}; - /// final time1 = DateTime.fromMillisecondsSinceEpoch(0); - /// final time2 = DateTime.fromMillisecondsSinceEpoch(0); - /// // time1 and time2 are equal, but not identical. - /// assert(time1 == time2); - /// assert(!identical(time1, time2)); - /// final time1Added = dateTimes.add(time1); - /// print(time1Added); // true - /// // A value equal to time2 exists already in the set, and the call to - /// // add doesn't change the set. - /// final time2Added = dateTimes.add(time2); - /// print(time2Added); // false - /// - /// print(dateTimes); // {1970-01-01 02:00:00.000} - /// assert(dateTimes.length == 1); - /// assert(identical(time1, dateTimes.first)); - /// print(dateTimes.length); - /// ``` - @override - bool add(E value) { - _setPreviousValue(Set.of(_value)); - final result = _value.add(value); - if (result) _notifyChanged(); - return result; - } - - /// Whether [value] is in the set. - /// ```dart - /// final characters = {'A', 'B', 'C'}; - /// final containsB = characters.contains('B'); // true - /// final containsD = characters.contains('D'); // false - /// ``` - @override - bool contains(Object? element) { - value; - return _value.contains(element); - } - - /// An iterator that iterates over the elements of this set. - /// - /// The order of iteration is defined by the individual `Set` implementation, - /// but must be consistent between changes to the set. - @override - Iterator get iterator { - value; - return _value.iterator; - } - - /// Returns the number of elements in the iterable. - /// - /// This is an efficient operation that doesn't require iterating through - /// the elements. - @override - int get length { - value; - return _value.length; - } - - /// Returns the [index]th element. - /// - /// The [index] must be non-negative and less than [length]. - /// Index zero represents the first element (so `iterable.elementAt(0)` is - /// equivalent to `iterable.first`). - /// - /// May iterate through the elements in iteration order, ignoring the - /// first [index] elements and then returning the next. - /// Some iterables may have a more efficient way to find the element. - /// - /// Example: - /// ```dart - /// final numbers = [1, 2, 3, 5, 6, 7]; - /// final elementAt = numbers.elementAt(4); // 6 - /// ``` - @override - E elementAt(int index) { - value; - return _value.elementAt(index); - } - - /// If an object equal to [object] is in the set, return it. - /// - /// Checks whether [object] is in the set, like [contains], and if so, - /// returns the object in the set, otherwise returns `null`. - /// - /// If the equality relation used by the set is not identity, - /// then the returned object may not be *identical* to [object]. - /// Some set implementations may not be able to implement this method. - /// If the [contains] method is computed, - /// rather than being based on an actual object instance, - /// then there may not be a specific object instance representing the - /// set element. - /// ```dart - /// final characters = {'A', 'B', 'C'}; - /// final containsB = characters.lookup('B'); - /// print(containsB); // B - /// final containsD = characters.lookup('D'); - /// print(containsD); // null - /// ``` - @override - E? lookup(Object? object) { - value; - return _value.lookup(object); - } - - /// Removes [value] from the set. - /// - /// Returns `true` if [value] was in the set, and `false` if not. - /// The method has no effect if [value] was not in the set. - /// ```dart - /// final characters = {'A', 'B', 'C'}; - /// final didRemoveB = characters.remove('B'); // true - /// final didRemoveD = characters.remove('D'); // false - /// print(characters); // {A, C} - /// ``` - @override - bool remove(Object? value) { - var didRemove = false; - final index = _value.toList(growable: false).indexOf(value as E); - if (index >= 0) { - _setPreviousValue(Set.of(_value)); - _value.remove(value); - _notifyChanged(); - didRemove = true; - } - - return didRemove; - } - - /// Removes each element of [elements] from this set. - /// ```dart - /// final characters = {'A', 'B', 'C'}; - /// characters.removeAll({'A', 'B', 'X'}); - /// print(characters); // {C} - /// ``` - @override - void removeAll(Iterable elements) { - _setPreviousValue(Set.of(_value)); - var hasChanges = false; - for (final element in elements) { - final removed = _value.remove(element); - if (!hasChanges && removed) { - hasChanges = true; - } - } - - if (hasChanges) _notifyChanged(); - } - - /// Removes all elements of this set that satisfy [test]. - /// ```dart - /// final characters = {'A', 'B', 'C'}; - /// characters.removeWhere((element) => element.startsWith('B')); - /// print(characters); // {A, C} - /// ``` - @override - void removeWhere(bool Function(E element) test) { - final toRemove = []; - for (final element in this) { - if (test(element)) { - toRemove.add(element); - } - } - removeAll(toRemove); - } - - /// Creates a [Set] with the same elements and behavior as this `Set`. - /// - /// The returned set behaves the same as this set - /// with regard to adding and removing elements. - /// It initially contains the same elements. - /// If this set specifies an ordering of the elements, - /// the returned set will have the same order. - @override - Set toSet() { - value; - return _value.toSet(); - } - - /// Provides a view of this set as a set of [R] instances. - /// - /// If this set contains only instances of [R], all read operations - /// will work correctly. If any operation tries to access an element - /// that is not an instance of [R], the access will throw instead. - /// - /// Elements added to the set (e.g., by using [add] or [addAll]) - /// must be instances of [R] to be valid arguments to the adding function, - /// and they must be instances of [E] as well to be accepted by - /// this set as well. - /// - /// Methods which accept one or more `Object?` as argument, - /// like [contains], [remove] and [removeAll], - /// will pass the argument directly to the this set's method - /// without any checks. - /// That means that you can do `setOfStrings.cast().remove("a")` - /// successfully, even if it looks like it shouldn't have any effect. - @override - Set cast() => SetSignal(_value.cast()); - - /// Removes all elements from the set. - /// ```dart - /// final characters = {'A', 'B', 'C'}; - /// characters.clear(); // {} - /// ``` - @override - void clear() { - if (_value.isNotEmpty) { - _setPreviousValue(Set.of(_value)); - _value.clear(); - _notifyChanged(); - } - } - - /// Removes all elements of this set that are not elements in [elements]. - /// - /// Checks for each element of [elements] whether there is an element in this - /// set that is equal to it (according to `this.contains`), and if so, the - /// equal element in this set is retained, and elements that are not equal - /// to any element in [elements] are removed. - /// ```dart - /// final characters = {'A', 'B', 'C'}; - /// characters.retainAll({'A', 'B', 'X'}); - /// print(characters); // {A, B} - /// ``` - @override - void retainAll(Iterable elements) { - // Create a copy of the set, remove all of elements from the copy, - // then remove all remaining elements in copy from this. - final toRemove = _value.toSet(); - for (final e in elements) { - toRemove.remove(e); - } - removeAll(toRemove); - } - - /// Removes all elements of this set that fail to satisfy [test]. - /// ```dart - /// final characters = {'A', 'B', 'C'}; - /// characters.retainWhere( - /// (element) => element.startsWith('B') || element.startsWith('C')); - /// print(characters); // {B, C} - /// ``` - @override - void retainWhere(bool Function(E element) test) { - final removed = []; - for (var i = _value.length - 1; i >= 0; --i) { - final element = _value.elementAt(i); - if (!test(element)) { - removed.add(element); - } - } - if (removed.isNotEmpty) { - _setPreviousValue(Set.of(_value)); - for (final item in removed) { - _value.remove(item); - } - } - } - - /// Creates a [List] containing the elements of this [Iterable]. - /// - /// The elements are in iteration order. - /// The list is fixed-length if [growable] is false. - /// - /// Example: - /// ```dart - /// final planets = {1: 'Mercury', 2: 'Venus', 3: 'Mars'}; - /// final keysList = planets.keys.toList(growable: false); // [1, 2, 3] - /// final valuesList = - /// planets.values.toList(growable: false); // [Mercury, Venus, Mars] - /// ``` - @override - List toList({bool growable = true}) { - value; - return _value.toList(growable: growable); - } - - /// The last element that satisfies the given predicate [test]. - /// - /// An iterable that can access its elements directly may check its - /// elements in any order (for example a list starts by checking the - /// last element and then moves towards the start of the list). - /// The default implementation iterates elements in iteration order, - /// checks `test(element)` for each, - /// and finally returns that last one that matched. - /// - /// Example: - /// ```dart - /// final numbers = [1, 2, 3, 5, 6, 7]; - /// var result = numbers.lastWhere((element) => element < 5); // 3 - /// result = numbers.lastWhere((element) => element > 5); // 7 - /// result = numbers.lastWhere((element) => element > 10, - /// orElse: () => -1); // -1 - /// ``` - /// - /// If no element satisfies [test], the result of invoking the [orElse] - /// function is returned. - /// If [orElse] is omitted, it defaults to throwing a [StateError]. - @override - E lastWhere(bool Function(E value) test, {E Function()? orElse}) { - value; - return _value.lastWhere(test, orElse: orElse); - } - - /// The first element that satisfies the given predicate [test]. - /// - /// Iterates through elements and returns the first to satisfy [test]. - /// - /// Example: - /// ```dart - /// final numbers = [1, 2, 3, 5, 6, 7]; - /// var result = numbers.firstWhere((element) => element < 5); // 1 - /// result = numbers.firstWhere((element) => element > 5); // 6 - /// result = - /// numbers.firstWhere((element) => element > 10, orElse: () => -1); // -1 - /// ``` - /// - /// If no element satisfies [test], the result of invoking the [orElse] - /// function is returned. - /// If [orElse] is omitted, it defaults to throwing a [StateError]. - /// Stops iterating on the first matching element. - @override - E firstWhere(bool Function(E value) test, {E Function()? orElse}) { - value; - return _value.firstWhere(test, orElse: orElse); - } - - /// Checks that this iterable has only one element, and returns that element. - /// - /// Throws a [StateError] if `this` is empty or has more than one element. - /// This operation will not iterate past the second element. - @override - E get single { - value; - return _value.single; - } - - /// The single element that satisfies [test]. - /// - /// Checks elements to see if `test(element)` returns true. - /// If exactly one element satisfies [test], that element is returned. - /// If more than one matching element is found, throws [StateError]. - /// If no matching element is found, returns the result of [orElse]. - /// If [orElse] is omitted, it defaults to throwing a [StateError]. - /// - /// Example: - /// ```dart - /// final numbers = [2, 2, 10]; - /// var result = numbers.singleWhere((element) => element > 5); // 10 - /// ``` - /// When no matching element is found, the result of calling [orElse] is - /// returned instead. - /// ```dart continued - /// result = numbers.singleWhere((element) => element == 1, - /// orElse: () => -1); // -1 - /// ``` - /// There must not be more than one matching element. - /// ```dart continued - /// result = numbers.singleWhere((element) => element == 2); // Throws Error. - /// ``` - @override - E singleWhere(bool Function(E value) test, {E Function()? orElse}) { - value; - return _value.singleWhere(test, orElse: orElse); - } - - /// The first element. - /// - /// Throws a [StateError] if `this` is empty. - /// Otherwise returns the first element in the iteration order, - /// equivalent to `this.elementAt(0)`. - @override - E get first { - value; - return _value.first; - } - - /// The last element. - /// - /// Throws a [StateError] if `this` is empty. - /// Otherwise may iterate through the elements and returns the last one - /// seen. - /// Some iterables may have more efficient ways to find the last element - /// (for example a list can directly access the last element, - /// without iterating through the previous ones). - @override - E get last { - value; - return _value.last; - } - - @override - String toString() => - '''SetSignal<$E>(value: $_value, previousValue: $_previousValue)'''; - - void _notifyChanged() { - _reportChanged(); - _notifySignalUpdate(); - } - - @override - // ignore: overridden_fields - final _id = ReactiveName.nameFor('SetSignal'); -} diff --git a/packages/solidart/lib/src/core/computed.dart b/packages/solidart/lib/src/core/computed.dart deleted file mode 100644 index 41afb00e..00000000 --- a/packages/solidart/lib/src/core/computed.dart +++ /dev/null @@ -1,258 +0,0 @@ -part of 'core.dart'; -// ignore_for_file: unused_element - -/// {@template computed} -/// A special Signal that notifies only whenever the selected -/// values change. -/// -/// You may want to subscribe only to sub-field of a `Signal` value or to -/// combine multiple signal values. -/// ```dart -/// // first name signal -/// final firstName = Signal('Josh'); -/// -/// // last name signal -/// final lastName = Signal('Brown'); -/// -/// // derived signal, updates automatically when firstName or lastName change -/// final fullName = Computed(() => '${firstName()} ${lastName()}'); -/// -/// print(fullName()); // prints Josh Brown -/// -/// // just update the name, the effect above doesn't run because the age has not changed -/// user.update((value) => value.copyWith(name: 'new-name')); -/// -/// // just update the age, the effect above prints -/// user.update((value) => value.copyWith(age: 21)); -/// ``` -/// -/// A derived signal is not of type `Signal` but is a `ReadSignal`. -/// The difference with a normal `Signal` is that a `ReadSignal` doesn't have a -/// value setter, in other words it's a __read-only__ signal. -/// -/// You can also use derived signals in other ways, like here: -/// ```dart -/// final counter = Signal(0); -/// final doubleCounter = Computed(() => counter() * 2); -/// ``` -/// -/// Every time the `counter` signal changes, the doubleCounter updates with the -/// new doubled `counter` value. -/// -/// You can also transform the value type like: -/// ```dart -/// final counter = Signal(0); // counter contains the value type `int` -/// final isGreaterThan5 = Computed(() => counter() > 5); // isGreaterThan5 contains the value type `bool` -/// ``` -/// -/// `isGreaterThan5` will update only when the `counter` value becomes lower/greater than `5`. -/// - If the `counter` value is `0`, `isGreaterThan5` is equal to `false`. -/// - If you update the value to `1`, `isGreaterThan5` doesn't emit a new -/// value, but still contains `false`. -/// - If you update the value to `6`, `isGreaterThan5` emits a new `true` value. -/// {@endtemplate} -class Computed extends ReadSignal { - /// {@macro computed} - Computed( - this.selector, { - - /// {@macro SignalBase.name} - super.name, - - /// {@macro SignalBase.equals} - super.equals, - - /// {@macro SignalBase.autoDispose} - super.autoDispose, - - /// {@macro SignalBase.trackInDevTools} - super.trackInDevTools, - - /// {@macro SignalBase.comparator} - super.comparator = identical, - - /// {@macro SignalBase.trackPreviousValue} - super.trackPreviousValue, - }) { - var runnedOnce = false; - _internalComputed = _AlienComputed( - this, - (previousValue) { - if (trackPreviousValue && previousValue is T) { - _hasPreviousValue = true; - _untrackedPreviousValue = _previousValue = previousValue; - } - - try { - _untrackedValue = selector(); - - if (runnedOnce) { - _notifySignalUpdate(); - } else { - runnedOnce = true; - } - return _untrackedValue; - } catch (e, s) { - throw SolidartCaughtException(e, stackTrace: s); - } - }, - ); - - _notifySignalCreation(); - } - - /// The selector applied - final T Function() selector; - - late final _AlienComputed _internalComputed; - - bool _disposed = false; - - late T _untrackedValue; - - T? _previousValue; - - T? _untrackedPreviousValue; - - // Whether or not there is a previous value - bool _hasPreviousValue = false; - - // Keeps track of all the callbacks passed to [onDispose]. - // Used later to fire each callback when this signal is disposed. - final _onDisposeCallbacks = []; - - // A computed signal is always initialized - @override - bool get hasValue => true; - - final _deps = {}; - - @override - void dispose() { - if (_disposed) return; - - _internalComputed.dispose(); - _disposed = true; - for (final dep in _deps) { - if (dep is _AlienSignal) dep.parent._mayDispose(); - if (dep is _AlienComputed) dep.parent._mayDispose(); - } - - _deps.clear(); - - for (final cb in _onDisposeCallbacks) { - cb(); - } - _onDisposeCallbacks.clear(); - _notifySignalDisposal(); - } - - @override - T get value { - if (_disposed) { - return _untrackedValue; - } - - if ((_internalComputed.flags & system.ReactiveFlags.pending) != - system.ReactiveFlags.none && - _internalComputed.deps == null) { - _internalComputed.flags &= ~system.ReactiveFlags.pending; - } - - final value = _internalComputed.get(); - if (autoDispose) { - _mayDispose(); - } - - return value; - } - - @override - T call() { - return value; - } - - /// Returns the previous value of the computed. - @override - T? get previousValue { - if (!trackPreviousValue) return null; - // cause observation - if (!disposed) value; - return _previousValue; - } - - /// Returns the untracked value of the computed. - @override - T get untrackedValue { - return _untrackedValue; - } - - /// Returns the untracked previous value of the computed. - @override - T? get untrackedPreviousValue { - return _untrackedPreviousValue; - } - - @override - void _mayDispose() { - if (_disposed || !autoDispose) return; - if (_internalComputed.deps == null && _internalComputed.subs == null) { - dispose(); - } else { - _deps.clear(); - - var link = _internalComputed.deps; - for (; link != null; link = link.nextDep) { - final dep = link.dep; - _deps.add(dep); - } - } - } - - @override - bool get disposed => _disposed; - - @override - bool get hasPreviousValue { - if (!trackPreviousValue) return false; - // cause observation - value; - return _hasPreviousValue; - } - - // coverage:ignore-start - @override - int get listenerCount => _deps.length; - // coverage:ignore-end - - @override - void onDispose(VoidCallback cb) { - _onDisposeCallbacks.add(cb); - } - - /// Manually runs the computed to update its value. - /// This is usually not necessary, as the computed will automatically - /// update when its dependencies change. - /// However, in some cases, you may want to force an update. - void run() { - if (_disposed) return; - _internalComputed.didUpdate(); - } - - @override - final _id = ReactiveName.nameFor('Computed'); - - @override - String toString() { - value; - return '''Computed<$T>(value: $untrackedValue, previousValue: $untrackedPreviousValue)'''; - } - - @override - bool _compare(T? oldValue, T? newValue) { - if (equals) { - return oldValue == newValue; - } - return comparator(oldValue, newValue); - } -} diff --git a/packages/solidart/lib/src/core/config.dart b/packages/solidart/lib/src/core/config.dart deleted file mode 100644 index 18275f1b..00000000 --- a/packages/solidart/lib/src/core/config.dart +++ /dev/null @@ -1,66 +0,0 @@ -part of 'core.dart'; - -/// {@template solidart-config} -/// The global configuration of the reactive system. -/// {@endtemplate} -abstract class SolidartConfig { - /// Whether to use the equality operator when updating the signal, defaults to - /// false - static bool equals = false; - - /// Whether to enable the auto disposal of the reactive system, defaults to - /// true. - static bool autoDispose = true; - - /// Whether to enable the DevTools extension, defaults to false. - static bool devToolsEnabled = false; - - /// Whether to track the previous value of the signal, defaults to true. - static bool trackPreviousValue = true; - - /// {@macro Resource.useRefreshing} - static bool useRefreshing = true; - - // coverage:ignore-start - /// Whether to assert that SignalBuilder has at least one dependency during - /// its build. Defaults to true. - /// - /// If you set this to false, you must ensure that the SignalBuilder has at - /// least one dependency, otherwise it won't rebuild when the signals change. - /// - /// The ability to disable this assertion is provided for advanced use cases - /// where you might have a SignalBuilder that builds something based on - /// disposed signals where you might be interested in their latest values. - static bool assertSignalBuilderWithoutDependencies = true; - // coverage:ignore-end - - /// The list of observers. - static final observers = []; - - /// If you want nested effects to have their own independent behavior, you can - /// set this to true so that the Reactive system creates a dependency chain - /// for nested inner effects. Defaults to false. - static bool detachEffects = false; -} - -/// {@template solidart-observer} -/// An object that listens to the changes of the reactive system. -/// -/// This can be used for logging purposes. -/// {@endtemplate} -abstract class SolidartObserver { - // coverage:ignore-start - - /// {@macro solidart-observer} - const SolidartObserver(); - // coverage:ignore-end - - /// A signal has been created. - void didCreateSignal(SignalBase signal); - - /// A signal has been updated. - void didUpdateSignal(SignalBase signal); - - /// A signal has been disposed. - void didDisposeSignal(SignalBase signal); -} diff --git a/packages/solidart/lib/src/core/core.dart b/packages/solidart/lib/src/core/core.dart deleted file mode 100644 index 95e58b02..00000000 --- a/packages/solidart/lib/src/core/core.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:async'; -import 'dart:collection'; -import 'dart:convert'; -import 'dart:developer' as dev; -import 'dart:math'; - -import 'package:collection/collection.dart'; -import 'package:meta/meta.dart'; -import 'package:solidart/deps/preset.dart' as preset; -import 'package:solidart/deps/system.dart' as system; -import 'package:solidart/src/extensions/until.dart'; -import 'package:solidart/src/utils.dart'; - -part 'alien.dart'; -part 'batch.dart'; -part 'collections/list.dart'; -part 'collections/map.dart'; -part 'collections/set.dart'; -part 'computed.dart'; -part 'config.dart'; -part 'devtools.dart'; -part 'effect.dart'; -part 'reactive_system.dart'; -part 'read_signal.dart'; -part 'resource.dart'; -part 'signal.dart'; -part 'signal_base.dart'; -part 'extensions.dart'; -part 'untracked.dart'; diff --git a/packages/solidart/lib/src/core/devtools.dart b/packages/solidart/lib/src/core/devtools.dart deleted file mode 100644 index e676b57e..00000000 --- a/packages/solidart/lib/src/core/devtools.dart +++ /dev/null @@ -1,104 +0,0 @@ -part of 'core.dart'; - -// coverage:ignore-start - -/// The type of the event emitted to the devtools -enum DevToolsEventType { - /// The signal was created - created, - - /// The signal was updated - updated, - - /// The signal was disposed - disposed, -} - -dynamic _toJson(Object? obj) { - try { - return jsonEncode(obj); - } catch (e) { - if (obj is List) { - return obj.map(_toJson).toList().toString(); - } - if (obj is Set) { - return obj.map(_toJson).toList().toString(); - } - if (obj is Map) { - return obj - .map((key, value) => MapEntry(_toJson(key), _toJson(value))) - .toString(); - } - return jsonEncode(obj.toString()); - } -} - -/// Extension for the devtools -extension DevToolsExt on SignalBase { - void _notifySignalCreation() { - for (final obs in SolidartConfig.observers) { - obs.didCreateSignal(this); - } - if (!trackInDevTools) return; - _notifyDevToolsAboutSignal(this, eventType: DevToolsEventType.created); - } - - void _notifySignalUpdate() { - for (final obs in SolidartConfig.observers) { - obs.didUpdateSignal(this); - } - if (!trackInDevTools) return; - _notifyDevToolsAboutSignal(this, eventType: DevToolsEventType.updated); - } - - void _notifySignalDisposal() { - for (final obs in SolidartConfig.observers) { - obs.didDisposeSignal(this); - } - if (!trackInDevTools) return; - _notifyDevToolsAboutSignal(this, eventType: DevToolsEventType.disposed); - } -} - -void _notifyDevToolsAboutSignal( - SignalBase signal, { - required DevToolsEventType eventType, -}) { - if (!SolidartConfig.devToolsEnabled || !signal.trackInDevTools) return; - final eventName = 'ext.solidart.signal.${eventType.name}'; - var value = signal.value; - var previousValue = signal.previousValue; - if (signal is Resource) { - value = signal._value.asReady?.value; - previousValue = signal._previousValue?.asReady?.value; - } - final jsonValue = _toJson(value); - final jsonPreviousValue = _toJson(previousValue); - - dev.postEvent(eventName, { - '_id': signal._id, - 'name': signal.name, - 'value': jsonValue, - 'previousValue': jsonPreviousValue, - 'hasPreviousValue': signal.hasPreviousValue, - 'type': switch (signal) { - Resource() => 'Resource', - ListSignal() => 'ListSignal', - MapSignal() => 'MapSignal', - SetSignal() => 'SetSignal', - Signal() => 'Signal', - Computed() => 'Computed', - ReadableSignal() => 'ReadSignal', - _ => 'Unknown', - }, - 'valueType': value.runtimeType.toString(), - if (signal.hasPreviousValue) - 'previousValueType': previousValue.runtimeType.toString(), - 'disposed': signal.disposed, - 'autoDispose': signal.autoDispose, - 'listenerCount': signal.listenerCount, - 'lastUpdate': DateTime.now().toIso8601String(), - }); -} - -// coverage:ignore-end diff --git a/packages/solidart/lib/src/core/effect.dart b/packages/solidart/lib/src/core/effect.dart deleted file mode 100644 index db6bf45a..00000000 --- a/packages/solidart/lib/src/core/effect.dart +++ /dev/null @@ -1,208 +0,0 @@ -part of 'core.dart'; - -/// Dispose function -typedef DisposeEffect = void Function(); - -/// The reaction interface -abstract class ReactionInterface { - /// Indicate if the reaction is dispose - bool get disposed; - - /// Tries to dispose the effects, if no listeners are present - // ignore: unused_element - void _mayDispose(); - - /// Disposes the reaction - void dispose(); -} - -/// {@template effect} -/// Signals are trackable values, but they are only one half of the equation. -/// To complement those are observers that can be updated by those trackable -/// values. An effect is one such observer; it runs a side effect that depends -/// on signals. -/// -/// An effect can be created by using `Effect`. -/// The effect subscribes automatically to any signal used in the callback and -/// reruns when any of them change. -/// -/// So let's create an `Effect` that reruns whenever `counter` changes: -/// ```dart -/// // sample signal -/// final counter = Signal(0); -/// -/// // effect creation -/// Effect(() { -/// print("The count is now ${counter.value}"); -/// }); -/// // The effect prints `The count is now 0`; -/// -/// // increment the counter -/// counter.value++; -/// -/// // The effect prints `The count is now 1`; -/// ``` -/// -/// The `Effect` method returns a `Dispose` class giving you a more -/// advanced usage: -/// ```dart -/// final dispose = Effect(() { -/// print("The count is now ${counter.value}"); -/// }); -/// ``` -/// -/// Whenever you want to stop the effect from running, you just have to call -/// the returned callback of the `Effect` method: -/// ```dart -/// final disposeEffect = Effect(() { /* your code */ }); -/// // later -/// disposeEffect(); // this will stop the effect from running -/// ``` -/// -/// Any effect runs at least once immediately when is created with the current -/// signals values. -/// -/// > An effect is useless after it is disposed, you must not use it anymore. -/// {@endtemplate} -class Effect extends preset.EffectNode implements ReactionInterface { - /// {@macro effect} - factory Effect( - void Function() callback, { - ErrorCallback? onError, - - /// The name of the effect, useful for logging - String? name, - - /// Whether to automatically dispose the effect (defaults to true). - /// - /// This happens automatically when all the tracked dependencies are - /// disposed. - bool? autoDispose, - - /// Detach effect, default value is [SolidartConfig.detachEffects] - bool? detach, - - /// Whether to automatically run the effect (defaults to true). - bool? autorun, - }) { - late Effect effect; - - try { - final effectiveName = name ?? ReactiveName.nameFor('Effect'); - final effectiveAutoDispose = autoDispose ?? SolidartConfig.autoDispose; - - return effect = Effect._internal( - callback: callback, - onError: onError, - name: effectiveName, - autoDispose: effectiveAutoDispose, - detach: detach, - ); - } finally { - if (autorun ?? true) effect.run(); - } - } - - /// {@macro effect} - Effect._internal({ - required VoidCallback callback, - required this.name, - required this.autoDispose, - ErrorCallback? onError, - bool? detach, - }) : detach = detach ?? SolidartConfig.detachEffects, - super( - fn: () { - try { - callback(); - } catch (e, s) { - if (onError != null) { - onError(SolidartCaughtException(e, stackTrace: s)); - return; - } - rethrow; - } - }, - flags: system.ReactiveFlags.watching | system.ReactiveFlags.dirty, - ); - - /// The name of the effect, useful for logging purposes. - final String name; - - /// Whether to automatically dispose the effect (defaults to true). - final bool autoDispose; - - /// Whether this effect is detached from parent subscribers. - bool get isDetached => detach; - - bool _disposed = false; - - /// Whether the effect should detach from parent subscribers. - final bool detach; - - final _deps = {}; - - /// The subscriber of the effect, do not use it directly. - @protected - system.ReactiveNode get subscriber => this; - - @override - bool get disposed => _disposed; - - /// Runs the effect, tracking any signal read during the execution. - void run() { - final currentSub = preset.getActiveSub(); - if (!SolidartConfig.detachEffects && - currentSub != null && - (currentSub is! preset.EffectNode || - !(detach || (currentSub is Effect && currentSub.detach)))) { - preset.link(this, currentSub, preset.cycle); - } - - try { - preset.run(this); - } finally { - if (SolidartConfig.autoDispose) { - _mayDispose(); - } - } - } - - /// Sets the dependencies of the effect, do not use it directly. - @internal - void setDependencies(system.ReactiveNode node) { - _deps - ..clear() - ..addAll(node.getDependencies()); - } - - /// Invalidates the effect. - /// - /// After this operation the effect is useless. - void call() => dispose(); - - /// Invalidates the effect. - /// - /// After this operation the effect is useless. - @override - void dispose() { - if (_disposed) return; - _disposed = true; - - final dependencies = {...subscriber.getDependencies(), ..._deps}; - preset.stop(this); - subscriber.mayDisposeDependencies(dependencies); - } - - @override - void _mayDispose() { - if (_disposed) return; - - if (SolidartConfig.autoDispose) { - if (!autoDispose || _disposed) return; - if (subscriber.deps?.dep == null) { - dispose(); - } - } - } -} diff --git a/packages/solidart/lib/src/core/extensions.dart b/packages/solidart/lib/src/core/extensions.dart deleted file mode 100644 index 9081e537..00000000 --- a/packages/solidart/lib/src/core/extensions.dart +++ /dev/null @@ -1,34 +0,0 @@ -part of 'core.dart'; - -/// Adds the [toggle] method to boolean signals -extension ToggleBoolSignal on Signal { - /// Toggles the signal boolean value. - void toggle() => value = !value; -} - -/// A callback that is fired when the signal value changes -extension ObserveSignal on SignalBase { - /// Observe the signal and trigger the [listener] every time the value changes - DisposeObservation observe( - ObserveCallback listener, { - bool fireImmediately = false, - }) { - var skipped = false; - final disposeEffect = Effect(() { - // Tracks the value - value; - if (!fireImmediately && !skipped) { - skipped = true; - return; - } - untracked(() { - listener(untrackedPreviousValue, untrackedValue); - }); - }); - - return () { - disposeEffect(); - _mayDispose(); - }; - } -} diff --git a/packages/solidart/lib/src/core/reactive_system.dart b/packages/solidart/lib/src/core/reactive_system.dart deleted file mode 100644 index b6013ebd..00000000 --- a/packages/solidart/lib/src/core/reactive_system.dart +++ /dev/null @@ -1,44 +0,0 @@ -// ignore_for_file: public_member_api_docs -// -// Reactive flags map: https://github.com/medz/alien-signals-dart/blob/main/flags.md -part of 'core.dart'; - -extension MayDisposeDependencies on system.ReactiveNode { - Iterable getDependencies() { - var link = deps; - final foundDeps = {}; - for (; link != null; link = link.nextDep) { - foundDeps.add(link.dep); - } - return foundDeps; - } - - void mayDisposeDependencies([Iterable? include]) { - final dependencies = {...getDependencies(), ...?include}; - for (final dep in dependencies) { - switch (dep) { - case _AlienSignal(): - dep.parent._mayDispose(); - case _AlienComputed(): - dep.parent._mayDispose(); - } - } - } -} - -typedef ReactionErrorHandler = - void Function(Object error, ReactionInterface reaction); - -class ReactiveName { - ReactiveName._internal(); - static final _instance = ReactiveName._internal(); - - int nextIdCounter = 0; - - int get nextId => ++nextIdCounter; - - static String nameFor(String prefix) { - assert(prefix.isNotEmpty, 'the prefix cannot be empty'); - return '$prefix@${_instance.nextId}'; - } -} diff --git a/packages/solidart/lib/src/core/read_signal.dart b/packages/solidart/lib/src/core/read_signal.dart deleted file mode 100644 index ac960c77..00000000 --- a/packages/solidart/lib/src/core/read_signal.dart +++ /dev/null @@ -1,365 +0,0 @@ -part of 'core.dart'; - -/// {@macro readsignal} -abstract class ReadSignal extends SignalBase { - /// {@macro readsignal} - ReadSignal({ - required super.name, - super.comparator, - super.equals, - super.autoDispose, - super.trackInDevTools, - super.trackPreviousValue, - }); -} - -/// {@template readsignal} -/// A read-only [Signal]. -/// -/// When you don't need to expose the setter of a [Signal], -/// you should consider transforming it in a [ReadSignal] -/// using the `toReadSignal` method. -/// -/// All derived-signals are [ReadableSignal]s because they depend -/// on the value of a [Signal]. -/// {@endtemplate} -class ReadableSignal implements ReadSignal { - /// {@macro readsignal} - ReadableSignal( - T initialValue, { - - /// {@macro SignalBase.name} - this.name, - - /// {@macro SignalBase.equals} - bool? equals, - - /// {@macro SignalBase.autoDispose} - bool? autoDispose, - - /// {@macro SignalBase.trackInDevTools} - bool? trackInDevTools, - - /// {@macro SignalBase.comparator} - this.comparator = identical, - - /// {@macro SignalBase.trackPreviousValue} - bool? trackPreviousValue, - }) : _hasValue = true, - trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, - autoDispose = autoDispose ?? SolidartConfig.autoDispose, - equals = equals ?? SolidartConfig.equals, - trackPreviousValue = - trackPreviousValue ?? SolidartConfig.trackPreviousValue { - _internalSignal = _AlienSignal(this, Some(initialValue)); - _untrackedValue = initialValue; - _notifySignalCreation(); - } - - /// {@macro readsignal} - ReadableSignal.lazy({ - /// {@macro SignalBase.name} - this.name, - - /// {@macro SignalBase.equals} - bool? equals, - - /// {@macro SignalBase.autoDispose} - bool? autoDispose, - - /// {@macro SignalBase.trackInDevTools} - bool? trackInDevTools, - - /// {@macro SignalBase.comparator} - this.comparator = identical, - - /// {@macro SignalBase.trackPreviousValue} - bool? trackPreviousValue, - }) : _hasValue = false, - trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, - autoDispose = autoDispose ?? SolidartConfig.autoDispose, - equals = equals ?? SolidartConfig.equals, - // coverage:ignore-start - // coverage:ignore-end - trackPreviousValue = - trackPreviousValue ?? SolidartConfig.trackPreviousValue { - _internalSignal = _AlienSignal(this, None()); - } - - /// {@macro SignalBase.name} - @override - final String? name; - - /// {@macro SignalBase.equals} - @override - final bool equals; - - /// {@macro SignalBase.autoDispose} - @override - final bool autoDispose; - - /// {@macro SignalBase.trackInDevTools} - @override - final bool trackInDevTools; - - /// {@macro SignalBase.trackPreviousValue} - @override - final bool trackPreviousValue; - - /// {@macro SignalBase.comparator} - @override - final ValueComparator comparator; - - /// Tracks the internal value - late final _AlienSignal _internalSignal; - - @override - final _id = ReactiveName.nameFor('ReadSignal'); - - @override - bool get hasValue { - _internalSignal.get(); - return _hasValue; - } - - bool _hasValue = false; - - // Tracks the internal previous value - T? _previousValue; - - // Whether or not there is a previous value - bool _hasPreviousValue = false; - - T get _value { - if (_disposed) { - return untracked( - () => _internalSignal.get().unwrap(), - ); - } - final value = _internalSignal.get().unwrap(); - - if (autoDispose) { - _subs.clear(); - - var link = _internalSignal.subs; - for (; link != null; link = link.nextSub) { - _subs.add(link.sub); - } - } - return value; - } - - late T _untrackedValue; - - T? _untrackedPreviousValue; - - /// Returns the untracked previous value of the signal. - @override - T? get untrackedPreviousValue { - return _untrackedPreviousValue; - } - - /// Returns the value without triggering the reactive system. - @override - T get untrackedValue { - if (!_hasValue) { - throw StateError( - '''The signal named "$name" is lazy and has not been initialized yet, cannot access its value''', - ); - } - return _untrackedValue; - } - - set _value(T newValue) { - _untrackedPreviousValue = _untrackedValue; - _untrackedValue = newValue; - _internalSignal.set(Some(newValue)); - } - - @override - T get value { - if (!_hasValue) { - throw StateError( - '''The signal named "$name" is lazy and has not been initialized yet, cannot access its value''', - ); - } - return _value; - } - - @override - T call() { - return value; - } - - /// {@template set-signal-value} - /// Sets the current signal value with [newValue]. - /// - /// This operation may be skipped if the value is equal to the previous one, - /// check [equals] and [comparator]. - /// {@endtemplate} - @protected - T setValue(T newValue) { - final firstValue = !_hasValue; - - if (firstValue) { - _untrackedValue = newValue; - _hasValue = true; - } - - // skip if the values are equal - if (!firstValue && _compare(_untrackedValue, newValue)) { - return newValue; - } - - // store the previous value - if (!firstValue) _setPreviousValue(_untrackedValue); - - // notify with the new value - _value = newValue; - - if (firstValue) { - _notifySignalCreation(); - } else { - _notifySignalUpdate(); - } - return newValue; - } - - @override - bool get hasPreviousValue { - if (!trackPreviousValue) return false; - // cause observation - value; - return _hasPreviousValue; - } - - /// The previous value, if any. - @override - T? get previousValue { - if (!trackPreviousValue) return null; - // cause observation - value; - return _previousValue; - } - - /// Sets the previous signal value to [value]. - void _setPreviousValue(T value) { - if (!trackPreviousValue) return; - _previousValue = value; - _hasPreviousValue = true; - } - - bool _disposed = false; - - // Keeps track of all the callbacks passed to [onDispose]. - // Used later to fire each callback when this signal is disposed. - final _onDisposeCallbacks = []; - - /// Returns the number of listeners listening to this signal. - @override - int get listenerCount => _subs.length; - - final _subs = {}; - - @override - void dispose() { - // ignore if already disposed - if (_disposed) return; - _disposed = true; - - // This will dispose the signal - untracked(() { - _internalSignal.get(); - }); - - if (SolidartConfig.autoDispose) { - for (final sub in _subs) { - if (sub is Effect) { - sub.dispose(); - continue; - } - if (sub is preset.EffectNode) { - if (sub.deps?.dep == _internalSignal) { - sub.deps = null; - } - if (sub.depsTail?.dep == _internalSignal) { - sub.depsTail = null; - } - // coverage:ignore-start - sub.mayDisposeDependencies(); - preset.stop(sub); - // coverage:ignore-end - } - if (sub is _AlienComputed) { - // coverage:ignore-start - if (sub.deps?.dep == _internalSignal) { - sub.deps = null; - } - if (sub.depsTail?.dep == _internalSignal) { - sub.depsTail = null; - } - // coverage:ignore-end - sub.parent._mayDispose(); - } - } - _subs.clear(); - } - - for (final cb in _onDisposeCallbacks) { - cb(); - } - _onDisposeCallbacks.clear(); - _notifySignalDisposal(); - } - - @override - bool get disposed => _disposed; - - @override - void _mayDispose() { - if (!autoDispose || _disposed) return; - if (_internalSignal.subs == null) dispose(); - } - - @override - void onDispose(VoidCallback cb) { - _onDisposeCallbacks.add(cb); - } - - /// Forces a change notification even when the value - /// hasn't substantially changed. - /// - /// This should only be used when you need to force - /// trigger reactions despite no - /// actual value change. For normal value updates, - // ignore: comment_references - /// use [reactiveSystem.setSignalValue] instead. - void _reportChanged() { - _internalSignal.set(Some(_untrackedValue)); - } - - /// Indicates if the signal should update its value. - bool shouldUpdate() { - if ((_internalSignal.flags & system.ReactiveFlags.dirty) == - system.ReactiveFlags.none) { - return false; - } - return _internalSignal.didUpdate(); - } - - @override - String toString() => - '''ReadSignal<$T>(value: $_value, previousValue: $_previousValue)'''; - - /// Indicates if the [oldValue] and the [newValue] are equal - @override - bool _compare(T? oldValue, T? newValue) { - // skip if the value are equals - if (equals) { - return oldValue == newValue; - } - - // return the [comparator] result - return comparator(oldValue, newValue); - } -} diff --git a/packages/solidart/lib/src/core/resource.dart b/packages/solidart/lib/src/core/resource.dart deleted file mode 100644 index d0a84c91..00000000 --- a/packages/solidart/lib/src/core/resource.dart +++ /dev/null @@ -1,817 +0,0 @@ -part of 'core.dart'; - -/// {@template FutureOrThenExtension} -/// Extension to add a `then` method to `FutureOr`. -/// This is used internally to handle both `Future` and synchronous values -/// uniformly. -/// {@endtemplate} -extension FutureOrThenExtension on FutureOr { - /// Extension method to add a `then` method to `FutureOr`. - FutureOr then( - FutureOr Function(T value) onValue, { - Function? onError, - }) { - final v = this; - if (v is Future) { - return v.then(onValue, onError: onError); - } else { - return onValue(v); - } - } -} - -/// {@template resource} -/// `Resources` are special `Signal`s designed specifically to handle Async -/// loading. Their purpose is wrap async values in a way that makes them easy -/// to interact with handling the common states of a future __data__, __error__ -/// and __loading__. -/// -/// Resources can be driven by a `source` signal that provides the query to an -/// async data `fetcher` function that returns a `Future` or to a `stream` that -/// is listened again when the source changes. -/// -/// The contents of the `fetcher` function can be anything. You can hit typical -/// REST endpoints or GraphQL or anything that generates a future. Resources -/// are not opinionated on the means of loading the data, only that they are -/// driven by an async operation. -/// -/// Let's create a Resource: -/// -/// ```dart -/// // Using http as a client -/// import 'package:http/http.dart' as http; -/// -/// // The source -/// final userId = Signal(1); -/// -/// // The fetcher -/// Future fetchUser() async { -/// final response = await http.get( -/// Uri.parse('https://jsonplaceholder.typicode.com/users/${userId.value}/'), -/// headers: {'Accept': 'application/json'}, -/// ); -/// return response.body; -/// } -/// -/// // The resource (source is optional) -/// final user = Resource(fetchUser, source: userId); -/// ``` -/// -/// A Resource can also be driven from a [stream] instead of a Future. -/// In this case you just need to pass the `stream` field to the -/// `Resource.stream` constructor. -/// -/// The resource has a [state] named [ResourceState], that provides many useful -/// convenience methods to correctly handle the state of the resource. -/// -/// The `when` method forces you to handle all the states of a Resource -/// (_ready_, _error_ and _loading_). -/// The are also other convenience methods to handle only specific states: -/// - `when` forces you to handle all the states of a Resource -/// - `maybeWhen` lets you decide which states to handle and provide an `orElse` -/// action for unhandled states -/// - `map` equal to `on` but gives access to the `ResourceState` data class -/// - `maybeMap` equal to `maybeMap` but gives access to the `ResourceState` -/// data class -/// - `isReady` indicates if the `Resource` is in the ready state -/// - `isLoading` indicates if the `Resource` is in the loading state -/// - `hasError` indicates if the `Resource` is in the error state -/// - `asReady` upcast `ResourceState` into a `ResourceReady`, or return null if the `ResourceState` is in loading/error state -/// - `asError` upcast `ResourceState` into a `ResourceError`, or return null if the `ResourceState` is in loading/ready state -/// - `value` attempts to synchronously get the value of `ResourceReady` -/// - `error` attempts to synchronously get the error of `ResourceError` -/// -/// A `Resource` provides the `resolve` and `refresh` methods. -/// -/// The `resolve` method must be called only once for the lifecycle of the -/// resource. -/// If runs the `fetcher` for the first time and then it listen to the -/// [source], if provided. -/// If you're passing a [stream] it subscribes to it, and every time the source -/// changes, it resubscribes again. -/// -/// The `refresh` method forces an update and calls the `fetcher` function -/// again or subscribes againg to the [stream]. -/// {@endtemplate} -class Resource extends Signal> { - /// {@macro resource} - Resource( - this.fetcher, { - this.source, - - /// {@macro SignalBase.name} - super.name, - - /// {@macro SignalBase.equals} - super.equals, - - /// {@macro SignalBase.autoDispose} - super.autoDispose, - - /// {@macro SignalBase.trackInDevTools} - super.trackInDevTools, - - /// Indicates whether the resource should be computed lazily, defaults to - /// true. - this.lazy = true, - - /// {@macro Resource.useRefreshing} - bool? useRefreshing, - - /// Whether to track the previous state of the resource, defaults to true. - bool? trackPreviousState, - - /// The debounce delay when the source changes, optional. - this.debounceDelay, - }) : useRefreshing = useRefreshing ?? SolidartConfig.useRefreshing, - stream = null, - super( - ResourceState.loading(), - trackPreviousValue: - trackPreviousState ?? SolidartConfig.trackPreviousValue, - comparator: identical, - ) { - // resolve the resource immediately if not lazy - if (!lazy) _resolveIfNeeded(); - } - - /// {@macro resource} - Resource.stream( - this.stream, { - this.source, - - /// {@macro SignalBase.name} - super.name, - - /// {@macro SignalBase.equals} - super.equals, - - /// {@macro SignalBase.autoDispose} - super.autoDispose, - - /// {@macro SignalBase.trackInDevTools} - super.trackInDevTools, - - /// Indicates whether the resource should be computed lazily, defaults to - /// true. - this.lazy = true, - - /// {@macro Resource.useRefreshing} - bool? useRefreshing, - - /// Whether to track the previous state of the resource, defaults to true. - bool? trackPreviousState, - this.debounceDelay, - }) : useRefreshing = useRefreshing ?? SolidartConfig.useRefreshing, - fetcher = null, - super( - ResourceState.loading(), - trackPreviousValue: - trackPreviousState ?? SolidartConfig.trackPreviousValue, - comparator: identical, - ) { - // resolve the resource immediately if not lazy - if (!lazy) _resolveIfNeeded(); - } - - /// Indicates whether the resource should be computed lazily, defaults to true - final bool lazy; - - /// Reactive signal values passed to the fetcher, optional. - final SignalBase? source; - - /// The asynchrounous function used to retrieve data. - final Future Function()? fetcher; - - /// The stream used to retrieve data. - final Stream Function()? stream; - - /// The debounce delay when the source changes, optional. - final Duration? debounceDelay; - - StreamSubscription? _streamSubscription; - - // The source dispose observation - DisposeObservation? _sourceDisposeObservation; - - /// Indicates if the resource has been resolved - bool _resolved = false; - - /// {@template Resource.useRefreshing} - /// Whether to use `isRefreshing` in the current state of the resource, - /// defaults to true. - /// - /// If you set to false, the state will always transition to `loading` when - /// refreshing. - /// {@endtemplate} - final bool useRefreshing; - - @override - // ignore: overridden_fields - final _id = ReactiveName.nameFor('Resource'); - - /// The current resource state - ResourceState get state { - _resolveIfNeeded(); - return super.value; - } - - /// The current resource state - @override - ResourceState call() => state; - - /// Updates the current resource state - set state(ResourceState state) => super.value = state; - - // coverage:ignore-start - @Deprecated('Use state instead') - @override - ResourceState get value => state; - - @Deprecated('Use state instead') - @override - set value(ResourceState value) => state = value; - - @Deprecated('Use previousState instead') - @override - ResourceState? get previousValue => previousState; - - @Deprecated('Use untrackedState instead') - @override - ResourceState get untrackedValue => untrackedState; - - @Deprecated('Use untrackedPreviousState instead') - @override - ResourceState? get untrackedPreviousValue => untrackedPreviousState; - - @Deprecated('Use update instead') - @override - ResourceState updateValue( - ResourceState Function(ResourceState state) callback, - ) => update(callback); - // coverage:ignore-end - - /// The previous resource state - ResourceState? get previousState { - _resolveIfNeeded(); - if (!_resolved) return null; - return super.previousValue; - } - - /// The previous resource state, without tracking - ResourceState? get untrackedPreviousState => super.untrackedPreviousValue; - - /// The resource state without tracking - ResourceState get untrackedState => super.untrackedValue; - - // The stream trasformed in a broadcast stream, if needed - Stream get _stream { - final s = stream!(); - if (!_broadcastStreams.keys.contains(s)) { - _broadcastStreams[s] = s.isBroadcast - ? s - : s.asBroadcastStream( - onListen: (subscription) { - if (!_streamSubscriptions.contains(subscription)) { - _streamSubscriptions.add(subscription); - } - subscription.resume(); - }, - onCancel: (subscription) { - subscription.pause(); - }, - ); - } - return _broadcastStreams[s]!; - } - - final _broadcastStreams = , Stream>{}; - final _streamSubscriptions = >[]; - - /// Resolves the [Resource]. - /// - /// If you provided a [fetcher], it run the async call. - /// Otherwise it starts listening to the [stream]. - /// - /// Then will subscribe to the [source], if provided. - /// - /// This method must be called once during the life cycle of the resource. - Future _resolve() async { - assert( - !_resolved, - """The resource has been already resolved, you can't resolve it more than once. Use `refresh()` instead if you want to refresh the value.""", - ); - _resolved = true; - if (fetcher != null) { - // start fetching - await _fetch(); - } - // React the the [stream], if provided - if (stream != null) { - _subscribe(); - } - - // react to the [source], if provided. - if (source != null) { - _sourceDisposeObservation = source!.observe((p, v) { - if (debounceDelay != null) { - Debouncer.debounce( - source!._id, - debounceDelay!, - refresh, - ); - } else { - refresh(); - } - }); - source!.onDispose(_sourceDisposeObservation!); - } - } - - /// Resolves the resource, if needed - void _resolveIfNeeded() { - if (!_resolved) _resolve(); - } - - /// Runs the [fetcher] for the first time. - Future _fetch() async { - assert(fetcher != null, 'You are trying to fetch, but fetcher is null'); - try { - final result = await fetcher!(); - state = ResourceState.ready(result); - } catch (e, s) { - state = ResourceState.error(e, stackTrace: s); - } - } - - /// Subscribes to the provided [stream] for the first time. - void _subscribe() { - assert( - stream != null, - 'You are trying to listen to a stream, but stream is null', - ); - _listenStream(); - } - - /// Listens to the stream - void _listenStream() { - _streamSubscription = _stream.listen( - (data) { - state = ResourceState.ready(data); - }, - onError: (Object error, StackTrace stackTrace) { - state = ResourceState.error(error, stackTrace: stackTrace); - }, - ); - } - - /// Forces a refresh of the [fetcher] or the [stream]. - /// - /// In case of the [stream], cancels the previous subscription and - /// resubscribes. - Future refresh() async { - if (fetcher != null) { - return _refetch(); - } - return _resubscribe(); - } - - /// Resubscribes to the [stream]. - /// - /// Cancels the previous subscription and resubscribes. - void _resubscribe() { - assert( - stream != null, - 'You are trying to listen to a stream, but stream is null', - ); - _streamSubscription?.cancel(); - _streamSubscription = null; - _transition(); - _listenStream(); - } - - // Transitions to the refreshing state, if enabled, otherwise sets the state - // to loading. - void _transition() { - if (useRefreshing) { - state.map( - ready: (ready) { - state = ready.copyWith(isRefreshing: true); - }, - error: (error) { - state = error.copyWith(isRefreshing: true); - }, - loading: (_) { - state = ResourceState.loading(); - }, - ); - } else { - state = ResourceState.loading(); - } - } - - /// Force a refresh of the [fetcher]. - Future _refetch() async { - assert(fetcher != null, 'You are trying to refetch, but fetcher is null'); - try { - _transition(); - final result = await fetcher!(); - state = ResourceState.ready(result); - } catch (e, s) { - state = ResourceState.error(e, stackTrace: s); - } - } - - /// Returns a future that completes with the value when the Resource is ready - /// If the resource is already ready, it completes immediately. - FutureOr untilReady() async { - final state = await until((value) => value.isReady); - return state.asReady!.value; - } - - /// Calls a function with the current [state] and assigns the result as the - /// new state - ResourceState update( - ResourceState Function(ResourceState state) callback, - ) => state = callback(_value); - - @override - void dispose() { - _streamSubscription?.cancel(); - _streamSubscription = null; - for (final sub in _streamSubscriptions) { - sub.cancel(); - } - _streamSubscriptions.clear(); - - _sourceDisposeObservation?.call(); - _broadcastStreams.clear(); - _streamSubscriptions.clear(); - // Dispose the source, if needed - if (source != null) { - if (source!.autoDispose && source!.listenerCount == 0) { - source!.dispose(); - } - } - super.dispose(); - } - - @override - String toString() => - '''Resource<$T>(state: $_value, previousState: $_previousValue)'''; -} - -/// Manages all the different states of a [Resource]: -/// - ResourceReady -/// - ResourceLoading -/// - ResourceError -@sealed -@immutable -sealed class ResourceState { - /// Creates an [ResourceState] with a data. - /// - /// The data can be `null`. - const factory ResourceState.ready(T data, {bool isRefreshing}) = - ResourceReady; - - /// Creates an [ResourceState] in loading state. - /// - /// Prefer always using this constructor with the `const` keyword. - // coverage:ignore-start - const factory ResourceState.loading() = ResourceLoading; - // coverage:ignore-end - - /// Creates an [ResourceState] in error state. - /// - /// The parameter [error] cannot be `null`. - // coverage:ignore-start - const factory ResourceState.error( - Object error, { - StackTrace? stackTrace, - bool isRefreshing, - }) = ResourceError; - // coverage:ignore-end - - /// private mapper, so that classes inheriting Resource can specify their own - /// `map` method with different parameters. - // coverage:ignore-start - R map({ - required R Function(ResourceReady ready) ready, - required R Function(ResourceError error) error, - required R Function(ResourceLoading loading) loading, - }); - // coverage:ignore-end -} - -/// Creates an [ResourceState] in ready state with a data. -@immutable -class ResourceReady implements ResourceState { - /// Creates an [ResourceState] with a data. - const ResourceReady(this.value, {this.isRefreshing = false}); - - /// The value currently exposed. - final T value; - - /// Indicates if the data is being refreshed, defaults to false. - final bool isRefreshing; - - // coverage:ignore-start - @override - R map({ - required R Function(ResourceReady ready) ready, - required R Function(ResourceError error) error, - required R Function(ResourceLoading loading) loading, - }) { - return ready(this); - } - - @override - String toString() { - return 'ResourceReady<$T>(value: $value, refreshing: $isRefreshing)'; - } - - @override - bool operator ==(Object other) { - return runtimeType == other.runtimeType && - other is ResourceReady && - other.value == value && - other.isRefreshing == isRefreshing; - } - - @override - int get hashCode => Object.hash(runtimeType, value, isRefreshing); - - /// Convenience method to update the values of a [ResourceReady]. - ResourceReady copyWith({ - T? value, - bool? isRefreshing, - }) { - return ResourceReady( - value ?? this.value, - isRefreshing: isRefreshing ?? this.isRefreshing, - ); - } - - // coverage:ignore-end -} - -/// {@template resourceloading} -/// Creates an [ResourceState] in loading state. -/// -/// Prefer always using this constructor with the `const` keyword. -/// {@endtemplate} -@immutable -class ResourceLoading implements ResourceState { - /// {@macro resourceloading} - const ResourceLoading(); - - // coverage:ignore-start - @override - R map({ - required R Function(ResourceReady ready) ready, - required R Function(ResourceError error) error, - required R Function(ResourceLoading loading) loading, - }) { - return loading(this); - } - - @override - String toString() { - return 'ResourceLoading<$T>()'; - } - - @override - bool operator ==(Object other) { - return runtimeType == other.runtimeType; - } - - @override - int get hashCode => runtimeType.hashCode; - // coverage:ignore-end -} - -/// {@template resourceerror} -/// Creates an [ResourceState] in error state. -/// -/// The parameter [error] cannot be `null`. -/// {@endtemplate} -@immutable -class ResourceError implements ResourceState { - /// {@macro resourceerror} - const ResourceError( - this.error, { - this.stackTrace, - this.isRefreshing = false, - }); - - /// The error. - final Object error; - - /// The stackTrace of [error], optional. - final StackTrace? stackTrace; - - /// Indicates if the data is being refreshed, defaults to false. - final bool isRefreshing; - - // coverage:ignore-start - @override - R map({ - required R Function(ResourceReady ready) ready, - required R Function(ResourceError error) error, - required R Function(ResourceLoading loading) loading, - }) { - return error(this); - } - - @override - String toString() { - return 'ResourceError<$T>(error: $error, stackTrace: $stackTrace, ' - 'refreshing: $isRefreshing)'; - } - - @override - bool operator ==(Object other) { - return runtimeType == other.runtimeType && - other is ResourceError && - other.error == error && - other.stackTrace == stackTrace && - other.isRefreshing == isRefreshing; - } - - @override - int get hashCode => Object.hash(runtimeType, error, stackTrace, isRefreshing); - - /// Convenience method to update the [isRefreshing] value of a [Resource] - ResourceError copyWith({ - Object? error, - StackTrace? stackTrace, - bool? isRefreshing, - }) { - return ResourceError( - error ?? this.error, - stackTrace: stackTrace ?? this.stackTrace, - isRefreshing: isRefreshing ?? this.isRefreshing, - ); - } - - // coverage:ignore-end -} - -/// Some useful extension available on any [ResourceState]. -// coverage:ignore-start -extension ResourceExtensions on ResourceState { - /// Indicates if the resource is loading. - bool get isLoading => this is ResourceLoading; - - /// Indicates if the resource has an error. - bool get hasError => this is ResourceError; - - /// Indicates if the resource is ready. - bool get isReady => this is ResourceReady; - - /// Indicates if the resource is refreshing. Loading is not considered as - /// refreshing. - bool get isRefreshing => switch (this) { - ResourceReady(:final isRefreshing) => isRefreshing, - ResourceError(:final isRefreshing) => isRefreshing, - ResourceLoading() => false, - }; - - /// Upcast [ResourceState] into a [ResourceReady], or return null if the - /// [ResourceState] is in loading/error state. - ResourceReady? get asReady { - return map( - ready: (r) => r, - error: (_) => null, - loading: (_) => null, - ); - } - - /// Upcast [ResourceState] into a [ResourceError], or return null if the - /// [ResourceState] is in ready/loading state. - ResourceError? get asError { - return map( - error: (e) => e, - ready: (_) => null, - loading: (_) => null, - ); - } - - /// Attempts to synchronously get the value of [ResourceReady]. - /// - /// On error, this will rethrow the error. - /// If loading, will return `null`. - T? get value { - return map( - ready: (r) => r.value, - // ignore: only_throw_errors - error: (r) => throw r.error, - loading: (_) => null, - ); - } - - /// Attempts to synchronously get the error of [ResourceError]. - /// - /// On other states will return `null`. - Object? get error { - return map( - error: (r) => r.error, - ready: (_) => null, - loading: (_) => null, - ); - } - - /// Perform some actions based on the state of the [ResourceState], or call - /// orElse if the current state is not considered. - R maybeMap({ - required R Function() orElse, - R Function(ResourceReady ready)? ready, - R Function(ResourceError error)? error, - R Function(ResourceLoading loading)? loading, - }) { - return map( - ready: (r) { - if (ready != null) return ready(r); - return orElse(); - }, - error: (d) { - if (error != null) return error(d); - return orElse(); - }, - loading: (l) { - if (loading != null) return loading(l); - return orElse(); - }, - ); - } - - /// Performs an action based on the state of the [ResourceState]. - /// - /// All cases are required. - @Deprecated('Use when instead') - R on({ - required R Function(T data) ready, - required R Function(Object error, StackTrace? stackTrace) error, - required R Function() loading, - }) { - return when(ready: ready, error: error, loading: loading); - } - - /// Performs an action based on the state of the [ResourceState]. - /// - /// All cases are required. - R when({ - required R Function(T data) ready, - required R Function(Object error, StackTrace? stackTrace) error, - required R Function() loading, - }) { - return map( - ready: (r) => ready(r.value), - error: (e) => error(e.error, e.stackTrace), - loading: (l) => loading(), - ); - } - - /// Performs an action based on the state of the [ResourceState], or call - /// [orElse] if the current state is not considered. - @Deprecated('Use maybeWhen instead') - R maybeOn({ - required R Function() orElse, - R Function(T data)? ready, - R Function(Object error, StackTrace? stackTrace)? error, - R Function()? loading, - }) { - return maybeWhen( - orElse: orElse, - ready: ready, - error: error, - loading: loading, - ); - } - - /// Performs an action based on the state of the [ResourceState], or call - /// [orElse] if the current state is not considered. - R maybeWhen({ - required R Function() orElse, - R Function(T data)? ready, - R Function(Object error, StackTrace? stackTrace)? error, - R Function()? loading, - }) { - return map( - ready: (r) { - if (ready != null) return ready(r.value); - return orElse(); - }, - error: (e) { - if (error != null) return error(e.error, e.stackTrace); - return orElse(); - }, - loading: (l) { - if (loading != null) return loading(); - return orElse(); - }, - ); - } -} - -// coverage:ignore-end diff --git a/packages/solidart/lib/src/core/signal.dart b/packages/solidart/lib/src/core/signal.dart deleted file mode 100644 index 228b7892..00000000 --- a/packages/solidart/lib/src/core/signal.dart +++ /dev/null @@ -1,172 +0,0 @@ -part of 'core.dart'; - -/// {@template signal} -/// # Signals -/// Signals are the cornerstone of reactivity in `solidart`. They contain -/// values that change over time; when you change a signal's value, it -/// automatically updates anything that uses it. -/// -/// Create the signal with: -/// -/// ```dart -/// final counter = Signal(0); -/// ```` -/// -/// The argument passed to the create call is the initial value, and the return -/// value is the signal. -/// -/// To retrieve the current signal value use: -/// ```dart -/// counter.value; // 0 -/// // or -/// counter(); // 0 -/// ``` -/// -/// To update the current signal value you can use: -/// ```dart -/// counter.value++; // increase by 1 -/// // or -/// counter.set(2); // sets the value to 2 -/// // or -/// counter.value = 5; // sets the value to 5 -/// // or -/// counter.update((v) => v * 2); // update based on the current value -/// ``` - -/// ## Derived Signals -/// -/// You may want to subscribe only to a sub-field of a `Signal` value. -/// ```dart -/// // sample User class -/// class User { -/// const User({ -/// required this.name, -/// required this.age, -/// }); -/// -/// final String name; -/// final int age; -/// -/// User copyWith({ -/// String? name, -/// int? age, -/// }) { -/// return User( -/// name: name ?? this.name, -/// age: age ?? this.age, -/// ); -/// } -/// } -/// -/// // create a user signal -/// final user = Signal(const User(name: "name", age: 20)); -/// -/// // create a derived signal just for the age -/// final age = Computed(() => user().age); -/// -/// // adding an effect to print the age -/// Effect((_) { -/// print('age changed from ${age.previousValue} into ${age.value}'); -/// }); -/// -/// // just update the name, the effect above doesn't run because the age has not changed -/// user.update((value) => value.copyWith(name: 'new-name')); -/// -/// // just update the age, the effect above prints -/// user.update((value) => value.copyWith(age: 21)); -/// ``` -/// -/// A derived signal is not of type `Signal` but is a `ReadSignal`. -/// The difference with a normal `Signal` is that a `ReadSignal` doesn't have a -/// value setter, in other words it's a __read-only__ signal. -/// -/// You can also use derived signals in other ways, like here: -/// ```dart -/// final counter = Signal(0); -/// final doubleCounter = Computed(() => counter() * 2); -/// ``` -/// -/// Every time the `counter` signal changes, the doubleCounter updates with the -/// new doubled `counter` value. -/// -/// You can also transform the value type into a `bool`: -/// ```dart -/// final counter = Signal(0); // type: int -/// final isGreaterThan5 = Computed(() => counter() > 5); // type: bool -/// ``` -/// -/// `isGreaterThan5` will update only when the `counter` value becomes lower/greater than `5`. -/// - If the `counter` value is `0`, `isGreaterThan5` is equal to `false`. -/// - If you update the value to `1`, `isGreaterThan5` doesn't emit a new -/// value, but still contains `false`. -/// - If you update the value to `6`, `isGreaterThan5` emits a new `true` value. -/// {@endtemplate} -class Signal extends ReadableSignal { - /// {@macro signal} - Signal( - super.initialValue, { - - /// {@macro SignalBase.name} - super.name, - - /// {@macro SignalBase.equals} - super.equals, - - /// {@macro SignalBase.autoDispose} - super.autoDispose, - - /// {@macro SignalBase.trackInDevTools} - super.trackInDevTools, - - /// {@macro SignalBase.comparator} - super.comparator = identical, - - /// {@macro SignalBase.trackPreviousValue} - super.trackPreviousValue, - }); - - /// {@macro signal} - /// - /// This is a lazy signal, it doesn't have a value at the moment of creation. - /// But would throw a StateError if you try to access the value before setting - /// one. - Signal.lazy({ - /// {@macro SignalBase.name} - super.name, - - /// {@macro SignalBase.equals} - super.equals, - - /// {@macro SignalBase.autoDispose} - super.autoDispose, - - /// {@macro SignalBase.trackInDevTools} - super.trackInDevTools, - - /// {@macro SignalBase.comparator} - super.comparator = identical, - - /// {@macro SignalBase.trackPreviousValue} - super.trackPreviousValue, - }) : super.lazy(); - - /// {@macro set-signal-value} - 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; - - @override - // ignore: overridden_fields - final _id = ReactiveName.nameFor('Signal'); - - @override - String toString() => - '''Signal<$T>(value: $_untrackedValue, previousValue: $_untrackedPreviousValue)'''; -} diff --git a/packages/solidart/lib/src/core/signal_base.dart b/packages/solidart/lib/src/core/signal_base.dart deleted file mode 100644 index df6334c7..00000000 --- a/packages/solidart/lib/src/core/signal_base.dart +++ /dev/null @@ -1,110 +0,0 @@ -// coverage:ignore-file - -part of 'core.dart'; - -/// A callback that stops an observation when called -typedef DisposeObservation = void Function(); - -/// A custom comparator function -typedef ValueComparator = bool Function(T a, T b); - -/// The base of a signal. -abstract class SignalBase { - /// The base of a signal. - SignalBase({ - this.name, - this.comparator = identical, - bool? equals, - bool? autoDispose, - bool? trackInDevTools, - bool? trackPreviousValue, - }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, - trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, - equals = equals ?? SolidartConfig.equals, - trackPreviousValue = - trackPreviousValue ?? SolidartConfig.trackPreviousValue; - - String get _id; - - /// {@template SignalBase.name} - /// The name of the signal, useful for logging purposes. - /// {@endtemplate} - final String? name; - - /// {@template SignalBase.equals} - /// Whether to check the equality of the value with the == equality. - /// - /// Preventing signal updates if the new value is equal to the previous. - /// - /// When this value is true, the [comparator] is not used. - /// {@endtemplate} - final bool equals; - - /// {@template SignalBase.comparator} - /// An optional comparator function, defaults to [identical]. - /// - /// Preventing signal updates if the [comparator] returns true. - /// - /// Taken into account only if [equals] is false. - /// {@endtemplate} - final ValueComparator comparator; - - /// {@template SignalBase.autoDispose} - /// Whether to automatically dispose the signal (defaults to - /// [SolidartConfig.autoDispose]). - /// - /// This happens automatically when there are no longer subscribers. - /// If you set it to false, you should remember to dispose the signal manually - /// {@endtemplate} - final bool autoDispose; - - /// Whether to track the signal in the DevTools extension, defaults to - /// [SolidartConfig.devToolsEnabled]. - final bool trackInDevTools; - - /// Whether to track the previous value of the signal, defaults to true - final bool trackPreviousValue; - - /// The current signal value - T get value; - - /// The current signal value - T call(); - - /// Whether or not the signal has been initialized with a value. - bool get hasValue; - - /// Indicates if there is a previous value. It is especially - /// helpful if [T] is nullable. - bool get hasPreviousValue; - - /// The previous signal value - /// - /// Defaults to null when no previous value is present. - T? get previousValue; - - /// Returns the untracked previous value of the signal. - T? get untrackedPreviousValue; - - /// Returns the untracked value of the signal. - T get untrackedValue; - - /// Tells if the signal is disposed; - bool get disposed; - - /// Fired when the signal is disposing - void onDispose(VoidCallback cb); - - /// The total number of listeners subscribed to the signal. - int get listenerCount; - - /// Tries to dispose the signal, if no observers are present - void _mayDispose(); - - /// Diposes the signal - void dispose(); - - /// Indicates if the [oldValue] and the [newValue] are equal - // ignore: unused_element - bool _compare(T? oldValue, T? newValue); -} diff --git a/packages/solidart/lib/src/core/untracked.dart b/packages/solidart/lib/src/core/untracked.dart deleted file mode 100644 index 37cf0517..00000000 --- a/packages/solidart/lib/src/core/untracked.dart +++ /dev/null @@ -1,14 +0,0 @@ -part of 'core.dart'; - -/// Execute a callback that will not be tracked by the reactive system. -/// -/// This can be useful inside Effects or Observations to prevent a signal from -/// being tracked. -T untracked(T Function() callback) { - final prevSub = preset.setActiveSub(); - try { - return callback(); - } finally { - preset.setActiveSub(prevSub); - } -} diff --git a/packages/solidart/lib/src/extensions/until.dart b/packages/solidart/lib/src/extensions/until.dart deleted file mode 100644 index fa2a832b..00000000 --- a/packages/solidart/lib/src/extensions/until.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'dart:async'; - -import 'package:solidart/solidart.dart'; - -/// Extension that adds the `until` method to [SignalBase] classes. -extension Until on SignalBase { - /// Returns the future that completes when the [condition] evaluates to true. - /// If the [condition] is already true, it completes immediately. - /// - /// The [timeout] parameter specifies the maximum time to wait for the - /// condition to be met. If provided and the timeout is reached before the - /// condition is met, the future will complete with a [TimeoutException]. - FutureOr until( - bool Function(T value) condition, { - Duration? timeout, - }) { - if (condition(value)) return value; - - final completer = Completer(); - Timer? timer; - late final Effect effect; - - void dispose() { - effect.dispose(); - timer?.cancel(); - } - - effect = Effect( - () { - if (condition(value)) { - dispose(); - completer.complete(value); - } - }, - autoDispose: false, - ); - - // Start timeout timer if specified - if (timeout != null) { - timer = Timer(timeout, () { - if (!completer.isCompleted) { - dispose(); - completer.completeError(TimeoutException(null, timeout)); - } - }); - } - - return completer.future; - } -} diff --git a/packages/solidart/lib/src/v3.dart b/packages/solidart/lib/src/solidart.dart similarity index 100% rename from packages/solidart/lib/src/v3.dart rename to packages/solidart/lib/src/solidart.dart diff --git a/packages/solidart/lib/src/utils.dart b/packages/solidart/lib/src/utils.dart deleted file mode 100644 index c1c36113..00000000 --- a/packages/solidart/lib/src/utils.dart +++ /dev/null @@ -1,179 +0,0 @@ -import 'dart:async'; - -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); - -/// The callback fired by the observer -typedef ObserveCallback = void Function(T? previousValue, T value); - -/// {@template solidartexception} -/// An Exception class to capture Solidart specific exceptions -/// {@endtemplate} -@immutable -class SolidartException extends Error implements Exception { - /// {@macro solidartexception} - SolidartException(this.message); - - /// The error message - final String message; - - @override - String toString() => message; -} - -/// {@template solidartreactionexception} -/// This exception would be fired when an reaction has a cycle and does -/// not stabilize in `ReactiveConfig.maxIterations` iterations -/// {@endtemplate} -class SolidartReactionException extends SolidartException { - /// {@macro solidartreactionexception} - SolidartReactionException(super.message); -} - -/// {@template solidartcaughtexception} -/// This captures the stack trace when user-land code throws an exception -/// {@endtemplate} -class SolidartCaughtException extends SolidartException { - /// {@macro solidartcaughtexception} - SolidartCaughtException(Object exception, {required StackTrace stackTrace}) - : _exception = exception, - _stackTrace = stackTrace, - super('SolidartException: $exception'); - - final Object _exception; - final StackTrace _stackTrace; - - /// the exception - Object get exception => _exception; - - /// The stacktrace - @override - StackTrace? get stackTrace => _stackTrace; -} - -/// The `Option` class represents an optional value. - -/// It is either `Some` or `None`. -/// Use `unwrap` to get the value of a `Some` or throw an exception if it is a -/// `None`. -/// Or safely switch the sealed class to get the value. -sealed class Option { - const Option(); - - /// Unwraps the option, yielding the content of a `Some`. - T unwrap() { - return switch (this) { - final Some some => some.value, - _ => throw Exception('Cannot unwrap None'), - }; - } - - /// Safe unwraps the option, yielding the content of a `Some` or `null`. - T? safeUnwrap() { - return switch (this) { - final Some some => some.value, - _ => null, - }; - } -} - -/// {@template some} -/// The `Some` class represents a value of type `T`. -/// {@endtemplate} -class Some extends Option { - /// {@macro some} - const Some(this.value); - - /// Te value of the `Some` class. - final T value; -} - -/// {@template none} -/// The `None` class represents an absence of a value. -/// {@endtemplate} -class None extends Option { - /// {@macro none} - const None(); -} - -/// {@template DebounceOperation} -/// The operation that must be performed is the [callback] -/// It will be perfomed when the [timer] completes and only -/// if the timer has not been cancelled before -/// {@endtemplate} -@immutable -class DebounceOperation { - /// {@macro DebounceOperation} - const DebounceOperation(this.callback, this.timer); - - /// The callback to be performed - final VoidCallback callback; - - /// The timer that will trigger the callback - final Timer timer; -} - -/// A static class for handling method call debouncing. -@immutable -abstract class Debouncer { - static final Map _operations = {}; - - /// Will delay the execution of [callback] with the given [duration]. If - /// another call to `debounce()` with the same [operationId] happens within - /// this duration, the first call will be cancelled and the debouncer will - /// start waiting for another [duration] before executing [callback]. - /// - /// [operationId] is any arbitrary Object, and is used to identify this - /// particular debounce operation in subsequent calls to `debounce()` or - /// `cancel()`. I recommend using `#someString` (a Symbol). - static void debounce( - Object operationId, - Duration duration, - VoidCallback callback, - ) { - _operations[operationId]?.timer.cancel(); - - _operations[operationId] = DebounceOperation( - callback, - Timer(duration, () { - fire(operationId); - }), - ); - } - - /// Fires the callback associated with [operationId] immediately. It also - /// cancels the debounce timer. - static void fire(Object operationId) { - _operations[operationId]?.timer.cancel(); - final operation = _operations.remove(operationId); - operation?.callback(); - } - - /// Cancels any active debounce operation with the given [operationId]. - static void cancel(Object operationId) { - _operations[operationId]?.timer.cancel(); - _operations.remove(operationId); - } - - /// Cancels all active debouncers. - static void cancelAll() { - for (final operation in _operations.values) { - operation.timer.cancel(); - } - _operations.clear(); - } - - /// Returns the number of active debouncers (debouncers that haven't yet - /// called their callbacks). - static int count() { - return _operations.length; - } -} - -/// coverage:ignore-end diff --git a/packages/solidart/lib/v3.dart b/packages/solidart/lib/v3.dart deleted file mode 100644 index df7dc06b..00000000 --- a/packages/solidart/lib/v3.dart +++ /dev/null @@ -1,23 +0,0 @@ -// TODO(medz): rename the v3.dart to solidart.dart filename. - -export 'src/v3.dart' - show - Computed, - Effect, - LazySignal, - ReactiveList, - ReactiveMap, - ReactiveSet, - ReadonlySignal, - Resource, - ResourceError, - ResourceLoading, - ResourceReady, - ResourceState, - ResourceStateExtensions, - Signal, - SolidartConfig, - SolidartObserver, - ValueComparator, - batch, - untracked; diff --git a/packages/solidart/test/v3_auto_dispose_test.dart b/packages/solidart/test/auto_dispose_test.dart similarity index 98% rename from packages/solidart/test/v3_auto_dispose_test.dart rename to packages/solidart/test/auto_dispose_test.dart index 14bd8a6d..691a1ce0 100644 --- a/packages/solidart/test/v3_auto_dispose_test.dart +++ b/packages/solidart/test/auto_dispose_test.dart @@ -1,5 +1,5 @@ import 'package:solidart/deps/system.dart' as system; -import 'package:solidart/v3.dart'; +import 'package:solidart/solidart.dart'; import 'package:test/test.dart'; void main() { diff --git a/packages/solidart/test/v3_batch_test.dart b/packages/solidart/test/batch_test.dart similarity index 93% rename from packages/solidart/test/v3_batch_test.dart rename to packages/solidart/test/batch_test.dart index 3c89ec15..d6daae8d 100644 --- a/packages/solidart/test/v3_batch_test.dart +++ b/packages/solidart/test/batch_test.dart @@ -1,4 +1,4 @@ -import 'package:solidart/v3.dart'; +import 'package:solidart/solidart.dart'; import 'package:test/test.dart'; void main() { diff --git a/packages/solidart/test/v3_collections_test.dart b/packages/solidart/test/collections_test.dart similarity index 99% rename from packages/solidart/test/v3_collections_test.dart rename to packages/solidart/test/collections_test.dart index 71d665c0..030a50f3 100644 --- a/packages/solidart/test/v3_collections_test.dart +++ b/packages/solidart/test/collections_test.dart @@ -1,4 +1,4 @@ -import 'package:solidart/v3.dart'; +import 'package:solidart/solidart.dart'; import 'package:test/test.dart'; void main() { diff --git a/packages/solidart/test/v3_devtools_test.dart b/packages/solidart/test/devtools_test.dart similarity index 97% rename from packages/solidart/test/v3_devtools_test.dart rename to packages/solidart/test/devtools_test.dart index 195afca4..b412e89d 100644 --- a/packages/solidart/test/v3_devtools_test.dart +++ b/packages/solidart/test/devtools_test.dart @@ -1,4 +1,4 @@ -import 'package:solidart/v3.dart'; +import 'package:solidart/solidart.dart'; import 'package:test/test.dart'; class _Observer implements SolidartObserver { diff --git a/packages/solidart/test/v3_effect_test.dart b/packages/solidart/test/effect_test.dart similarity index 98% rename from packages/solidart/test/v3_effect_test.dart rename to packages/solidart/test/effect_test.dart index d2b9f4bb..f2709614 100644 --- a/packages/solidart/test/v3_effect_test.dart +++ b/packages/solidart/test/effect_test.dart @@ -1,5 +1,5 @@ import 'package:solidart/deps/system.dart' as system; -import 'package:solidart/v3.dart'; +import 'package:solidart/solidart.dart'; import 'package:test/test.dart'; void main() { diff --git a/packages/solidart/test/v3_equals_test.dart b/packages/solidart/test/equals_test.dart similarity index 96% rename from packages/solidart/test/v3_equals_test.dart rename to packages/solidart/test/equals_test.dart index f861d2a6..a56bc938 100644 --- a/packages/solidart/test/v3_equals_test.dart +++ b/packages/solidart/test/equals_test.dart @@ -1,4 +1,4 @@ -import 'package:solidart/v3.dart'; +import 'package:solidart/solidart.dart'; import 'package:test/test.dart'; void main() { diff --git a/packages/solidart/test/v3_previous_value_test.dart b/packages/solidart/test/previous_value_test.dart similarity index 98% rename from packages/solidart/test/v3_previous_value_test.dart rename to packages/solidart/test/previous_value_test.dart index c87796ed..4181a71f 100644 --- a/packages/solidart/test/v3_previous_value_test.dart +++ b/packages/solidart/test/previous_value_test.dart @@ -1,4 +1,4 @@ -import 'package:solidart/v3.dart'; +import 'package:solidart/solidart.dart'; import 'package:test/test.dart'; void main() { diff --git a/packages/solidart/test/v3_resource_test.dart b/packages/solidart/test/resource_test.dart similarity index 99% rename from packages/solidart/test/v3_resource_test.dart rename to packages/solidart/test/resource_test.dart index ab22a4fd..99516eeb 100644 --- a/packages/solidart/test/v3_resource_test.dart +++ b/packages/solidart/test/resource_test.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:fake_async/fake_async.dart'; -import 'package:solidart/v3.dart'; +import 'package:solidart/solidart.dart'; import 'package:test/test.dart'; void main() { diff --git a/packages/solidart/test/solidart_test.dart b/packages/solidart/test/solidart_test.dart deleted file mode 100644 index 43a4a290..00000000 --- a/packages/solidart/test/solidart_test.dart +++ /dev/null @@ -1,2061 +0,0 @@ -// ignore_for_file: cascade_invocations, unreachable_from_main - -import 'dart:async'; -import 'dart:math'; - -import 'package:collection/collection.dart'; -import 'package:meta/meta.dart'; -import 'package:mockito/mockito.dart'; -import 'package:solidart/src/core/core.dart'; -import 'package:solidart/src/extensions/until.dart'; -import 'package:solidart/src/utils.dart'; -import 'package:test/test.dart'; - -sealed class MyEvent {} - -class MyEventA implements MyEvent { - MyEventA(this.value); - - final int value; -} - -class MyEventB implements MyEvent { - MyEventB(this.value); - - final String value; -} - -class MockCallbackFunction extends Mock { - void call(); -} - -class MockCallbackFunctionWithValue extends Mock { - void call(T value); -} - -class _A { - _A(); -} - -class _B { - _B(this.c); - final _C c; -} - -class _C { - _C(this.count); - - final int count; -} - -@immutable -class User { - const User({required this.id}); - final int id; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is User && runtimeType == other.runtimeType && id == other.id; - - @override - int get hashCode => runtimeType.hashCode ^ id.hashCode; - - @override - String toString() => 'User(id: $id)'; -} - -class SampleList { - SampleList(this.numbers); - final List numbers; -} - -class MockSolidartObserver extends Mock implements SolidartObserver {} - -void main() { - group( - 'Signal tests - ', - () { - test( - 'with equals true it notifies only when the value changes', - () async { - final counter = Signal(0); - - final cb = MockCallbackFunction(); - final unobserve = counter.observe((_, _) => cb()); - - expect(counter(), 0); - - counter.value = 1; - await pumpEventQueue(); - expect(counter.value, 1); - - counter.value = 2; - counter.value = 2; - counter.value = 2; - - await pumpEventQueue(); - counter.value = 3; - - expect(counter.value, 3); - await pumpEventQueue(); - verify(cb()).called(3); - // clear - unobserve(); - }, - ); - - test('with the identical comparator it notifies only when the comparator ' - 'returns false', () async { - final signal = Signal<_A?>(null); - final cb = MockCallbackFunction(); - final unobserve = signal.observe((_, _) => cb()); - - expect(signal.value, null); - final a = _A(); - - signal.value = a; - signal.value = a; - signal.value = a; - - await pumpEventQueue(); - - signal.value = _A(); - await pumpEventQueue(); - verify(cb()).called(2); - - // clear - unobserve(); - }); - - test('check onDispose callback fired when disposing signal', () { - final s = Signal(0); - final cb = MockCallbackFunction(); - s - ..onDispose(cb.call) - ..dispose(); - verify(cb()).called(1); - }); - - test('observe fireImmediately works', () { - final s = Signal(0); - final cb = MockCallbackFunction(); - final unobserve = s.observe( - (previousValue, value) => cb.call(), - fireImmediately: true, - ); - verify(cb()).called(1); - addTearDown(() { - s.dispose(); - unobserve(); - }); - }); - - test('check previousValue stores the previous value', () { - final s = Signal(0); - expect( - s.previousValue, - null, - reason: 'The signal should have a null previousValue', - ); - - s.value++; - - expect( - s.previousValue, - 0, - reason: 'The signal should have 0 has previousValue', - ); - - s.updateValue((value) => value * 5); - expect( - s.previousValue, - 1, - reason: 'The signal should have 1 has previousValue', - ); - }); - - test('test until()', () async { - final count = Signal(0); - - unawaited( - expectLater(count.until((value) => value > 5), completion(11)), - ); - count.value = 2; - count.value = 11; - }); - - test( - 'test until() with timeout - condition met before timeout', - () async { - final count = Signal(0); - - unawaited( - expectLater( - count.until( - (value) => value > 5, - timeout: const Duration(milliseconds: 500), - ), - completion(11), - ), - ); - // Wait a bit then update the value before timeout - await Future.delayed(const Duration(milliseconds: 100)); - count.value = 11; - }, - ); - - test( - 'test until() with timeout - timeout occurs before condition', - () async { - final count = Signal(0); - - unawaited( - expectLater( - count.until( - (value) => value > 5, - timeout: const Duration(milliseconds: 100), - ), - throwsA(isA()), - ), - ); - - // Don't update the value, let it timeout - await Future.delayed(const Duration(milliseconds: 200)); - }, - ); - - test( - '''test until() with timeout - condition already met returns immediately''', - () async { - final count = Signal(10); // Value already meets condition - - final result = await count.until( - (value) => value > 5, - timeout: const Duration(milliseconds: 100), - ); - - expect(result, 10); - }, - ); - - test('test until() with timeout - proper cleanup on timeout', () async { - final count = Signal(0); - - // Create an until that will timeout - unawaited( - Future.value( - count.until( - (value) => value > 5, - timeout: const Duration(milliseconds: 50), - ), - ).catchError((_) { - // Expected to timeout, return a dummy value - return 0; - }), - ); - - // Wait for timeout to occur - await Future.delayed(const Duration(milliseconds: 100)); - - // Now update the value - if cleanup worked properly, - // no additional effects should trigger - count.value = 10; - - // Give time for any potential side effects - await Future.delayed(const Duration(milliseconds: 50)); - - // If we reach here without issues, cleanup worked - expect(count.value, 10); - }); - - test('test until() with timeout - proper cleanup on success', () async { - final count = Signal(0); - - final future = count.until( - (value) => value > 5, - timeout: const Duration(milliseconds: 200), - ); - - // Update value to meet condition - count.value = 10; - - // Wait for completion - final result = await future; - expect(result, 10); - - // Give time for cleanup - await Future.delayed(const Duration(milliseconds: 50)); - - // Additional changes shouldn't affect anything since effect was - // disposed - count.value = 15; - - // If we reach here without issues, cleanup worked - expect(count.value, 15); - }); - - test('check toString()', () { - final s = Signal(0); - expect(s.toString(), startsWith('Signal(value: 0')); - }); - - test('check custom name', () { - final s = ReadableSignal(0, name: 'custom-name'); - expect(s.name, 'custom-name'); - }); - - test('check custom name when lazy', () { - final s = ReadableSignal.lazy(name: 'lazy-custom-name'); - expect(s.name, 'lazy-custom-name'); - }); - - test('check Signal becomes ReadSignal', () { - final s = Signal(0); - expect(s, const TypeMatcher>()); - expect(s.toReadSignal(), const TypeMatcher>()); - }); - - test('Signal is disposed after dispose', () { - final s = Signal(0); - expect(s.disposed, false); - s.dispose(); - expect(s.disposed, true); - }); - - test('Signal toggle', () { - final signal = Signal(false); - expect(signal.value, false); - signal.toggle(); - expect(signal.value, true); - }); - - test('lazy Signal', () { - final signal = Signal.lazy(); - expect(signal.hasValue, false); - signal.value = true; - expect(signal.hasValue, true); - }); - - test( - '''lazy Signal trows StateError when accessing value before setting one''', - () { - final signal = Signal.lazy(); - expect(() => signal.value, throwsStateError); - }, - ); - - test('untrackedValue', () { - final counter = Signal(0); - - final cb = MockCallbackFunction(); - final unobserve = Effect( - () { - counter.untrackedValue; - counter.untrackedPreviousValue; - cb(); - }, - onError: (error) { - //ignore - }, - ); - addTearDown(unobserve); - - counter.value = 1; - - // An effect is always triggered once - verify(cb()).called(1); - }); - - test('Test untracked', () { - final count = Signal(0); - final effectCount = Signal(0); - int fn() => effectCount.value + 1; - - final cb = MockCallbackFunction(); - Effect(() { - count.value; - cb.call(); - - // Whenever this effect is triggered, run `fn` that gives new value - effectCount.value = untracked(fn); - }); - - expect(count.value, 0); - expect(effectCount.value, 1); - - count.value = 1; - expect(effectCount.value, 2); - - verify(cb()).called(2); - }); - - test("Check signal disposed isn't tracked by Computed", () { - final count = Signal(1); - final doubleCount = Computed(() => count.value * 2); - - expect(doubleCount.value, 2); - expect(count.disposed, false); - - count.dispose(); - expect(count.disposed, true); - expect(doubleCount.disposed, true); - - count.value = 2; - expect(doubleCount.value, 2); - }); - - test('Check Signal autoDisposes if no longer used', () { - final count = Signal(0, autoDispose: true); - final effect = Effect(() => count.value); - - expect(count.disposed, false); - expect(effect.disposed, false); - - effect.dispose(); - expect(effect.disposed, true); - expect(count.disposed, true); - }); - - test('Check Signal do not autoDisposes if no longer used', () { - final count = Signal(0, autoDispose: false); - final effect = Effect(() => count.value); - - expect(count.disposed, false); - expect(effect.disposed, false); - - effect.dispose(); - expect(effect.disposed, true); - expect(count.disposed, false); - - count.value = 1; - expect(count.value, 1); - }); - - test('shouldUpdate should return true if there is a new value', () { - final signal = Signal(0); - expect(signal.shouldUpdate(), false); - signal.value = 1; - expect(signal.shouldUpdate(), true); - // After checking, should be false again until a new value is set - expect(signal.shouldUpdate(), false); - }); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - - group( - 'Effect tests = ', - () { - test('check effect reaction', () async { - final signal1 = Signal(0); - final signal2 = Signal(0); - - final cb = MockCallbackFunctionWithValue(); - Effect(() => cb(signal1.value)); - Effect(() => cb(signal2.value)); - - signal1.value = 1; - await pumpEventQueue(); - verify(cb(1)).called(1); - signal1.value = 2; - await pumpEventQueue(); - verify(cb(2)).called(1); - signal2.value = 4; - signal1.value = 4; - await pumpEventQueue(); - verify(cb(4)).called(2); - }); - - test('check effect onError', () async { - Object? detectedError; - Effect( - () => throw Exception(), - onError: (error) { - detectedError = error; - }, - ); - expect(detectedError, isA()); - }); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - - group( - 'Computed tests - ', - () { - test( - 'check that a Computed updates only for the selected value', - () async { - final klass = _B(_C(0)); - final s = Signal(klass); - final selected = Computed(() => s.value.c.count); - final cb = MockCallbackFunctionWithValue(); - - // A computed always has a value - expect(selected.hasValue, true); - - void listener() { - cb(selected.value); - } - - final unobserve = selected.observe((_, _) => listener()); - - s.value = _B(_C(1)); - await pumpEventQueue(); - - s.value = _B(_C(5)); - await pumpEventQueue(); - - s.value = _B(_C(1)); - await pumpEventQueue(); - - verify(cb(1)).called(2); - s.value = _B(_C(2)); - await pumpEventQueue(); - s.value = _B(_C(2)); - await pumpEventQueue(); - s.value = _B(_C(3)); - await pumpEventQueue(); - verify(cb(2)).called(1); - verify(cb(3)).called(1); - - // clear - unobserve(); - }, - ); - - test('Computed contains previous value', () async { - final signal = Signal(0); - final derived = Computed(() => signal.value * 2); - await pumpEventQueue(); - expect(derived.hasPreviousValue, false); - expect(derived.previousValue, null); - - signal.value = 1; - await pumpEventQueue(); - expect(derived.hasPreviousValue, true); - expect(derived.previousValue, 0); - - signal.value = 2; - await pumpEventQueue(); - expect(derived.hasPreviousValue, true); - expect(derived.previousValue, 2); - - signal.value = 1; - await pumpEventQueue(); - expect(derived.hasPreviousValue, true); - expect(derived.previousValue, 4); - }); - - test('signal has previous value', () { - final s = Signal(0); - expect(s.hasPreviousValue, false); - s.value = 1; - expect(s.hasPreviousValue, true); - }); - - test('nullable derived signal', () async { - final count = Signal(0); - final doubleCount = Computed(() { - if (count.value == null) return null; - return count.value! * 2; - }); - - await pumpEventQueue(); - expect(doubleCount.value, 0); - - count.value = 1; - await pumpEventQueue(); - expect(doubleCount.value, 2); - - count.value = null; - await pumpEventQueue(); - expect(doubleCount.value, null); - }); - - test('derived signal disposes', () async { - final count = Signal(0); - final doubleCount = Computed(() => count.value * 2); - expect(doubleCount.disposed, false); - doubleCount.dispose(); - expect(doubleCount.disposed, true); - }); - - test('check derived signal that throws', () async { - final count = Signal(1); - final doubleCount = Computed( - () { - if (count.value == 1) { - return count.value * 2; - } - return throw Exception(); - }, - ); - - count.value = 3; - expect( - () => doubleCount.value, - throwsA(const TypeMatcher()), - ); - }); - - test('check toString computed', () { - final count = Signal(1); - final doubleCount = Computed(() => count.value * 2); - - expect(doubleCount.toString(), startsWith('Computed(value: 2')); - }); - - test("check disposed Computed won't react", () { - final count = Signal(0); - final doubleCount = Computed(() => count.value * 2); - var onDisposeCalled = false; - doubleCount.onDispose(() { - onDisposeCalled = true; - }); - final cb = MockCallbackFunctionWithValue(); - doubleCount.observe((_, _) { - cb(doubleCount.value); - }); - - expect(doubleCount.disposed, false); - expect(onDisposeCalled, false); - - count.value = 1; - expect(doubleCount.value, 2); - verify(cb(2)).called(1); - - doubleCount.dispose(); - expect(doubleCount.disposed, true); - expect(onDisposeCalled, true); - - count.value = 2; - expect(doubleCount(), 2); - verifyNever(cb(2)); - }); - - test( - 'Check Computed runs manually by counting the number of runs', - () async { - final cb = MockCallbackFunction(); - final count = Signal(0); - final doubleCount = Computed(() { - cb(); - return count.value * 2; - }); - // trigger reactive value - doubleCount.value; - // run manually twice - doubleCount.run(); - doubleCount.run(); - // 3 times in total, 1 automatically and 2 manually - verify(cb()).called(3); - }, - ); - - test('Check Computed autoDisposes if no longer used', () { - final count = Signal(0); - final doubleCount = Computed(() => count.value * 2, autoDispose: true); - - expect(count.disposed, false); - expect(doubleCount.disposed, false); - - count.value = 1; - expect(count.value, 1); - expect(doubleCount.value, 2); - - count.dispose(); - expect(count.disposed, true); - // After disposing, the Computed should be disposed - expect(doubleCount.disposed, true); - - // Changing the source signal should not trigger the Computed anymore - count.value = 2; - expect(doubleCount.value, 2); - }); - - test('Check Computed do not autoDisposes if no longer used', () { - final count = Signal(0); - final doubleCount = Computed(() => count.value * 2, autoDispose: false); - - expect(count.disposed, false); - expect(doubleCount.disposed, false); - - count.value = 1; - expect(count.value, 1); - expect(doubleCount.value, 2); - - count.dispose(); - expect(count.disposed, true); - // After disposing, the Computed should NOT be disposed - expect(doubleCount.disposed, false); - - // Changing the source signal should not trigger the Computed anymore - // because the count signal is disposed - count.value = 2; - expect(doubleCount.value, 2); - }); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - - group( - 'ReadSignal tests', - () { - // test('check ReadSignal value and listener count', () { - // final s = ReadSignal(0); - // expect(s.value, 0); - // expect(s.previousValue, null); - // expect(s.listenerCount, 0); - // - // Effect((_) { - // s(); - // }); - // expect(s.listenerCount, 1); - // }); - - test('check toString()', () { - final s = ReadableSignal(0); - expect(s.toString(), startsWith('ReadSignal(value: 0')); - }); - - test('check untrackedValue throws if no value', () { - final count = Signal.lazy(); - expect(() => count.untrackedValue, throwsStateError); - }); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - - group( - 'Resource tests', - () { - test('check Resource with stream', () async { - final streamController = StreamController(); - addTearDown(streamController.close); - - final resource = Resource.stream(() => streamController.stream); - expect(resource.state, isA>()); - expect(resource.untrackedState, isA>()); - expect(resource.previousState, isNull); - expect(resource.untrackedPreviousState, isNull); - streamController.add(1); - await pumpEventQueue(); - expect(resource.state, isA>()); - expect(resource.untrackedState, isA>()); - expect(resource.previousState, isA>()); - expect(resource.untrackedPreviousState, isA>()); - expect(resource.state.value, 1); - expect(resource.untrackedState.value, 1); - - streamController.add(10); - await pumpEventQueue(); - expect(resource(), isA>()); - expect( - resource.previousState, - isA>().having( - (p0) => p0.value, - 'previousState value', - 1, - ), - ); - expect(resource.state.value, 10); - - streamController.addError(UnimplementedError()); - await pumpEventQueue(); - expect(resource.state, isA>()); - expect(resource.state.error, isUnimplementedError); - }); - - test('check Resource with stream and source', () async { - final count = Signal(-1); - - final resource = Resource.stream( - () { - if (count.value < 1) return Stream.value(0); - return Stream.value(count.value); - }, - source: count, - lazy: false, - ); - - addTearDown(() { - resource.dispose(); - count.dispose(); - }); - - await pumpEventQueue(); - expect( - resource.state, - isA>().having((p0) => p0.value, 'equal to 0', 0), - ); - - count.value = 5; - await pumpEventQueue(); - expect( - resource.state, - isA>().having((p0) => p0.value, 'equal to 5', 5), - ); - }); - - test('check Resource with changing stream', () async { - final streamControllerA = StreamController(); - final streamControllerB = StreamController(); - final source = Signal(0); - addTearDown(() { - streamControllerA.close(); - streamControllerB.close(); - source.dispose(); - }); - - final resource = Resource.stream( - () { - if (source.value.isEven) { - return streamControllerA.stream; - } - return streamControllerB.stream; - }, - source: source, - ); - expect(resource.state, isA>()); - streamControllerA.add(1); - await pumpEventQueue(); - expect(resource.state, isA>()); - expect(resource.state.value, 1); - - // changing to stream B - source.value = 1; - expect( - resource.state, - isA>().having( - (p0) => p0.isRefreshing, - 'Should be refreshing', - true, - ), - ); - - // add to stream A, the value should not be propagated - // because we're listening to stream B - streamControllerA.add(2); - await pumpEventQueue(); - expect(resource.state.value, 1); - - streamControllerA.add(3); - source.value = 2; - await pumpEventQueue(); - expect(resource.state.value, 3); - }); - - test('check Resource with future that throws', () async { - Future getUser() => throw Exception(); - final resource = Resource( - getUser, - lazy: false, - ); - - addTearDown(resource.dispose); - - await pumpEventQueue(); - expect(resource.state, isA>()); - expect(resource.state.error, isException); - }); - - test('check Resource with future', () async { - final userId = Signal(0); - - Future getUser() { - if (userId.value == 2) throw Exception(); - return Future.value(User(id: userId.value)); - } - - final resource = Resource( - getUser, - source: userId, - lazy: false, - ); - - await pumpEventQueue(); - expect(resource.state, isA>()); - expect(resource.state.value, const User(id: 0)); - - userId.value = 1; - await pumpEventQueue(); - expect(resource.state, isA>()); - expect(resource.state.value, const User(id: 1)); - - userId.value = 2; - await pumpEventQueue(); - expect(resource.state, isA>()); - expect(resource.state.error, isException); - - userId.value = 3; - await pumpEventQueue(); - await resource.refresh(); - expect(resource.state, isA>()); - expect(resource.state.hasError, false); - expect(resource.state.asError, isNull); - expect(resource.state.isLoading, false); - expect(resource.state.asReady, isNotNull); - expect(resource.state.isReady, true); - - resource.dispose(); - }); - - test('check Resource with useRefreshing false', () async { - final userId = Signal(0); - - Future getUser() { - if (userId.value == 2) throw Exception(); - return Future.value(User(id: userId.value)); - } - - final resource = Resource( - getUser, - source: userId, - useRefreshing: false, - lazy: false, - ); - - addTearDown(resource.dispose); - addTearDown(userId.dispose); - - await pumpEventQueue(); - expect(resource.state, isA>()); - expect(resource.state.value, const User(id: 0)); - - userId.value = 1; - expect(resource.state, isA>()); - }); - - test('update ResourceState', () async { - Future fetcher() => Future.value(1); - final resource = Resource(fetcher); - expect(resource.state, isA>()); - await pumpEventQueue(); - expect( - resource.state, - isA>().having( - (p0) => p0.value, - 'value equal to 1', - 1, - ), - ); - - resource.update((state) => const ResourceReady(2)); - expect( - resource.state, - isA>().having( - (p0) => p0.value, - 'value equal to 2', - 2, - ), - ); - }); - test('refresh Resource with fetcher while loading', () async { - Future fetcher() => Future.delayed( - const Duration(milliseconds: 200), - () => 1, - ); - final resource = Resource(fetcher); - expect(resource.state, isA>()); - await Future.delayed(const Duration(milliseconds: 100)); - expect(resource.state, isA>()); - - resource.refresh().ignore(); - expect(resource.state, isA>()); - - await Future.delayed(const Duration(milliseconds: 200)); - expect(resource.state, isA>()); - }); - test('refresh Resource with stream while loading', () async { - final controller = StreamController(); - final resource = Resource.stream(() => controller.stream); - expect(resource.state, isA>()); - - await resource.refresh(); - expect(resource.state, isA>()); - - controller.add(1); - await pumpEventQueue(); - expect(resource.state, isA>()); - }); - - test('check ResourceState.when', () async { - var shouldThrow = false; - Future fetcher() { - return Future.delayed(const Duration(milliseconds: 150), () { - if (shouldThrow) throw Exception(); - return 0; - }); - } - - var dataCalledTimes = 0; - var loadingCalledTimes = 0; - var errorCalledTimes = 0; - var refreshingOnDataTimes = 0; - var refreshingOnErrorTimes = 0; - final resource = Resource(fetcher); - - Effect( - () { - resource.state.when( - ready: (data) { - if (resource.state.isRefreshing) { - refreshingOnDataTimes++; - } else { - dataCalledTimes++; - } - }, - error: (error, stackTrace) { - if (resource.state.isRefreshing) { - refreshingOnErrorTimes++; - } else { - errorCalledTimes++; - } - }, - loading: () { - loadingCalledTimes++; - }, - ); - }, - ); - - await Future.delayed(const Duration(milliseconds: 40)); - expect(loadingCalledTimes, 1); - await Future.delayed(const Duration(milliseconds: 150)); - expect(dataCalledTimes, 1); - expect(errorCalledTimes, 0); - - resource.refresh().ignore(); - await Future.delayed(const Duration(milliseconds: 40)); - expect(refreshingOnDataTimes, 1); - await Future.delayed(const Duration(milliseconds: 150)); - expect(dataCalledTimes, 2); - - expect(resource.state, const TypeMatcher>()); - shouldThrow = true; - resource.refresh().ignore(); - await Future.delayed(const Duration(milliseconds: 150)); - expect(errorCalledTimes, 1); - expect(refreshingOnErrorTimes, 0); - - resource.refresh().ignore(); - await Future.delayed(const Duration(milliseconds: 150)); - expect(refreshingOnErrorTimes, 1); - expect(errorCalledTimes, 2); - - shouldThrow = false; - resource.refresh().ignore(); - await Future.delayed(const Duration(milliseconds: 150)); - expect(refreshingOnErrorTimes, 2); - expect(errorCalledTimes, 2); - expect(dataCalledTimes, 3); - - expect(loadingCalledTimes, 1); - }); - - test( - 'test untilReady()', - () async { - Future fetcher() => Future.delayed( - const Duration(milliseconds: 300), - () => 1, - ); - final count = Resource(fetcher); - - await expectLater(count.untilReady(), completion(1)); - }, - ); - - test( - 'until syncronously fires the then callback if condition is met', - () async { - final count = Resource(() => Future.value(1), lazy: false); - var fired = false; - count.until((v) => true).then((value) => fired = true); - expect(fired, true); - }, - ); - - test( - 'until asynchronously fires the then callback if condition is met', - () async { - final count = Signal(0); - var fired = false; - count.until((v) => v == 1).then((value) => fired = true); - count.value = 1; - await pumpEventQueue(); - expect(fired, true); - }, - ); - - test('check toString()', () async { - final r = Resource( - () => Future.value(1), - lazy: false, - ); - await pumpEventQueue(); - expect( - r.toString(), - startsWith( - '''Resource(state: ResourceReady(value: 1, refreshing: false)''', - ), - ); - }); - - test( - 'check Resource debounceDelay for source that triggers very often', - () async { - final source = Signal(0); - - Future fetcher() => Future.value(42); - - final resource = Resource( - fetcher, - source: source, - debounceDelay: const Duration(milliseconds: 100), - lazy: false, // Start immediately so we get an initial load - ); - - addTearDown(() { - resource.dispose(); - source.dispose(); - }); - - // Wait for initial load to complete - await pumpEventQueue(); - expect(resource.state, isA>()); - - // Rapidly change the source value multiple times - for (var i = 1; i < 10; i++) { - source.value = i; - await Future.delayed(const Duration(milliseconds: 30)); - } - - // Wait enough time to ensure the debounce delay has passed - // and the Future completes - await Future.delayed(const Duration(milliseconds: 200)); - - // At this point, the resource should still be ready after debounced - // refresh - expect(resource.state, isA>()); - }, - ); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - - // group( - // 'ReactiveContext tests', - // () { - // test('throws Exception for reactions that do not converge', () { - // var firstTime = true; - // final count = Signal(0); - // final d = Effect((_) { - // // watch count - // count.value; - // if (firstTime) { - // firstTime = false; - // return; - // } - // - // // cyclic-dependency - // // this effect will keep on getting triggered as count.value keeps - // // changing every time it's invoked - // count.value++; - // }); - // - // expect( - // () => count.value = 1, - // throwsA(const TypeMatcher()), - // ); - // d(); - // }); - // }, - // timeout: const Timeout(Duration(seconds: 1)), - // ); - - group( - 'ListSignal tests', - () { - test('check length', () { - final list = ListSignal([1, 2]); - expect(list.length, 2); - list.add(3); - expect(list.length, 3); - }); - - test('change length', () { - final list = ListSignal([1, 2]); - expect(list.length, 2); - list.length = 3; - expect(list.length, 3); - expect(const ListEquality().equals(list, [1, 2, null]), true); - }); - - test('check elementAt', () { - final list = ListSignal([1, 2]); - expect(list.elementAt(0), 1); - expect(list.elementAt(1), 2); - expect(() => list.elementAt(2), throwsRangeError); - - list.add(3); - expect(list.elementAt(2), 3); - }); - - test('check operator +', () { - final list = ListSignal([1, 2]); - expect(list + [3, 4], [1, 2, 3, 4]); - }); - - test('check operator []', () { - final list = ListSignal([1, 2]); - expect(list[0], 1); - expect(list[1], 2); - expect(() => list[2], throwsRangeError); - - list.add(3); - expect(list[2], 3); - }); - - test('check operator []=', () { - final list = ListSignal([1, 2]); - expect(list[0], 1); - expect(list[1], 2); - list[0] = 3; - expect(list[0], 3); - list[1] = 4; - expect(list[1], 4); - }); - - test('check add', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.add(3); - expect(list, [1, 2, 3]); - }); - - test('check addAll', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.addAll([3, 4]); - expect(list, [1, 2, 3, 4]); - }); - - test('check single', () { - final list = ListSignal([1]); - expect(list.single, 1); - list.add(3); - expect(() => list.single, throwsStateError); - }); - - test('check first', () { - final list = ListSignal([1, 2]); - expect(list.first, 1); - - list.add(3); - expect(list.first, 1); - - list[0] = 4; - expect(list.first, 4); - }); - - test('check last', () { - final list = ListSignal([1, 2]); - expect(list.last, 2); - - list.add(3); - expect(list.last, 3); - - list[2] = 4; - expect(list.last, 4); - }); - - test('check singleWhere', () { - final list = ListSignal([1, 2]); - expect(list.singleWhere((e) => e == 1), 1); - expect(() => list.singleWhere((e) => e == 4), throwsStateError); - }); - - test('check firstWhere', () { - final list = ListSignal([1, 2]); - expect(list.firstWhere((e) => e == 1), 1); - expect(() => list.firstWhere((e) => e == 4), throwsStateError); - }); - - test('check lastWhere', () { - final list = ListSignal([1, 2]); - expect(list.lastWhere((e) => e == 1), 1); - expect(() => list.lastWhere((e) => e == 4), throwsStateError); - }); - - test('check lastIndexWhere', () { - final list = ListSignal([1, 2]); - expect(list.lastIndexWhere((e) => e == 1), 0); - expect(list.lastIndexWhere((e) => e == 4), -1); - }); - - test('check isEmpty', () { - final list = ListSignal([1, 2]); - expect(list.isEmpty, false); - list.clear(); - expect(list.isEmpty, true); - }); - - test('check isNotEmpty', () { - final list = ListSignal([1, 2]); - expect(list.isNotEmpty, true); - list.clear(); - expect(list.isNotEmpty, false); - }); - - test('check clear', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.clear(); - expect(list, isEmpty); - }); - - test('check remove', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.remove(1); - expect(list, [2]); - }); - - test('check removeAt', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.removeAt(0); - expect(list, [2]); - }); - - test('check removeWhere', () { - final list = ListSignal([1, 2, 1]); - expect(list, [1, 2, 1]); - list.removeWhere((e) => e == 1); - expect(list, [2]); - }); - - test('check retainWhere', () { - final list = ListSignal([1, 2, 1]); - expect(list, [1, 2, 1]); - - list.retainWhere((e) => e == 1); - expect(list, [1, 1]); - }); - - test('check setAll', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.setAll(0, [3, 4]); - expect(list, [3, 4]); - }); - - test('check setRange', () { - final list1 = ListSignal([1, 2, 3, 4]); - final list2 = [5, 6, 7, 8, 9]; - - const skipCount = 3; - list1.setRange(1, 3, list2, skipCount); - expect(list1, [1, 8, 9, 4]); - }); - - test('check replaceRange', () { - final list = ListSignal([1, 2, 3, 4, 5]); - final replacements = [6, 7]; - list.replaceRange(1, 4, replacements); - expect(list, [1, 6, 7, 5]); - }); - - test('check fillRange', () { - final list = ListSignal([1, 2, 3, 4, 5]); - expect(list, [1, 2, 3, 4, 5]); - list.fillRange(1, 4, 6); - expect(list, [1, 6, 6, 6, 5]); - }); - - test('check sort', () { - final list = ListSignal([3, 1, 2, 4]); - expect(list, [3, 1, 2, 4]); - list.sort((a, b) => a.compareTo(b)); - expect(list, [1, 2, 3, 4]); - }); - - test('check sublist', () { - final list = ListSignal([1, 2, 3, 4]); - expect(list.sublist(1, 3), [2, 3]); - }); - - test('check toList', () { - final list = ListSignal([1, 2]); - expect(list.toList(growable: false), [1, 2]); - }); - - test('check cast', () { - final list = ListSignal([1, 2]); - expect(list.cast(), [1, 2]); - }); - - test('check toString', () { - final list = ListSignal([1, 2]); - expect(list.toString(), startsWith('ListSignal(value: [1, 2]')); - }); - - test('check set first', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.first = 3; - expect(list, [3, 2]); - }); - - test('check set last', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.last = 3; - expect(list, [1, 3]); - }); - - test('check insert', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.insert(0, 3); - expect(list, [3, 1, 2]); - }); - - test('check insertAll', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.insertAll(0, [3, 4]); - expect(list, [3, 4, 1, 2]); - }); - - test('check removeLast', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.removeLast(); - expect(list, [1]); - }); - - test('check removeRange', () { - final list = ListSignal([1, 2, 3, 4]); - expect(list, [1, 2, 3, 4]); - list.removeRange(1, 3); - expect(list, [1, 4]); - }); - - test('check shuffle', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.shuffle(_AlwaysZeroRandom()); - expect(list, [2, 1]); - }); - - test('check set', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.value = [3, 4]; - expect(list, [3, 4]); - }); - - test('check set with equals', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.value = [3, 4]; - expect(list, [3, 4]); - }); - - test('check updateValue', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.updateValue((v) => v..add(3)); - expect(list, [1, 2, 3]); - }); - - test('check value and previousValue', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - expect(list.previousValue, null); - list.updateValue((v) => v..add(3)); - expect(list, [1, 2, 3]); - expect(list.previousValue, [1, 2]); - }); - - test('check equals', () { - final list = ListSignal([1], equals: true); - expect(list, [1]); - list.value = [1, 2]; - expect(list, [1, 2]); - }); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - - group( - 'SetSignal tests', - () { - test('check length', () { - final set = SetSignal({1, 2}); - expect(set.length, 2); - set.add(3); - expect(set.length, 3); - }); - - test('check elementAt', () { - final set = SetSignal({1, 2}); - expect(set.elementAt(0), 1); - expect(set.elementAt(1), 2); - expect(() => set.elementAt(2), throwsRangeError); - - set.add(3); - expect(set.elementAt(2), 3); - }); - - test('check add', () { - final set = SetSignal({1, 2}); - expect(set, [1, 2]); - set.add(3); - expect(set, [1, 2, 3]); - }); - - test('check addAll', () { - final set = SetSignal({1, 2}); - expect(set, [1, 2]); - set.addAll([3, 4]); - expect(set, [1, 2, 3, 4]); - }); - - test('check single', () { - final set = SetSignal({1}); - expect(set.single, 1); - set.add(3); - expect(() => set.single, throwsStateError); - }); - - test('check first', () { - final set = SetSignal({1, 2}); - expect(set.first, 1); - - set.remove(1); - expect(set.first, 2); - }); - - test('check last', () { - final set = SetSignal({1, 2}); - expect(set.last, 2); - - set.add(3); - expect(set.last, 3); - }); - - test('check singleWhere', () { - final set = SetSignal({1, 2}); - expect(set.singleWhere((e) => e == 1), 1); - expect(() => set.singleWhere((e) => e == 4), throwsStateError); - }); - - test('check firstWhere', () { - final set = SetSignal({1, 2}); - expect(set.firstWhere((e) => e == 1), 1); - expect(() => set.firstWhere((e) => e == 4), throwsStateError); - }); - - test('check lastWhere', () { - final set = SetSignal({1, 2}); - expect(set.lastWhere((e) => e == 1), 1); - expect(() => set.lastWhere((e) => e == 4), throwsStateError); - }); - - test('check isEmpty', () { - final set = SetSignal({1, 2}); - expect(set.isEmpty, false); - set.clear(); - expect(set.isEmpty, true); - }); - - test('check isNotEmpty', () { - final set = SetSignal({1, 2}); - expect(set.isNotEmpty, true); - set.clear(); - expect(set.isNotEmpty, false); - }); - - test('check clear', () { - final set = SetSignal({1, 2}); - expect(set, [1, 2]); - set.clear(); - expect(set, isEmpty); - }); - - test('check remove', () { - final set = SetSignal({1, 2}); - expect(set, [1, 2]); - set.remove(1); - expect(set, [2]); - }); - - test('check removeWhere', () { - final set = SetSignal({1, 2, 3}); - expect(set, {1, 2, 3}); - set.removeWhere((e) => e == 1); - expect(set, {2, 3}); - }); - - test('check retainWhere', () { - final set = SetSignal({ - 1, - 2, - }); - expect(set, {1, 2}); - - set.retainWhere((e) => e == 1); - expect(set, {1}); - }); - - test('check toList', () { - final set = SetSignal({1, 2}); - expect(set.toList(growable: false), [1, 2]); - }); - - test('check cast', () { - final set = SetSignal({1, 2}); - expect(set.cast(), [1, 2]); - }); - - test('check toString', () { - final set = SetSignal({1, 2}); - expect(set.toString(), startsWith('SetSignal(value: {1, 2}')); - }); - - test('check contains', () { - final set = SetSignal({1, 2}); - expect(set.contains(1), true); - expect(set.contains(3), false); - }); - - test('check lookup', () { - final set = SetSignal({1, 2}); - expect(set.lookup(1), 1); - expect(set.lookup(3), null); - }); - - test('check retainAll', () { - final set = SetSignal({1, 2, 3, 4}); - expect(set, {1, 2, 3, 4}); - set.retainAll({1, 3, 10}); - expect(set, {1, 3}); - }); - - test('check set', () { - final set = SetSignal({1, 2}); - expect(set, {1, 2}); - set.value = {3, 4}; - expect(set, {3, 4}); - }); - - test('check set with equals', () { - final set = SetSignal({1, 2}); - expect(set, {1, 2}); - set.value = {3, 4}; - expect(set, {3, 4}); - }); - - test('check updateValue', () { - final set = SetSignal({1, 2}); - expect(set, {1, 2}); - set.updateValue((v) => v..add(3)); - expect(set, {1, 2, 3}); - }); - - test('check value and previousValue', () { - final set = SetSignal({1, 2}); - expect(set, {1, 2}); - expect(set.previousValue, null); - set.updateValue((v) => v..add(3)); - expect(set, {1, 2, 3}); - expect(set.previousValue, {1, 2}); - }); - - test('check equals', () { - final set = SetSignal({1}, equals: true); - expect(set, {1}); - set.value = {1, 2}; - expect(set, {1, 2}); - }); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - - group( - 'MapSignal tests', - () { - test('check [] operator', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map['a'], 1); - expect(map['c'], null); - expect(map['b'], 2); - }); - - test('check []= operator', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map['a'], 1); - map['a'] = 3; - expect(map['a'], 3); - }); - - test('check clear', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.clear(); - expect(map, isEmpty); - }); - - test('check keys', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map.keys, ['a', 'b']); - map.clear(); - expect(map.keys, isEmpty); - }); - - test('check values', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map.values, [1, 2]); - map.clear(); - expect(map.values, isEmpty); - }); - - test('check remove', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.remove('a'); - expect(map, {'b': 2}); - }); - - test('check cast', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map.cast(), {'a': 1, 'b': 2}); - }); - - test('check length', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map.length, 2); - map['c'] = 3; - expect(map.length, 3); - }); - - test('check isEmpty', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map.isEmpty, false); - map.clear(); - expect(map.isEmpty, true); - }); - - test('check isNotEmpty', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map.isNotEmpty, true); - map.clear(); - expect(map.isNotEmpty, false); - }); - - test('check containsKey', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map.containsKey('a'), true); - expect(map.containsKey('c'), false); - }); - - test('check containsValue', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map.containsValue(1), true); - expect(map.containsValue(3), false); - }); - - test('check entries', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(Map.fromEntries(map.entries), map); - map.clear(); - expect(map.entries, isEmpty); - }); - - test('check addEntries', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.addEntries({'c': 3, 'd': 4}.entries); - expect(map, {'a': 1, 'b': 2, 'c': 3, 'd': 4}); - }); - - test('check addAll', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.addAll({'c': 3, 'd': 4}); - expect(map, {'a': 1, 'b': 2, 'c': 3, 'd': 4}); - }); - - test('check putIfAbset', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.putIfAbsent('c', () => 3); - expect(map, {'a': 1, 'b': 2, 'c': 3}); - map.putIfAbsent('a', () => 4); - expect(map, {'a': 1, 'b': 2, 'c': 3}); - }); - - test('check removeWhere', () { - final map = MapSignal({'a': 1, 'b': 2, 'c': 1}); - expect(map, {'a': 1, 'b': 2, 'c': 1}); - map.removeWhere((k, v) => v == 1); - expect(map, {'b': 2}); - }); - - test('check update', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.update('a', (value) => 3); - expect(map, {'a': 3, 'b': 2}); - - map.update('c', (value) => 4, ifAbsent: () => 4); - expect(map, {'a': 3, 'b': 2, 'c': 4}); - - expect(() => map.update('d', (value) => 5), throwsArgumentError); - }); - - test('check updateAll', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.updateAll((k, v) => v * 2); - expect(map, {'a': 2, 'b': 4}); - }); - - test('check toString', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect( - map.toString(), - startsWith('MapSignal(value: {a: 1, b: 2}'), - ); - }); - - test('check set', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.value = {'c': 3, 'd': 4}; - expect(map, {'c': 3, 'd': 4}); - }); - - test('check set with equals', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.value = {'c': 3, 'd': 4}; - expect(map, {'c': 3, 'd': 4}); - }); - - test('check updateValue', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.updateValue((v) { - v['c'] = 3; - return v; - }); - expect(map, {'a': 1, 'b': 2, 'c': 3}); - }); - - test('check value and previousValue', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.updateValue((v) { - v['c'] = 3; - return v; - }); - expect(map, {'a': 1, 'b': 2, 'c': 3}); - expect(map.previousValue, {'a': 1, 'b': 2}); - }); - - test('check equals', () { - final map = MapSignal({'a': 1}, equals: true); - expect(map, {'a': 1}); - map.value = {'b': 2}; - expect(map, {'b': 2}); - }); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - - group( - 'SolidartObserver', - () { - test('didCreateSignal is fired on signal creation', () { - final observer = MockSolidartObserver(); - SolidartConfig.observers.add(observer); - final count = Signal(0); - verify(observer.didCreateSignal(count)).called(1); - }); - - test('didUpdateSignal is fired on signal update', () { - final observer = MockSolidartObserver(); - SolidartConfig.observers.add(observer); - final count = Signal(0); - verifyNever(observer.didUpdateSignal(count)); - count.value++; - verify(observer.didUpdateSignal(count)).called(1); - }); - - test('didDisposeSignal is fired on signal update', () { - final observer = MockSolidartObserver(); - SolidartConfig.observers.add(observer); - final count = Signal(0); - verifyNever(observer.didDisposeSignal(count)); - count.dispose(); - verify(observer.didDisposeSignal(count)).called(1); - }); - - test('modifications are batched', () { - final x = Signal(10); - final y = Signal(20); - final total = Signal(30); - - final calls = <({int x, int y, int total})>[]; - expect(calls, isEmpty); - expect(total.value, equals(30)); - - final disposeEffect = Effect(() { - calls.add((x: x.value, y: y.value, total: total.value)); - }); - - addTearDown(disposeEffect); - - expect( - calls, - equals([ - (x: 10, y: 20, total: 30), - ]), - ); - - batch(() { - x.value++; - y.value++; - - total.value = x.value + y.value; - }); - - expect( - calls, - equals([ - (x: 10, y: 20, total: 30), - (x: 11, y: 21, total: 32), - ]), - ); - }); - - test('should correctly propagate changes through computed signals', () { - final source = Signal(0); - final c1 = Computed(() => source.value % 2); - final c2 = Computed(() => c1.value); - final c3 = Computed(() => c2.value); - - c3.value; - source.value = 1; - c2.value; - source.value = 3; - - expect(c3.value, equals(1)); - }); - test('should clear subscriptions when untracked by all subscribers', () { - var bRunTimes = 0; - - final a = Signal(1); - final b = Computed(() { - bRunTimes++; - return a.value * 2; - }); - final disposeEffect = Effect(() => b.value); - - expect(bRunTimes, equals(1)); - - a.value = 2; - expect(bRunTimes, equals(2)); - - disposeEffect(); - a.value = 2; - expect(bRunTimes, equals(2)); - }); - - test('should not run untracked inner effect', () { - final a = Signal(3); - final b = Computed(() => a.value > 0); - - late VoidCallback disposeInnerEffect; - - final disposeEffect = Effect( - () { - if (b.value) { - disposeInnerEffect = Effect( - () { - if (a.value == 0) { - throw Error(); - } - }, - name: 'inner', - ); - } else { - disposeInnerEffect(); - } - }, - name: 'outer', - ); - - addTearDown(() { - a.dispose(); - b.dispose(); - disposeEffect(); - disposeInnerEffect(); - }); - - a.value--; - a.value--; - a.value--; - expect(b.value, isFalse); - }); - - test('should run outer effect first', () { - final a = Signal(1); - final b = Signal(1); - - late VoidCallback disposeInnerEffect; - final disposeEffect = Effect(() { - if (a.value > 0) { - disposeInnerEffect = Effect(() { - b.value; - if (a.value == 0) { - throw Error(); - } - }); - } else { - disposeInnerEffect(); - } - }); - - addTearDown(() { - disposeEffect(); - a.dispose(); - b.dispose(); - }); - - batch(() { - a.value = 0; - b.value = 0; - }); - }); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); -} - -class _AlwaysZeroRandom implements Random { - @override - bool nextBool() => false; - - @override - double nextDouble() => 0; - - @override - int nextInt(int max) => 0; -} diff --git a/packages/solidart/test/v3_untracked_test.dart b/packages/solidart/test/untracked_test.dart similarity index 92% rename from packages/solidart/test/v3_untracked_test.dart rename to packages/solidart/test/untracked_test.dart index c14fd951..494d85e0 100644 --- a/packages/solidart/test/v3_untracked_test.dart +++ b/packages/solidart/test/untracked_test.dart @@ -1,4 +1,4 @@ -import 'package:solidart/v3.dart'; +import 'package:solidart/solidart.dart'; import 'package:test/test.dart'; void main() { From 5120f8a9e86bd9aee306bbec6fe695d54f487df3 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:29:41 +0800 Subject: [PATCH 045/121] Align ecosystem to solidart v3 --- examples/counter/pubspec.yaml | 2 +- examples/github_search/pubspec.yaml | 2 +- examples/infinite_scroll/pubspec.yaml | 2 +- examples/todos/pubspec.yaml | 2 +- examples/toggle_theme/pubspec.yaml | 2 +- packages/flutter_solidart/example/pubspec.yaml | 2 +- .../flutter_solidart/lib/src/utils/extensions.dart | 11 +++++++++-- .../lib/src/widgets/signal_builder.dart | 11 ++++++++++- packages/solidart/lib/src/solidart.dart | 14 +++++++++++--- packages/solidart_hooks/pubspec.yaml | 2 +- packages/solidart_lint/CHANGELOG.md | 6 ++++++ packages/solidart_lint/README.md | 2 +- packages/solidart_lint/example/lib/main.dart | 2 +- packages/solidart_lint/example/pubspec.yaml | 7 ++++++- packages/solidart_lint/pubspec.yaml | 6 +++--- 15 files changed, 54 insertions(+), 19 deletions(-) diff --git a/examples/counter/pubspec.yaml b/examples/counter/pubspec.yaml index 4830744a..9ced859c 100644 --- a/examples/counter/pubspec.yaml +++ b/examples/counter/pubspec.yaml @@ -33,7 +33,7 @@ resolution: workspace dependencies: flutter: sdk: flutter - flutter_solidart: ^2.0.0 + flutter_solidart: 3.0.0-dev.1 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/examples/github_search/pubspec.yaml b/examples/github_search/pubspec.yaml index 7d812a49..aa86057d 100644 --- a/examples/github_search/pubspec.yaml +++ b/examples/github_search/pubspec.yaml @@ -33,7 +33,7 @@ dependencies: disco: ^1.0.0 flutter: sdk: flutter - flutter_solidart: ^2.0.0 + flutter_solidart: 3.0.0-dev.1 json_annotation: ^4.8.1 equatable: ^2.0.5 http: ^1.3.0 diff --git a/examples/infinite_scroll/pubspec.yaml b/examples/infinite_scroll/pubspec.yaml index 4207ba68..20a1f6e0 100644 --- a/examples/infinite_scroll/pubspec.yaml +++ b/examples/infinite_scroll/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: disco: ^1.0.3+1 flutter: sdk: flutter - flutter_solidart: ^2.7.1 + flutter_solidart: 3.0.0-dev.1 http: ^1.6.0 dev_dependencies: diff --git a/examples/todos/pubspec.yaml b/examples/todos/pubspec.yaml index e106719f..eb6e0ee4 100644 --- a/examples/todos/pubspec.yaml +++ b/examples/todos/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: disco: ^1.0.0 flutter: sdk: flutter - flutter_solidart: ^2.0.0 + flutter_solidart: 3.0.0-dev.1 uuid: ^4.5.1 dev_dependencies: diff --git a/examples/toggle_theme/pubspec.yaml b/examples/toggle_theme/pubspec.yaml index 381ef608..6a765f7d 100644 --- a/examples/toggle_theme/pubspec.yaml +++ b/examples/toggle_theme/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: disco: ^1.0.0 flutter: sdk: flutter - flutter_solidart: ^2.0.0 + flutter_solidart: 3.0.0-dev.1 dev_dependencies: flutter_test: diff --git a/packages/flutter_solidart/example/pubspec.yaml b/packages/flutter_solidart/example/pubspec.yaml index 36fd7444..cf8115ac 100644 --- a/packages/flutter_solidart/example/pubspec.yaml +++ b/packages/flutter_solidart/example/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: flutter: sdk: flutter http: ^1.3.0 - flutter_solidart: ^2.0.0 + flutter_solidart: 3.0.0-dev.1 dev_dependencies: flutter_test: diff --git a/packages/flutter_solidart/lib/src/utils/extensions.dart b/packages/flutter_solidart/lib/src/utils/extensions.dart index 5a4d4310..f6412afd 100644 --- a/packages/flutter_solidart/lib/src/utils/extensions.dart +++ b/packages/flutter_solidart/lib/src/utils/extensions.dart @@ -13,9 +13,9 @@ extension ReadonlySignalToValueNotifier on ReadonlySignal { } class _SignalValueNotifier extends ValueNotifier { - _SignalValueNotifier(this._signal) : super(_signal.value) { + _SignalValueNotifier(this._signal) : super(_readValue(_signal)) { _effect = Effect( - () => value = _signal.value, + () => value = _readValue(_signal), autoDispose: false, detach: true, ); @@ -32,6 +32,13 @@ class _SignalValueNotifier extends ValueNotifier { } } +T _readValue(ReadonlySignal signal) { + if (signal is Resource) { + return (signal as Resource).state as T; + } + return signal.value; +} + /// {@template value-listenable-to-signal} /// Converts a [ValueListenable] into a [Signal] that mirrors its value. /// diff --git a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart index 5af87498..667997c0 100644 --- a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart +++ b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart @@ -70,9 +70,11 @@ class _SignalBuilderElement extends StatelessElement { ); bool _isBuilding = false; + system.Link? _depsHead; system.Link? _depsTail; void _runEffect() { + _effect.deps = _depsHead; _effect.depsTail = _depsTail; if (_isBuilding || dirty) { return; @@ -82,6 +84,8 @@ class _SignalBuilderElement extends StatelessElement { @override void unmount() { + _effect.deps = _depsHead; + _effect.depsTail = _depsTail; _effect.dispose(); super.unmount(); } @@ -89,16 +93,21 @@ class _SignalBuilderElement extends StatelessElement { @override Widget build() { _isBuilding = true; + final prevDetach = SolidartConfig.detachEffects; final prevSub = preset.setActiveSub(_effect); _effect.depsTail = null; + _effect.flags = + system.ReactiveFlags.watching | system.ReactiveFlags.recursedCheck; preset.cycle++; try { + SolidartConfig.detachEffects = true; final built = super.build(); preset.purgeDeps(_effect); + _depsHead = _effect.deps; _depsTail = _effect.depsTail; - _effect.flags = system.ReactiveFlags.watching; return built; } finally { + SolidartConfig.detachEffects = prevDetach; preset.setActiveSub(prevSub); _effect.flags &= ~system.ReactiveFlags.recursedCheck; _isBuilding = false; diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index 78e0019f..a9de917c 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -373,9 +373,17 @@ abstract class Disposable { while (link != null) { final next = link.nextDep; final dep = link.dep; - preset.unlink(link, node); - if (canAutoDispose(dep) && dep.subs == null) { + final isLastSub = + identical(dep.subs, link) && + link.prevSub == null && + link.nextSub == null; + if (canAutoDispose(dep) && isLastSub) { (dep as Disposable).dispose(); + } else { + preset.unlink(link, node); + if (canAutoDispose(dep) && dep.subs == null) { + (dep as Disposable).dispose(); + } } link = next; } @@ -1277,8 +1285,8 @@ class Computed extends preset.ComputedNode @override void dispose() { if (isDisposed) return; - Disposable.unlinkSubs(this); Disposable.unlinkDeps(this); + Disposable.unlinkSubs(this); preset.stop(this); super.dispose(); _notifySignalDisposal(this); diff --git a/packages/solidart_hooks/pubspec.yaml b/packages/solidart_hooks/pubspec.yaml index 587dbd5f..60418dfb 100644 --- a/packages/solidart_hooks/pubspec.yaml +++ b/packages/solidart_hooks/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: flutter: sdk: flutter flutter_hooks: ^0.21.3+1 - flutter_solidart: ^2.7.2 + flutter_solidart: 3.0.0-dev.1 dev_dependencies: flutter_test: diff --git a/packages/solidart_lint/CHANGELOG.md b/packages/solidart_lint/CHANGELOG.md index 07be8eef..949c3ac3 100644 --- a/packages/solidart_lint/CHANGELOG.md +++ b/packages/solidart_lint/CHANGELOG.md @@ -1,3 +1,9 @@ +## 3.0.2 + +- **CHORE**: Upgrade `solidart` and `flutter_solidart` dev dependencies to v3. +- **DOCS**: Update README version snippet. +- **EXAMPLE**: Migrate example code to v3 APIs. + ## 3.0.1 - Update README.md diff --git a/packages/solidart_lint/README.md b/packages/solidart_lint/README.md index 1643424d..b89e03b8 100644 --- a/packages/solidart_lint/README.md +++ b/packages/solidart_lint/README.md @@ -10,7 +10,7 @@ Then edit your `analysis_options.yaml` file and add these lines of code: ```yaml plugins: - solidart_lint: ^3.0.0 + solidart_lint: ^3.0.2 ``` ## ASSISTS diff --git a/packages/solidart_lint/example/lib/main.dart b/packages/solidart_lint/example/lib/main.dart index da28b39a..aa4c13cd 100644 --- a/packages/solidart_lint/example/lib/main.dart +++ b/packages/solidart_lint/example/lib/main.dart @@ -45,7 +45,7 @@ class MyHomePage extends StatelessWidget { return ElevatedButton( child: const Text('Increment'), onPressed: () { - counter.updateValue((value) => throw UnimplementedError()); + counter.value = counter.value + 1; }, ); } diff --git a/packages/solidart_lint/example/pubspec.yaml b/packages/solidart_lint/example/pubspec.yaml index 9dbc2a75..cb7cfdb4 100644 --- a/packages/solidart_lint/example/pubspec.yaml +++ b/packages/solidart_lint/example/pubspec.yaml @@ -31,12 +31,17 @@ dependencies: disco: ^1.0.3 flutter: sdk: flutter - flutter_solidart: ^2.1.0 + flutter_solidart: + path: ../../flutter_solidart dev_dependencies: flutter_test: sdk: flutter +dependency_overrides: + solidart: + path: ../../solidart + # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your diff --git a/packages/solidart_lint/pubspec.yaml b/packages/solidart_lint/pubspec.yaml index 6b6f2d18..694c76b9 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.0.1 +version: 3.0.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: ^2.0.0 - flutter_solidart: ^2.0.0 + solidart: 3.0.0-dev.1 + flutter_solidart: 3.0.0-dev.1 From 68481c5a70cd8531016da9fef1087b06739a8279 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:43:19 +0800 Subject: [PATCH 046/121] Guard SignalBuilder effect after unmount --- packages/flutter_solidart/lib/src/widgets/signal_builder.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart index 667997c0..7e3761be 100644 --- a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart +++ b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart @@ -76,6 +76,9 @@ class _SignalBuilderElement extends StatelessElement { void _runEffect() { _effect.deps = _depsHead; _effect.depsTail = _depsTail; + if (!mounted) { + return; + } if (_isBuilding || dirty) { return; } From a2ca9e83bfa5494cfc42606412d6e5ad5d4a4b8c Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:58:44 +0800 Subject: [PATCH 047/121] Trim devtools extension to v3 signals --- .../solidart_devtools_extension/lib/main.dart | 51 ++----------------- 1 file changed, 4 insertions(+), 47 deletions(-) diff --git a/packages/solidart_devtools_extension/lib/main.dart b/packages/solidart_devtools_extension/lib/main.dart index 57efba1b..6e32f652 100644 --- a/packages/solidart_devtools_extension/lib/main.dart +++ b/packages/solidart_devtools_extension/lib/main.dart @@ -40,47 +40,36 @@ class Signals extends StatefulWidget { } enum SignalType { - readSignal, readonlySignal, signal, lazySignal, computed, resource, - listSignal, reactiveList, - mapSignal, reactiveMap, - setSignal, reactiveSet; static SignalType byName(String name) { return switch (name) { - 'ReadSignal' => SignalType.readSignal, 'ReadonlySignal' => SignalType.readonlySignal, 'Signal' => SignalType.signal, 'LazySignal' => SignalType.lazySignal, 'Computed' => SignalType.computed, 'Resource' => SignalType.resource, - 'ListSignal' => SignalType.listSignal, 'ReactiveList' => SignalType.reactiveList, - 'MapSignal' => SignalType.mapSignal, 'ReactiveMap' => SignalType.reactiveMap, - 'SetSignal' => SignalType.setSignal, 'ReactiveSet' => SignalType.reactiveSet, _ => SignalType.signal, }; } } -enum SignalVersion { v2, v3 } - class SignalData { const SignalData({ required this.value, required this.hasPreviousValue, required this.previousValue, required this.type, - required this.version, required this.disposed, required this.autoDispose, required this.listenerCount, @@ -95,7 +84,6 @@ class SignalData { final bool hasPreviousValue; final Object? previousValue; final SignalType type; - final SignalVersion version; final bool disposed; final bool autoDispose; final int listenerCount; @@ -107,7 +95,7 @@ class SignalData { return value.toString().toLowerCase().contains(search) || previousValue.toString().toLowerCase().contains(search) || valueType.toLowerCase().contains(search) || - version.name.toLowerCase().contains(search) || + type.name.toLowerCase().contains(search) || (previousValueType != null && previousValueType!.toLowerCase().contains(search)); } @@ -120,7 +108,7 @@ class _SignalsState extends State { final searchText = Signal(''); final filterType = Signal(null); final showDisposed = Signal(true); - final signals = MapSignal({}); + final signals = ReactiveMap({}); late final filteredSignals = Computed(() { final lowercasedSearch = searchText.value.toLowerCase(); @@ -151,31 +139,23 @@ class _SignalsState extends State { .where((e) { final kind = e.extensionKind; return kind != null && - (kind.startsWith('ext.solidart.signal') || - kind.startsWith('ext.solidart.v3.signal')); + kind.startsWith('ext.solidart.v3.signal'); }) .listen((event) { final data = event.extensionData?.data; if (data == null) return; final kind = event.extensionKind; - final version = (kind?.startsWith('ext.solidart.v3.signal') ?? false) - ? SignalVersion.v3 - : SignalVersion.v2; switch (kind) { - case 'ext.solidart.signal.created': - case 'ext.solidart.signal.updated': - case 'ext.solidart.signal.disposed': case 'ext.solidart.v3.signal.created': case 'ext.solidart.v3.signal.updated': case 'ext.solidart.v3.signal.disposed': - final signalId = '${version.name}:${data['_id']}'; + final signalId = data['_id'].toString(); signals[signalId] = SignalData( name: data['name'] ?? data['_id'], value: jsonDecode(data['value'] ?? 'null'), hasPreviousValue: data['hasPreviousValue'], previousValue: jsonDecode(data['previousValue'] ?? 'null'), type: SignalType.byName(data['type']), - version: version, disposed: data['disposed'], autoDispose: data['autoDispose'], listenerCount: data['listenerCount'], @@ -421,25 +401,6 @@ class _SignalsState extends State { entry.key; }, ), - ShadBadge( - child: Text( - signal.version.name - .toUpperCase(), - style: shadTheme - .textTheme - .small - .copyWith( - fontSize: 10, - color: shadTheme - .primaryBadgeTheme - .foregroundColor, - ), - ), - onPressed: () { - selectedSignalId.value = - entry.key; - }, - ), ShadBadge( child: Text( DateFormat( @@ -543,10 +504,6 @@ class _SignalsState extends State { name: 'type', value: signal.type.name.capitalizeFirst(), ), - ParameterView( - name: 'version', - value: signal.version.name, - ), ParameterView( name: 'value', value: signal.value, From 006f4488db4a43ec06896a2d2ba667239e8add84 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 07:20:29 +0800 Subject: [PATCH 048/121] Adapt solidart_hooks to v3 APIs --- packages/solidart_hooks/README.md | 12 +- packages/solidart_hooks/example/lib/main.dart | 24 +- ...ist_signal.dart => use_reactive_list.dart} | 8 +- ..._map_signal.dart => use_reactive_map.dart} | 8 +- ..._set_signal.dart => use_reactive_set.dart} | 8 +- .../solidart_hooks/lib/solidart_hooks.dart | 206 +++++++----------- 6 files changed, 109 insertions(+), 157 deletions(-) rename packages/solidart_hooks/example/lib/pages/{use_list_signal.dart => use_reactive_list.dart} (72%) rename packages/solidart_hooks/example/lib/pages/{use_map_signal.dart => use_reactive_map.dart} (78%) rename packages/solidart_hooks/example/lib/pages/{use_set_signal.dart => use_reactive_set.dart} (73%) diff --git a/packages/solidart_hooks/README.md b/packages/solidart_hooks/README.md index c194fcfd..8c9eed81 100644 --- a/packages/solidart_hooks/README.md +++ b/packages/solidart_hooks/README.md @@ -164,7 +164,7 @@ class Example extends HookWidget { } ``` -## useListSignal +## useReactiveList How to create a new list signal inside of a hook widget: @@ -172,7 +172,7 @@ How to create a new list signal inside of a hook widget: class Example extends HookWidget { @override Widget build(BuildContext context) { - final items = useListSignal(['Item1', 'Item2']); + final items = useReactiveList(['Item1', 'Item2']); return Scaffold( body: Center( child: SignalBuilder( @@ -193,7 +193,7 @@ class Example extends HookWidget { The widget will automatically rebuild when the list changes. The signal will get disposed when the widget gets unmounted. -## useSetSignal +## useReactiveSet How to create a new set signal inside of a hook widget: @@ -201,7 +201,7 @@ How to create a new set signal inside of a hook widget: class Example extends HookWidget { @override Widget build(BuildContext context) { - final uniqueItems = useSetSignal({'Item1', 'Item2'}); + final uniqueItems = useReactiveSet({'Item1', 'Item2'}); return Scaffold( body: Center( child: SignalBuilder( @@ -222,7 +222,7 @@ class Example extends HookWidget { The widget will automatically rebuild when the set changes. The signal will get disposed when the widget gets unmounted. -## useMapSignal +## useReactiveMap How to create a new map signal inside of a hook widget: @@ -230,7 +230,7 @@ How to create a new map signal inside of a hook widget: class Example extends HookWidget { @override Widget build(BuildContext context) { - final userRoles = useMapSignal({'admin': 'John'}); + final userRoles = useReactiveMap({'admin': 'John'}); return Scaffold( body: Center( child: Column( diff --git a/packages/solidart_hooks/example/lib/main.dart b/packages/solidart_hooks/example/lib/main.dart index 6bfaf0e6..77fb2ed2 100644 --- a/packages/solidart_hooks/example/lib/main.dart +++ b/packages/solidart_hooks/example/lib/main.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'pages/use_signal.dart'; -import 'pages/use_list_signal.dart'; -import 'pages/use_set_signal.dart'; -import 'pages/use_map_signal.dart'; +import 'pages/use_reactive_list.dart'; +import 'pages/use_reactive_set.dart'; +import 'pages/use_reactive_map.dart'; import 'pages/use_computed.dart'; import 'pages/use_resource.dart'; import 'pages/use_resource_stream.dart'; @@ -54,19 +54,19 @@ class HookListScreen extends HookWidget { example: () => const UseSignalExample(), ), HookInfo( - title: 'useListSignal', - description: 'Create a reactive list signal', - example: () => const UseListSignalExample(), + title: 'useReactiveList', + description: 'Create a reactive list', + example: () => const UseReactiveListExample(), ), HookInfo( - title: 'useSetSignal', - description: 'Create a reactive set signal', - example: () => const UseSetSignalExample(), + title: 'useReactiveSet', + description: 'Create a reactive set', + example: () => const UseReactiveSetExample(), ), HookInfo( - title: 'useMapSignal', - description: 'Create a reactive map signal', - example: () => const UseMapSignalExample(), + title: 'useReactiveMap', + description: 'Create a reactive map', + example: () => const UseReactiveMapExample(), ), HookInfo( title: 'useComputed', diff --git a/packages/solidart_hooks/example/lib/pages/use_list_signal.dart b/packages/solidart_hooks/example/lib/pages/use_reactive_list.dart similarity index 72% rename from packages/solidart_hooks/example/lib/pages/use_list_signal.dart rename to packages/solidart_hooks/example/lib/pages/use_reactive_list.dart index dbdff8b0..ee25eafe 100644 --- a/packages/solidart_hooks/example/lib/pages/use_list_signal.dart +++ b/packages/solidart_hooks/example/lib/pages/use_reactive_list.dart @@ -2,15 +2,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:solidart_hooks/solidart_hooks.dart'; -class UseListSignalExample extends HookWidget { - const UseListSignalExample({super.key}); +class UseReactiveListExample extends HookWidget { + const UseReactiveListExample({super.key}); @override Widget build(BuildContext context) { - final items = useListSignal(['Item1', 'Item2']); + final items = useReactiveList(['Item1', 'Item2']); return Scaffold( - appBar: AppBar(title: const Text('useListSignal')), + appBar: AppBar(title: const Text('useReactiveList')), body: Center( child: SignalBuilder( builder: (context, child) { diff --git a/packages/solidart_hooks/example/lib/pages/use_map_signal.dart b/packages/solidart_hooks/example/lib/pages/use_reactive_map.dart similarity index 78% rename from packages/solidart_hooks/example/lib/pages/use_map_signal.dart rename to packages/solidart_hooks/example/lib/pages/use_reactive_map.dart index 5d11c257..417ef9eb 100644 --- a/packages/solidart_hooks/example/lib/pages/use_map_signal.dart +++ b/packages/solidart_hooks/example/lib/pages/use_reactive_map.dart @@ -2,14 +2,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:solidart_hooks/solidart_hooks.dart'; -class UseMapSignalExample extends HookWidget { - const UseMapSignalExample({super.key}); +class UseReactiveMapExample extends HookWidget { + const UseReactiveMapExample({super.key}); @override Widget build(BuildContext context) { - final userRoles = useMapSignal({'admin': 'John'}); + final userRoles = useReactiveMap({'admin': 'John'}); return Scaffold( - appBar: AppBar(title: const Text('useMapSignal')), + appBar: AppBar(title: const Text('useReactiveMap')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/packages/solidart_hooks/example/lib/pages/use_set_signal.dart b/packages/solidart_hooks/example/lib/pages/use_reactive_set.dart similarity index 73% rename from packages/solidart_hooks/example/lib/pages/use_set_signal.dart rename to packages/solidart_hooks/example/lib/pages/use_reactive_set.dart index 5dace88a..0f4524e0 100644 --- a/packages/solidart_hooks/example/lib/pages/use_set_signal.dart +++ b/packages/solidart_hooks/example/lib/pages/use_reactive_set.dart @@ -2,15 +2,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:solidart_hooks/solidart_hooks.dart'; -class UseSetSignalExample extends HookWidget { - const UseSetSignalExample({super.key}); +class UseReactiveSetExample extends HookWidget { + const UseReactiveSetExample({super.key}); @override Widget build(BuildContext context) { - final uniqueItems = useSetSignal({'Item1', 'Item2'}); + final uniqueItems = useReactiveSet({'Item1', 'Item2'}); return Scaffold( - appBar: AppBar(title: const Text('useSetSignal')), + appBar: AppBar(title: const Text('useReactiveSet')), body: Center( child: SignalBuilder( builder: (context, child) { diff --git a/packages/solidart_hooks/lib/solidart_hooks.dart b/packages/solidart_hooks/lib/solidart_hooks.dart index e1a64067..5c9898e5 100644 --- a/packages/solidart_hooks/lib/solidart_hooks.dart +++ b/packages/solidart_hooks/lib/solidart_hooks.dart @@ -7,35 +7,32 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_solidart/flutter_solidart.dart'; -/// Bind an existing signal to the hook widget +/// Bind an existing signal to the hook widget. /// -/// This will not dispose the signal when the widget is unmounted -T useExistingSignal(T value) { +/// This will not dispose the signal when the widget is unmounted. +T useExistingSignal(T value) { final target = useMemoized(() => value, [value]); return use(_SignalHook('useExistingSignal', target, disposeOnUnmount: false)); } -/// {macro signal} +/// Create a [Signal] inside a hook widget. Signal useSignal( /// The initial value of the signal. T initialValue, { - /// {macro SignalBase.name} + /// Optional name used by DevTools. String? name, - /// {macro SignalBase.equals} - bool? equals, - - /// {@macro SignalBase.autoDispose} - bool? autoDispose = false, + /// Whether the signal should auto-dispose when unused. + bool? autoDispose, - /// {@macro SignalBase.trackInDevTools} + /// Whether to report updates to DevTools. bool? trackInDevTools, - /// {@macro SignalBase.comparator} - bool Function(T? a, T? b) comparator = identical, + /// Comparator used to skip equal updates. + ValueComparator equals = identical, - /// {@macro SignalBase.trackPreviousValue} + /// Whether to track previous values. bool? trackPreviousValue, }) { final target = useMemoized( @@ -45,134 +42,113 @@ Signal useSignal( name: name, equals: equals, trackInDevTools: trackInDevTools, - comparator: comparator, trackPreviousValue: trackPreviousValue, ), [], ); - return use( - _SignalHook('useSignal', target, disposeOnUnmount: autoDispose ?? true), - ); + return use(_SignalHook('useSignal', target)); } -/// {macro list-signal} -ListSignal useListSignal( +/// Create a [ReactiveList] inside a hook widget. +ReactiveList useReactiveList( /// The initial value of the signal. Iterable initialValue, { - /// {macro SignalBase.name} + /// Optional name used by DevTools. String? name, - /// {macro SignalBase.equals} - bool? equals, - - /// {@macro SignalBase.autoDispose} - bool? autoDispose = false, + /// Whether the reactive list should auto-dispose when unused. + bool? autoDispose, - /// {@macro SignalBase.trackInDevTools} + /// Whether to report updates to DevTools. bool? trackInDevTools, - /// {@macro SignalBase.comparator} - bool Function(List? a, List? b) comparator = identical, + /// Comparator used to skip equal updates. + ValueComparator> equals = identical, - /// {@macro SignalBase.trackPreviousValue} + /// Whether to track previous values. bool? trackPreviousValue, }) { final target = useMemoized( - () => ListSignal( + () => ReactiveList( initialValue, autoDispose: autoDispose, name: name, equals: equals, trackInDevTools: trackInDevTools, - comparator: comparator, trackPreviousValue: trackPreviousValue, ), [], ); - return use( - _SignalHook('useListSignal', target, disposeOnUnmount: autoDispose ?? true), - ); + return use(_SignalHook('useReactiveList', target)); } -/// {macro set-signal} -SetSignal useSetSignal( +/// Create a [ReactiveSet] inside a hook widget. +ReactiveSet useReactiveSet( /// The initial value of the signal. Iterable initialValue, { - /// {macro SignalBase.name} + /// Optional name used by DevTools. String? name, - /// {macro SignalBase.equals} - bool? equals, - - /// {@macro SignalBase.autoDispose} - bool? autoDispose = false, + /// Whether the reactive set should auto-dispose when unused. + bool? autoDispose, - /// {@macro SignalBase.trackInDevTools} + /// Whether to report updates to DevTools. bool? trackInDevTools, - /// {@macro SignalBase.comparator} - bool Function(Set? a, Set? b) comparator = identical, + /// Comparator used to skip equal updates. + ValueComparator> equals = identical, - /// {@macro SignalBase.trackPreviousValue} + /// Whether to track previous values. bool? trackPreviousValue, }) { final target = useMemoized( - () => SetSignal( + () => ReactiveSet( initialValue, autoDispose: autoDispose, name: name, equals: equals, trackInDevTools: trackInDevTools, - comparator: comparator, trackPreviousValue: trackPreviousValue, ), [], ); - return use( - _SignalHook('useSetSignal', target, disposeOnUnmount: autoDispose ?? true), - ); + return use(_SignalHook('useReactiveSet', target)); } -/// {macro map-signal} -MapSignal useMapSignal( +/// Create a [ReactiveMap] inside a hook widget. +ReactiveMap useReactiveMap( /// The initial value of the signal. Map initialValue, { - /// {macro SignalBase.name} + /// Optional name used by DevTools. String? name, - /// {macro SignalBase.equals} - bool? equals, - - /// {@macro SignalBase.autoDispose} - bool? autoDispose = false, + /// Whether the reactive map should auto-dispose when unused. + bool? autoDispose, - /// {@macro SignalBase.trackInDevTools} + /// Whether to report updates to DevTools. bool? trackInDevTools, - /// {@macro SignalBase.comparator} - bool Function(Map? a, Map? b) comparator = identical, + /// Comparator used to skip equal updates. + ValueComparator> equals = identical, - /// {@macro SignalBase.trackPreviousValue} + /// Whether to track previous values. bool? trackPreviousValue, }) { final target = useMemoized( - () => MapSignal( + () => ReactiveMap( initialValue, autoDispose: autoDispose, name: name, equals: equals, trackInDevTools: trackInDevTools, - comparator: comparator, trackPreviousValue: trackPreviousValue, ), [], ); - return use( - _SignalHook('useMapSignal', target, disposeOnUnmount: autoDispose ?? true), - ); + return use(_SignalHook('useReactiveMap', target)); } /// Create a new computed signal @@ -180,22 +156,19 @@ Computed useComputed( /// The selector function to compute the value. T Function() selector, { - /// {macro SignalBase.name} + /// Optional name used by DevTools. String? name, - /// {macro SignalBase.equals} - bool? equals, - - /// {@macro SignalBase.autoDispose} - bool? autoDispose = false, + /// Whether the computed should auto-dispose when unused. + bool? autoDispose, - /// {@macro SignalBase.trackInDevTools} + /// Whether to report updates to DevTools. bool? trackInDevTools, - /// {@macro SignalBase.comparator} - bool Function(T? a, T? b) comparator = identical, + /// Comparator used to skip equal updates. + ValueComparator equals = identical, - /// {@macro SignalBase.trackPreviousValue} + /// Whether to track previous values. bool? trackPreviousValue, }) { final instance = useRef(selector); @@ -207,35 +180,32 @@ Computed useComputed( name: name, equals: equals, trackInDevTools: trackInDevTools, - comparator: comparator, trackPreviousValue: trackPreviousValue, ), [], ); - return use( - _SignalHook('useComputed', target, disposeOnUnmount: autoDispose ?? true), - ); + return use(_SignalHook('useComputed', target)); } -/// {macro resource} +/// Create a [Resource] from a future-producing [fetcher]. Resource useResource( /// The asynchrounous function used to retrieve data. final Future Function()? fetcher, { - /// {macro SignalBase.name} + /// Optional name used by DevTools. String? name, - /// {macro SignalBase.equals} - bool? equals, - - /// {@macro SignalBase.autoDispose} - bool? autoDispose = false, + /// Whether the resource should auto-dispose when unused. + bool? autoDispose, - /// {@macro SignalBase.trackInDevTools} + /// Whether to report updates to DevTools. bool? trackInDevTools, + /// Comparator used to skip equal updates. + ValueComparator> equals = identical, + /// Reactive signal values passed to the fetcher, optional. - final SignalBase? source, + final ReadonlySignal? source, /// Indicates whether the resource should be computed lazily, defaults to true final bool lazy = true, @@ -264,30 +234,28 @@ Resource useResource( ), [], ); - return use( - _SignalHook('useResource', target, disposeOnUnmount: autoDispose ?? true), - ); + return use(_SignalHook('useResource', target)); } -/// {macro resource} +/// Create a [Resource] from a stream factory. Resource useResourceStream( /// The asynchrounous function used to retrieve data. final Stream Function()? stream, { - /// {macro SignalBase.name} + /// Optional name used by DevTools. String? name, - /// {macro SignalBase.equals} - bool? equals, - - /// {@macro SignalBase.autoDispose} - bool? autoDispose = false, + /// Whether the resource should auto-dispose when unused. + bool? autoDispose, - /// {@macro SignalBase.trackInDevTools} + /// Whether to report updates to DevTools. bool? trackInDevTools, + /// Comparator used to skip equal updates. + ValueComparator> equals = identical, + /// Reactive signal values passed to the fetcher, optional. - final SignalBase? source, + final ReadonlySignal? source, /// Indicates whether the resource should be computed lazily, defaults to true final bool lazy = true, @@ -320,52 +288,36 @@ Resource useResourceStream( _SignalHook( 'useResourceStream', target, - disposeOnUnmount: autoDispose ?? true, ), ); } -/// Create a signal effect +/// Create an effect inside a hook widget. void useSolidartEffect( - dynamic Function() cb, { - - void Function(Object error)? onError, - - /// The name of the effect, useful for logging + VoidCallback cb, { + /// The name of the effect, useful for logging. String? name, - /// Delay each effect reaction - Duration? delay, - - /// Whether to automatically dispose the effect (defaults to true). - /// - /// This happens automatically when all the tracked dependencies are - /// disposed. + /// Whether the effect should auto-dispose when unused. bool? autoDispose, - /// Detach effect, default value is [SolidartConfig.detachEffects] + /// Detach effect, default value is [SolidartConfig.detachEffects]. bool? detach, - - /// Whether to automatically run the effect (defaults to true). - bool? autorun, }) { final instance = useRef(cb); instance.value = cb; useEffect( () => Effect( () => instance.value(), - onError: onError, name: name, - delay: delay, autoDispose: autoDispose, detach: detach, - autorun: autorun, ).dispose, [], ); } -class _SignalHook> extends Hook { +class _SignalHook> extends Hook { const _SignalHook(this.type, this.target, {this.disposeOnUnmount = true}); final String type; @@ -376,7 +328,7 @@ class _SignalHook> extends Hook { _SignalHookState createState() => _SignalHookState(); } -class _SignalHookState> +class _SignalHookState> extends HookState> { @override void initHook() { From ae8b7a7567cbc76d591315da0d1d85014f6d1674 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 07:57:55 +0800 Subject: [PATCH 049/121] Fix v3 tests and examples --- examples/auth_flow/pubspec.yaml | 4 +- .../example/lib/pages/lazy_counter.dart | 2 +- packages/solidart/test/collections_test.dart | 95 +++++++++++-------- packages/solidart/test/devtools_test.dart | 16 ++-- .../solidart/test/previous_value_test.dart | 94 +++++++++++------- packages/solidart/test/resource_test.dart | 85 ++++++++++------- 6 files changed, 174 insertions(+), 122 deletions(-) diff --git a/examples/auth_flow/pubspec.yaml b/examples/auth_flow/pubspec.yaml index 92430094..8ae758db 100644 --- a/examples/auth_flow/pubspec.yaml +++ b/examples/auth_flow/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: flutter: sdk: flutter disco: ^1.0.3+1 - flutter_solidart: ^2.7.2 + flutter_solidart: 3.0.0-dev.1 go_router: ^17.0.0 localstorage: ^6.0.0 @@ -23,4 +23,4 @@ dev_dependencies: flutter: uses-material-design: true - \ No newline at end of file + diff --git a/packages/flutter_solidart/example/lib/pages/lazy_counter.dart b/packages/flutter_solidart/example/lib/pages/lazy_counter.dart index 4efeba24..994c8f54 100644 --- a/packages/flutter_solidart/example/lib/pages/lazy_counter.dart +++ b/packages/flutter_solidart/example/lib/pages/lazy_counter.dart @@ -9,7 +9,7 @@ class LazyCounterPage extends StatefulWidget { } class _LazyCounterPageState extends State { - final counter = Signal.lazy(name: 'lazyCounter'); + final counter = LazySignal(name: 'lazyCounter'); @override Widget build(BuildContext context) { diff --git a/packages/solidart/test/collections_test.dart b/packages/solidart/test/collections_test.dart index 030a50f3..46055744 100644 --- a/packages/solidart/test/collections_test.dart +++ b/packages/solidart/test/collections_test.dart @@ -33,20 +33,25 @@ void main() { test('tracks previous value after read', () { final list = ReactiveList([1, 2]); - expect(list.previousValue, isNull); + final values = ( + initial: list.previousValue, + afterAdd: (list..add(3)).previousValue, + ); - list.add(3); - - expect(list.previousValue, [1, 2]); + expect(values.initial, isNull); + expect(values.afterAdd, [1, 2]); }); test('respects trackPreviousValue false', () { final list = ReactiveList([1], trackPreviousValue: false); - list.add(2); + final values = ( + previous: (list..add(2)).previousValue, + untracked: list.untrackedPreviousValue, + ); - expect(list.previousValue, isNull); - expect(list.untrackedPreviousValue, isNull); + expect(values.previous, isNull); + expect(values.untracked, isNull); }); test('no-op mutations do not notify', () { @@ -60,15 +65,16 @@ void main() { expect(runs, 1); - list.addAll([]); - list.insertAll(1, []); - list.replaceRange(0, 0, []); - list.setAll(0, [1, 2, 3]); - list.setRange(0, 3, [1, 2, 3]); - list.fillRange(1, 1); - list.removeWhere((_) => false); - list.retainWhere((_) => true); - list.sort(); + list + ..addAll([]) + ..insertAll(1, []) + ..replaceRange(0, 0, []) + ..setAll(0, [1, 2, 3]) + ..setRange(0, 3, [1, 2, 3]) + ..fillRange(1, 1) + ..removeWhere((_) => false) + ..retainWhere((_) => true) + ..sort(); expect(runs, 1); }); @@ -84,10 +90,11 @@ void main() { expect(runs, 1); - list.clear(); - list.removeWhere((_) => true); - list.sort(); - list.shuffle(); + list + ..clear() + ..removeWhere((_) => true) + ..sort() + ..shuffle(); expect(runs, 1); }); @@ -99,7 +106,7 @@ void main() { var runs = 0; Effect(() { - map['a']; + final _ = map['a']; runs++; }); @@ -121,9 +128,9 @@ void main() { test('tracks previous value after read', () { final map = ReactiveMap({'a': 1}); - map['a'] = 2; + final previous = (map..['a'] = 2).previousValue; - expect(map.previousValue, {'a': 1}); + expect(previous, {'a': 1}); }); test('no-op mutations do not notify', () { @@ -137,10 +144,11 @@ void main() { expect(runs, 1); - map.addAll({}); - map.updateAll((key, value) => value); - map.removeWhere((_, __) => false); - map.putIfAbsent('a', () => 99); + map + ..addAll({}) + ..updateAll((key, value) => value) + ..removeWhere((key, value) => false) + ..putIfAbsent('a', () => 99); expect(runs, 1); }); @@ -156,10 +164,11 @@ void main() { expect(runs, 1); - map.clear(); - map.addAll({}); - map.removeWhere((_, __) => true); - map.updateAll((_, value) => value); + map + ..clear() + ..addAll({}) + ..removeWhere((key, value) => true) + ..updateAll((key, value) => value); expect(runs, 1); }); @@ -193,9 +202,9 @@ void main() { test('tracks previous value after read', () { final set = ReactiveSet({1}); - set.add(2); + final previous = (set..add(2)).previousValue; - expect(set.previousValue, {1}); + expect(previous, {1}); }); test('no-op mutations do not notify', () { @@ -209,11 +218,12 @@ void main() { expect(runs, 1); - set.addAll([]); - set.removeAll([]); - set.retainAll({1, 2}); - set.removeWhere((_) => false); - set.retainWhere((_) => true); + set + ..addAll([]) + ..removeAll([]) + ..retainAll({1, 2}) + ..removeWhere((_) => false) + ..retainWhere((_) => true); expect(runs, 1); }); @@ -229,10 +239,11 @@ void main() { expect(runs, 1); - set.clear(); - set.addAll([]); - set.removeAll([]); - set.retainAll({}); + set + ..clear() + ..addAll([]) + ..removeAll([]) + ..retainAll({}); expect(runs, 1); }); diff --git a/packages/solidart/test/devtools_test.dart b/packages/solidart/test/devtools_test.dart index b412e89d..13d5d39d 100644 --- a/packages/solidart/test/devtools_test.dart +++ b/packages/solidart/test/devtools_test.dart @@ -50,8 +50,9 @@ void main() { expect(observer.updated, 0); expect(observer.disposed, 0); - signal.value = 1; - signal.value; + signal + ..value = 1 + ..value; expect(observer.updated, 1); @@ -64,11 +65,10 @@ void main() { final observer = _Observer(); SolidartConfig.observers.add(observer); - final signal = Signal(0, trackInDevTools: false); - - signal.value = 1; - signal.value; - signal.dispose(); + Signal(0, trackInDevTools: false) + ..value = 1 + ..value + ..dispose(); expect(observer.created, 0); expect(observer.updated, 0); @@ -80,7 +80,7 @@ void main() { final observer = _Observer(); SolidartConfig.observers.add(observer); - final signal = Signal(0, trackInDevTools: true); + final _ = Signal(0, trackInDevTools: true); expect(observer.created, 1); }); diff --git a/packages/solidart/test/previous_value_test.dart b/packages/solidart/test/previous_value_test.dart index 4181a71f..1cf49247 100644 --- a/packages/solidart/test/previous_value_test.dart +++ b/packages/solidart/test/previous_value_test.dart @@ -1,45 +1,69 @@ import 'package:solidart/solidart.dart'; import 'package:test/test.dart'; +const _skip = Object(); + +void expectPreviousValues( + ReadonlySignal signal, { + Object? previous = _skip, + Object? untracked = _skip, +}) { + if (previous != _skip && untracked != _skip) { + final values = ( + previous: signal.previousValue, + untracked: signal.untrackedPreviousValue, + ); + + expect(values.previous, previous); + expect(values.untracked, untracked); + return; + } + + if (previous != _skip) { + expect(signal.previousValue, previous); + } + + if (untracked != _skip) { + expect(signal.untrackedPreviousValue, untracked); + } +} + void main() { group('Signal previous value', () { test('tracks previousValue and untrackedPreviousValue', () { final signal = Signal(0); - expect(signal.previousValue, isNull); - expect(signal.untrackedPreviousValue, isNull); + expectPreviousValues(signal, previous: null, untracked: null); signal.value = 1; - expect(signal.previousValue, 0); - expect(signal.untrackedPreviousValue, 0); + expectPreviousValues(signal, previous: 0, untracked: 0); signal.value = 2; - expect(signal.previousValue, 1); - expect(signal.untrackedPreviousValue, 1); + expectPreviousValues(signal, previous: 1, untracked: 1); }); test('updates previous only after read', () { final signal = Signal(0); - signal.value = 1; - - expect(signal.untrackedPreviousValue, isNull); + expectPreviousValues(signal..value = 1, untracked: null); - signal.value; + final _ = signal.value; - expect(signal.untrackedPreviousValue, 0); + expectPreviousValues(signal, untracked: 0); }); test('respects trackPreviousValue false', () { final signal = Signal(0, trackPreviousValue: false); - signal.value = 1; - signal.value; - - expect(signal.previousValue, isNull); - expect(signal.untrackedPreviousValue, isNull); + expectPreviousValues( + signal + ..value = 1 + ..value, + previous: null, + untracked: null, + ); }); }); @@ -48,13 +72,12 @@ void main() { final source = Signal(1); final computed = Computed(() => source.value * 2); - expect(computed.previousValue, isNull); + expectPreviousValues(computed, previous: null, untracked: null); expect(computed.value, 2); source.value = 2; - expect(computed.previousValue, 2); - expect(computed.untrackedPreviousValue, 2); + expectPreviousValues(computed, previous: 2, untracked: 2); expect(computed.value, 4); }); @@ -62,15 +85,19 @@ void main() { final source = Signal(1); final computed = Computed(() => source.value * 2); - computed.value; + { + final _ = computed.value; + } source.value = 2; - expect(computed.untrackedPreviousValue, isNull); + expectPreviousValues(computed, untracked: null); - computed.value; + { + final _ = computed.value; + } - expect(computed.untrackedPreviousValue, 2); + expectPreviousValues(computed, untracked: 2); }); test('respects trackPreviousValue false', () { @@ -80,12 +107,15 @@ void main() { trackPreviousValue: false, ); - computed.value; + { + final _ = computed.value; + } source.value = 2; - computed.value; + { + final _ = computed.value; + } - expect(computed.previousValue, isNull); - expect(computed.untrackedPreviousValue, isNull); + expectPreviousValues(computed, previous: null, untracked: null); }); }); @@ -98,18 +128,16 @@ void main() { test('tracks previous only after initialized and read', () { final lazy = LazySignal(); - lazy.value = 1; - - expect(lazy.previousValue, isNull); + expectPreviousValues(lazy..value = 1, previous: null, untracked: null); expect(lazy.isInitialized, isTrue); lazy.value = 2; - expect(lazy.untrackedPreviousValue, isNull); + expectPreviousValues(lazy, untracked: null); - lazy.value; + final _ = lazy.value; - expect(lazy.untrackedPreviousValue, 1); + expectPreviousValues(lazy, untracked: 1); }); }); } diff --git a/packages/solidart/test/resource_test.dart b/packages/solidart/test/resource_test.dart index 99516eeb..ecb31161 100644 --- a/packages/solidart/test/resource_test.dart +++ b/packages/solidart/test/resource_test.dart @@ -126,7 +126,7 @@ void main() { }, ); - resource.state; + final _ = resource.state; await Future.delayed(Duration.zero); expect(calls, 1); @@ -143,28 +143,31 @@ void main() { expect(resource.state.asReady?.value, 2); }); - test('fetcher error transitions to error then recovers on refresh', () async { - var shouldThrow = true; + test( + 'fetcher error transitions to error then recovers on refresh', + () async { + var shouldThrow = true; - final resource = Resource( - () async { - if (shouldThrow) { - throw StateError('boom'); - } - return 42; - }, - lazy: false, - ); + final resource = Resource( + () async { + if (shouldThrow) { + throw StateError('boom'); + } + return 42; + }, + lazy: false, + ); - await resource.resolve(); + await resource.resolve(); - expect(resource.state.hasError, isTrue); + expect(resource.state.hasError, isTrue); - shouldThrow = false; - await resource.refresh(); + shouldThrow = false; + await resource.refresh(); - expect(resource.state.asReady?.value, 42); - }); + expect(resource.state.asReady?.value, 42); + }, + ); test('source change triggers a single refresh', () async { final source = Signal(0); @@ -190,6 +193,8 @@ void main() { await Future.delayed(Duration.zero); expect(calls, 2); + + resource.dispose(); }); test('debounce groups source-triggered refreshes', () { @@ -211,16 +216,19 @@ void main() { expect(calls, 1); - source.value = 1; - source.value = 2; + source + ..value = 1 + ..value = 2; - async.elapse(const Duration(milliseconds: 49)); - async.flushMicrotasks(); + async + ..elapse(const Duration(milliseconds: 49)) + ..flushMicrotasks(); expect(calls, 1); - async.elapse(const Duration(milliseconds: 1)); - async.flushMicrotasks(); + async + ..elapse(const Duration(milliseconds: 1)) + ..flushMicrotasks(); expect(calls, 2); @@ -236,7 +244,9 @@ void main() { ); await resource.resolve(); - resource.state; + { + final _ = resource.state; + } expect(resource.untrackedPreviousState?.isLoading, isTrue); @@ -244,7 +254,9 @@ void main() { expect(resource.untrackedPreviousState?.isLoading, isTrue); - resource.state; + { + final _ = resource.state; + } expect(resource.untrackedPreviousState?.asReady?.value, 1); }); @@ -270,8 +282,9 @@ void main() { source.value = 1; resource.dispose(); - async.elapse(const Duration(milliseconds: 50)); - async.flushMicrotasks(); + async + ..elapse(const Duration(milliseconds: 50)) + ..flushMicrotasks(); expect(calls, 1); }); @@ -402,8 +415,8 @@ void main() { group('resource state extensions', () { test('flags and accessors for ready/loading/error', () { - final ready = ResourceState.ready(1, isRefreshing: true); - final loading = ResourceState.loading(); + const ready = ResourceState.ready(1, isRefreshing: true); + const loading = ResourceState.loading(); final error = ResourceState.error( StateError('boom'), stackTrace: StackTrace.current, @@ -439,14 +452,14 @@ void main() { }); test('when/maybeWhen/maybeMap behave as expected', () { - final ready = ResourceState.ready(2); + const ready = ResourceState.ready(2); final error = ResourceState.error(StateError('boom')); - final loading = ResourceState.loading(); + const loading = ResourceState.loading(); expect( ready.when( ready: (value) => 'ready $value', - error: (_, __) => 'error', + error: (_, stackTrace) => 'error', loading: () => 'loading', ), 'ready 2', @@ -455,7 +468,7 @@ void main() { expect( error.when( ready: (_) => 'ready', - error: (err, __) => err.toString(), + error: (err, stackTrace) => err.toString(), loading: () => 'loading', ), 'Bad state: boom', @@ -464,7 +477,7 @@ void main() { expect( loading.when( ready: (_) => 'ready', - error: (_, __) => 'error', + error: (_, stackTrace) => 'error', loading: () => 'loading', ), 'loading', @@ -478,7 +491,7 @@ void main() { expect( error.maybeWhen( orElse: () => 'fallback', - error: (err, __) => err.runtimeType.toString(), + error: (err, stackTrace) => err.runtimeType.toString(), ), 'StateError', ); From 321821f6fafdd76bafc136b13c8fc437314b0f80 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 08:18:51 +0800 Subject: [PATCH 050/121] Bump v3 package versions --- examples/auth_flow/pubspec.yaml | 2 +- examples/counter/pubspec.yaml | 2 +- examples/github_search/pubspec.yaml | 2 +- examples/infinite_scroll/pubspec.yaml | 2 +- examples/todos/pubspec.yaml | 2 +- examples/toggle_theme/pubspec.yaml | 2 +- packages/flutter_solidart/CHANGELOG.md | 5 +++++ packages/flutter_solidart/example/pubspec.yaml | 2 +- packages/flutter_solidart/pubspec.yaml | 4 ++-- packages/solidart/CHANGELOG.md | 8 ++++++++ packages/solidart/pubspec.yaml | 2 +- packages/solidart_devtools_extension/CHANGELOG.md | 7 +++++++ packages/solidart_devtools_extension/pubspec.yaml | 2 +- packages/solidart_hooks/CHANGELOG.md | 4 ++++ packages/solidart_hooks/pubspec.yaml | 4 ++-- packages/solidart_lint/CHANGELOG.md | 4 ++++ packages/solidart_lint/pubspec.yaml | 6 +++--- 17 files changed, 44 insertions(+), 16 deletions(-) create mode 100644 packages/solidart_devtools_extension/CHANGELOG.md diff --git a/examples/auth_flow/pubspec.yaml b/examples/auth_flow/pubspec.yaml index 8ae758db..a3be1e0a 100644 --- a/examples/auth_flow/pubspec.yaml +++ b/examples/auth_flow/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: flutter: sdk: flutter disco: ^1.0.3+1 - flutter_solidart: 3.0.0-dev.1 + flutter_solidart: 3.0.0-dev.2 go_router: ^17.0.0 localstorage: ^6.0.0 diff --git a/examples/counter/pubspec.yaml b/examples/counter/pubspec.yaml index 9ced859c..abf82037 100644 --- a/examples/counter/pubspec.yaml +++ b/examples/counter/pubspec.yaml @@ -33,7 +33,7 @@ resolution: workspace dependencies: flutter: sdk: flutter - flutter_solidart: 3.0.0-dev.1 + flutter_solidart: 3.0.0-dev.2 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/examples/github_search/pubspec.yaml b/examples/github_search/pubspec.yaml index aa86057d..5342dc67 100644 --- a/examples/github_search/pubspec.yaml +++ b/examples/github_search/pubspec.yaml @@ -33,7 +33,7 @@ dependencies: disco: ^1.0.0 flutter: sdk: flutter - flutter_solidart: 3.0.0-dev.1 + flutter_solidart: 3.0.0-dev.2 json_annotation: ^4.8.1 equatable: ^2.0.5 http: ^1.3.0 diff --git a/examples/infinite_scroll/pubspec.yaml b/examples/infinite_scroll/pubspec.yaml index 20a1f6e0..c2102896 100644 --- a/examples/infinite_scroll/pubspec.yaml +++ b/examples/infinite_scroll/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: disco: ^1.0.3+1 flutter: sdk: flutter - flutter_solidart: 3.0.0-dev.1 + flutter_solidart: 3.0.0-dev.2 http: ^1.6.0 dev_dependencies: diff --git a/examples/todos/pubspec.yaml b/examples/todos/pubspec.yaml index eb6e0ee4..d2699714 100644 --- a/examples/todos/pubspec.yaml +++ b/examples/todos/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: disco: ^1.0.0 flutter: sdk: flutter - flutter_solidart: 3.0.0-dev.1 + flutter_solidart: 3.0.0-dev.2 uuid: ^4.5.1 dev_dependencies: diff --git a/examples/toggle_theme/pubspec.yaml b/examples/toggle_theme/pubspec.yaml index 6a765f7d..ca388e58 100644 --- a/examples/toggle_theme/pubspec.yaml +++ b/examples/toggle_theme/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: disco: ^1.0.0 flutter: sdk: flutter - flutter_solidart: 3.0.0-dev.1 + flutter_solidart: 3.0.0-dev.2 dev_dependencies: flutter_test: diff --git a/packages/flutter_solidart/CHANGELOG.md b/packages/flutter_solidart/CHANGELOG.md index d23f3819..86492cc2 100644 --- a/packages/flutter_solidart/CHANGELOG.md +++ b/packages/flutter_solidart/CHANGELOG.md @@ -1,3 +1,8 @@ +## 3.0.0-dev.2 + +- **CHORE**: Bump `solidart` dependency to `3.0.0-dev.2`. +- **DOCS**: Refresh examples for `LazySignal` and v3 dependency pins. + ## 3.0.0-dev.1 - **BREAKING**: Remove Flutter-specific core wrappers under `src/core/*`; `flutter_solidart` now re-exports `solidart/solidart.dart` directly. diff --git a/packages/flutter_solidart/example/pubspec.yaml b/packages/flutter_solidart/example/pubspec.yaml index cf8115ac..6642341a 100644 --- a/packages/flutter_solidart/example/pubspec.yaml +++ b/packages/flutter_solidart/example/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: flutter: sdk: flutter http: ^1.3.0 - flutter_solidart: 3.0.0-dev.1 + flutter_solidart: 3.0.0-dev.2 dev_dependencies: flutter_test: diff --git a/packages/flutter_solidart/pubspec.yaml b/packages/flutter_solidart/pubspec.yaml index 8213b4b9..a34ca74f 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 b5d70103..2057779b 100644 --- a/packages/solidart/CHANGELOG.md +++ b/packages/solidart/CHANGELOG.md @@ -1,3 +1,11 @@ +## 3.0.0-dev.2 + +- **FEAT**: Add `untracked` and `batch` helpers for non-tracking reads and batched updates. +- **FEAT**: Add previous value tracking (`previousValue`, `untrackedPreviousValue`) and `trackPreviousValue` support. +- **FEAT**: Introduce `ReactiveList`, `ReactiveMap`, and `ReactiveSet` copy-on-write collections. +- **FEAT**: Expand `Resource` with fetcher/stream constructors, debounced source refreshes, and `previousState` access. +- **TEST**: Add v3 coverage for previous value, resources, untracked, and batch behavior. + ## 3.0.0-dev.1 - **BREAKING**: Remove all v2 implementation code (`src/core`, `src/extensions`, v2 utils). diff --git a/packages/solidart/pubspec.yaml b/packages/solidart/pubspec.yaml index 2cb92dc2..bfb05ca4 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: diff --git a/packages/solidart_devtools_extension/CHANGELOG.md b/packages/solidart_devtools_extension/CHANGELOG.md new file mode 100644 index 00000000..4ed3eb2a --- /dev/null +++ b/packages/solidart_devtools_extension/CHANGELOG.md @@ -0,0 +1,7 @@ +## 1.0.1 + +- **CHORE**: Align extension versioning with Solidart v3 dev releases. + +## 1.0.0 + +- Initial release. diff --git a/packages/solidart_devtools_extension/pubspec.yaml b/packages/solidart_devtools_extension/pubspec.yaml index c9b8add5..cd93614c 100644 --- a/packages/solidart_devtools_extension/pubspec.yaml +++ b/packages/solidart_devtools_extension/pubspec.yaml @@ -1,7 +1,7 @@ name: solidart_devtools_extension description: "solidart DevTools extension" publish_to: "none" -version: 1.0.0 +version: 1.0.1 environment: sdk: ^3.10.0 diff --git a/packages/solidart_hooks/CHANGELOG.md b/packages/solidart_hooks/CHANGELOG.md index b8951191..b940a7f3 100644 --- a/packages/solidart_hooks/CHANGELOG.md +++ b/packages/solidart_hooks/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.1.3 + +- **CHORE**: Bump `flutter_solidart` dependency to `3.0.0-dev.2`. + ## 3.1.2 ### Changes from solidart diff --git a/packages/solidart_hooks/pubspec.yaml b/packages/solidart_hooks/pubspec.yaml index 60418dfb..81794e12 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: 3.1.2 +version: 3.1.3 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 949c3ac3..5650d0fa 100644 --- a/packages/solidart_lint/CHANGELOG.md +++ b/packages/solidart_lint/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.0.3 + +- **CHORE**: Bump `solidart` and `flutter_solidart` dev dependencies to `3.0.0-dev.2`. + ## 3.0.2 - **CHORE**: Upgrade `solidart` and `flutter_solidart` dev dependencies to v3. diff --git a/packages/solidart_lint/pubspec.yaml b/packages/solidart_lint/pubspec.yaml index 694c76b9..f89cf3e5 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.0.2 +version: 3.0.3 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 089cbca41f351ef6b046415d2784ca5e80e5cfc3 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 09:17:38 +0800 Subject: [PATCH 051/121] Update docs to v3 --- README.md | 6 +- docs-v2/astro.config.mjs | 9 + .../docs/advanced/automatic_disposal.mdx | 120 ++------ .../src/content/docs/advanced/list_signal.mdx | 18 +- .../src/content/docs/advanced/map_signal.mdx | 20 +- .../src/content/docs/advanced/set_signal.mdx | 20 +- docs-v2/src/content/docs/flutter/show.mdx | 8 +- .../content/docs/flutter/signal_builder.mdx | 16 +- .../content/docs/flutter/solidart_hooks.mdx | 14 +- .../src/content/docs/learning/computed.mdx | 16 +- docs-v2/src/content/docs/learning/effects.mdx | 35 ++- .../src/content/docs/learning/resource.mdx | 14 + docs-v2/src/content/docs/learning/signal.mdx | 75 +++-- .../src/content/docs/learning/untracked.mdx | 4 +- docs-v2/src/content/docs/migration.mdx | 276 ++++++++++++++++++ 15 files changed, 449 insertions(+), 202 deletions(-) create mode 100644 docs-v2/src/content/docs/migration.mdx diff --git a/README.md b/README.md index 864d881d..7598e4aa 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ To change the value, you can use: // Set the value to 2 counter.value = 2; // Update the value based on the current value -counter.updateValue((value) => value * 2); +counter.value *= 2; ``` ### Effect @@ -177,8 +177,8 @@ SignalBuilder( ) ``` -The `on` method forces you to handle all the states of a Resource (_ready_, _error_ and _loading_). -The are also other convenience methods to handle only specific states. +The `when` method forces you to handle all the states of a Resource (_ready_, _error_ and _loading_). +There are also convenience helpers like `maybeWhen`, `asReady`, and `asError`. ### Dependency Injection diff --git a/docs-v2/astro.config.mjs b/docs-v2/astro.config.mjs index 4cf5fee2..3bf098df 100644 --- a/docs-v2/astro.config.mjs +++ b/docs-v2/astro.config.mjs @@ -62,6 +62,15 @@ export default defineConfig({ label: 'Advanced', autogenerate: { directory: 'advanced' }, }, + { + label: 'Migration', + items: [ + { + label: 'v2 -> v3', + link: '/migration', + }, + ], + }, { label: 'Examples', link: '/examples', diff --git a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx index a55092f5..9be856d4 100644 --- a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx +++ b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx @@ -6,108 +6,52 @@ sidebar: --- import { Aside } from '@astrojs/starlight/components'; - + -By default when you create a `Signal`, `Computed`, `Resource` or `Effect` the library will dispose them automatically. +In v3, `SolidartConfig.autoDispose` defaults to `false`. That means signals, +computeds, resources, and effects are not automatically disposed unless you +opt in. -You can customize this behaviour for a specific trackable Object using the options, for example for a `Signal` you have to create it with `autoDispose` set to `false`. +Enable auto-dispose globally: ```dart -final counter = Signal(0, autoDispose: false); +SolidartConfig.autoDispose = true; ``` -If you want to disable it globally instead, use +Or per instance: ```dart -SolidartConfig.autoDispose = false; +final counter = Signal(0, autoDispose: true); ``` -The automatic disposal happens automatically when there are no longer subscribers and listeners (for `Signal`, `Computed`, `Resource`) and when the __currently__ tracked dependencies are all disposed (for `Effect`). +When auto-dispose is enabled, `Signal`, `Computed`, and `Resource` will dispose +when they have no subscribers. `Effect` will dispose when its tracked +dependencies are disposed. -There is a single case that the automatic disposal won't cover: +## Disposing effects in widgets -```dart -final count = Signal(0); - -@override -void initState() { - super.initState(); - Effect(() { - print("The count is ${count.value}"); - }, - ); -} - -@override -void dispose() { - // nothing disposed manually here - super.dispose(); -} -``` - -In the example above the `count` signal will not be disposed because the `Effect` is a subscriber, and the `Effect` won't be disposed because the `count` that watches is not disposed. So they're going to be alive forever. -In order to fix this we need to dispose the `Effect` manually: - -```dart -final count = Signal(0); -late final DisposeEffect disposeEffect; - -@override -void initState() { - super.initState(); - disposeEffect = Effect(() { - print("The count is ${count.value}"); - }, - ); -} - -@override -void dispose() { - disposeEffect(); - super.dispose(); -} -``` - -In this case the `count` signal would be disposed because the subscriber is disposed and no longer watches it. -This would work also if you disposed only the `count` instead of the `Effect`. - -But I suggest to always dispose the `Effect` because it's always one, but if it is tracking multiple signals, all of them need to be disposed in order for the effect to dispose, for example: - -```dart -final count = Signal(0); -final name = Signal('Alex'); - -@override -void initState() { - super.initState(); - Effect(() { - print("The count is ${count.value} and the name is ${name.value}"); - }, - ); -} - -@override -void dispose() { - count.dispose(); - name.dispose(); - super.dispose(); -} -``` - -As you can see both the `count` and `name` signals needs to be disposed in order for the `Effect` to dispose. -This is the reason why I suggest to always dispose the `Effect`. - - - -In any case, don't worry to call `dispose()` yourself. It won't produce any error if it's already disposed. It just skips the operation. -In fact in the source code the operation is skipped if the object is already disposed: +Effects created inside widgets should be disposed in `dispose()`: ```dart -@override -void dispose() { - // ignore if already disposed - if (_disposed) return; - ... +class ExampleState extends State { + late final Effect _effect; + final count = Signal(0); + + @override + void initState() { + super.initState(); + _effect = Effect(() { + print('count: ${count.value}'); + }); + } + + @override + void dispose() { + _effect.dispose(); + count.dispose(); + super.dispose(); + } } ``` +Calling `dispose()` is always safe; it no-ops if the object is already disposed. diff --git a/docs-v2/src/content/docs/advanced/list_signal.mdx b/docs-v2/src/content/docs/advanced/list_signal.mdx index 972fdb5a..fe58f893 100644 --- a/docs-v2/src/content/docs/advanced/list_signal.mdx +++ b/docs-v2/src/content/docs/advanced/list_signal.mdx @@ -1,21 +1,21 @@ --- -title: List Signal -description: Learn ListSignal in solidart +title: Reactive List +description: Learn ReactiveList in solidart sidebar: order: 1 --- -A `ListSignal` is a special signal that can be used to represent a list of values. +A `ReactiveList` is a reactive wrapper around a `List` that copies on write. -When you modify any element on the list, the `ListSignal` will automatically notify its observers. +When you modify any element on the list, the `ReactiveList` will automatically notify its observers. ```dart -final items = ListSignal([1, 2]); +final items = ReactiveList([1, 2]); -items.observe((previousValue, value) { - print("Items changed: $previousValue -> $value"); +Effect(() { + print('Items: ${items.value}'); }); -items.add(3); // prints "Items changed: [1, 2] -> [1, 2, 3]" -items[0] = 10; // prints "Items changed: [1, 2, 3] -> [10, 2, 3]" +items.add(3); // prints "Items: [1, 2, 3]" +items[0] = 10; // prints "Items: [10, 2, 3]" ``` diff --git a/docs-v2/src/content/docs/advanced/map_signal.mdx b/docs-v2/src/content/docs/advanced/map_signal.mdx index 7fbcfa6b..55d1b3da 100644 --- a/docs-v2/src/content/docs/advanced/map_signal.mdx +++ b/docs-v2/src/content/docs/advanced/map_signal.mdx @@ -1,21 +1,21 @@ --- -title: Map Signal -description: Learn MapSignal in solidart +title: Reactive Map +description: Learn ReactiveMap in solidart sidebar: - order: 1 + order: 3 --- -A `MapSignal` is a special signal that can be used to represent a map of values. +A `ReactiveMap` is a reactive wrapper around a `Map` that copies on write. -When you modify any element on the map, the `MapSignal` will automatically notify its observers. +When you modify any element on the map, the `ReactiveMap` will automatically notify its observers. ```dart -final items = MapSignal({'a': 1, 'b': 2}); +final items = ReactiveMap({'a': 1, 'b': 2}); -items.observe((previousValue, value) { - print("Items changed: $previousValue -> $value"); +Effect(() { + print('Items: ${items.value}'); }); -items.add({'c': 3}); // prints "Items changed: {'a': 1, 'b': 2} -> {'a': 1, 'b': 2, 'c': 3}" -items.remove('a'); // prints "Items changed: {'a': 1, 'b': 2, 'c': 3} -> {'b': 2, 'c': 3}" +items['c'] = 3; // prints "Items: {a: 1, b: 2, c: 3}" +items.remove('a'); // prints "Items: {b: 2, c: 3}" ``` diff --git a/docs-v2/src/content/docs/advanced/set_signal.mdx b/docs-v2/src/content/docs/advanced/set_signal.mdx index 094dddf4..8fd7aa12 100644 --- a/docs-v2/src/content/docs/advanced/set_signal.mdx +++ b/docs-v2/src/content/docs/advanced/set_signal.mdx @@ -1,21 +1,21 @@ --- -title: Set Signal -description: Learn SetSignal in solidart +title: Reactive Set +description: Learn ReactiveSet in solidart sidebar: - order: 1 + order: 2 --- -A `SetSignal` is a special signal that can be used to represent a set of values. +A `ReactiveSet` is a reactive wrapper around a `Set` that copies on write. -When you modify any element on the set, the `SetSignal` will automatically notify its observers. +When you modify any element on the set, the `ReactiveSet` will automatically notify its observers. ```dart -final items = SetSignal({1, 2}); +final items = ReactiveSet({1, 2}); -items.observe((previousValue, value) { - print("Items changed: $previousValue -> $value"); +Effect(() { + print('Items: ${items.value}'); }); -items.add(3); // prints "Items changed: [1, 2] -> [1, 2, 3]" -items.remove(1); // prints "Items changed: [1, 2, 3] -> [2, 3]" +items.add(3); // prints "Items: {1, 2, 3}" +items.remove(1); // prints "Items: {2, 3}" ``` diff --git a/docs-v2/src/content/docs/flutter/show.mdx b/docs-v2/src/content/docs/flutter/show.mdx index 90809940..6c42c4ad 100644 --- a/docs-v2/src/content/docs/flutter/show.mdx +++ b/docs-v2/src/content/docs/flutter/show.mdx @@ -25,7 +25,7 @@ class _SampleState extends State { Widget build(BuildContext context) { return SignalBuilder( builder: (context, child) { - if (loggedIn()) return const Text('Logged in'); + if (loggedIn.value) return const Text('Logged in'); return const Text('Logged out'); }, ); @@ -48,7 +48,7 @@ class _SampleState extends State { @override Widget build(BuildContext context) { return Show( - when: loggedIn, + when: () => loggedIn.value, builder: (context) => const Text('Logged In'), fallback: (context) => const Text('Logged out'), ); @@ -59,7 +59,7 @@ class _SampleState extends State { The `Show` widget conditionally renders its `builder` or the `fallback` widget based on the `when` evaluation. The `fallback` widget builder is optional, by default nothing is rendered. -The `Show` widget takes a functions that returns a `bool`. +The `Show` widget takes a function that returns a `bool`. You can easily convert any type to `bool`, for example: ```dart {9} {13-17} @@ -76,7 +76,7 @@ class _SampleState extends State { @override Widget build(BuildContext context) { return Show( - when: () => count() > 5, + when: () => count.value > 5, builder: (context) => const Text('Count is greater than 5'), fallback: (context) => const Text('Count is lower than 6'), ); diff --git a/docs-v2/src/content/docs/flutter/signal_builder.mdx b/docs-v2/src/content/docs/flutter/signal_builder.mdx index 19b68248..08abb89e 100644 --- a/docs-v2/src/content/docs/flutter/signal_builder.mdx +++ b/docs-v2/src/content/docs/flutter/signal_builder.mdx @@ -5,16 +5,14 @@ sidebar: order: 1 --- -import { Aside } from '@astrojs/starlight/components'; +A magic widget builder that automatically rebuilds every time a signal used inside its builder changes. -A magic widget builder that automatically rebuilds everytime a signal used inside its builder changes. - -Reacts to any number of *signals* calling the `builder` each time. +Reacts to any number of signals, calling the `builder` each time. The `builder` argument must not be null. The `child` is optional but is good practice to use if part of the widget -subtree does not depend on the value of the *signals*. +subtree does not depend on the value of the signals. ```dart {9} {13-17} class SampleCounter extends StatefulWidget { @@ -38,9 +36,5 @@ class _SampleCounterState extends State { } ``` - +`SignalBuilder` tracks dependencies automatically. If no signal is read inside +`builder`, it simply builds once and does not subscribe to updates. diff --git a/docs-v2/src/content/docs/flutter/solidart_hooks.mdx b/docs-v2/src/content/docs/flutter/solidart_hooks.mdx index 6be97201..be3e9dff 100644 --- a/docs-v2/src/content/docs/flutter/solidart_hooks.mdx +++ b/docs-v2/src/content/docs/flutter/solidart_hooks.mdx @@ -200,7 +200,7 @@ class Example extends HookWidget { } ``` -## **useListSignal** +## **useReactiveList** How to create a new list signal inside of a hook widget: @@ -208,7 +208,7 @@ How to create a new list signal inside of a hook widget: class Example extends HookWidget { @override Widget build(BuildContext context) { - final items = useListSignal(['Item1', 'Item2']); + final items = useReactiveList(['Item1', 'Item2']); return Scaffold( body: Center( child: SignalBuilder( @@ -229,7 +229,7 @@ class Example extends HookWidget { The widget will automatically rebuild when the list changes. The signal will get disposed when the widget gets unmounted. -## **useSetSignal** +## **useReactiveSet** How to create a new set signal inside of a hook widget: @@ -237,7 +237,7 @@ How to create a new set signal inside of a hook widget: class Example extends HookWidget { @override Widget build(BuildContext context) { - final uniqueItems = useSetSignal({'Item1', 'Item2'}); + final uniqueItems = useReactiveSet({'Item1', 'Item2'}); return Scaffold( body: Center( child: SignalBuilder( @@ -258,7 +258,7 @@ class Example extends HookWidget { The widget will automatically rebuild when the set changes. The signal will get disposed when the widget gets unmounted. -## **useMapSignal** +## **useReactiveMap** How to create a new map signal inside of a hook widget: @@ -266,7 +266,7 @@ How to create a new map signal inside of a hook widget: class Example extends HookWidget { @override Widget build(BuildContext context) { - final userRoles = useMapSignal({'admin': 'John'}); + final userRoles = useReactiveMap({'admin': 'John'}); return Scaffold( body: Center( child: Column( @@ -281,7 +281,7 @@ class Example extends HookWidget { ), ], ), - ) + ), floatingActionButton: FloatingActionButton( onPressed: () => userRoles['user${userRoles.value.length}'] = 'User${userRoles.value.length}', child: const Icon(Icons.add), diff --git a/docs-v2/src/content/docs/learning/computed.mdx b/docs-v2/src/content/docs/learning/computed.mdx index 0140895a..b9d5fb61 100644 --- a/docs-v2/src/content/docs/learning/computed.mdx +++ b/docs-v2/src/content/docs/learning/computed.mdx @@ -56,30 +56,30 @@ class User { } // create a user signal -final user = Signal(const User(name: "name", age: 20)); +final user = Signal(const User(name: 'name', age: 20)); // create a derived signal just for the age -final age = Computed(() => user().age); +final age = Computed(() => user.value.age); // adding an effect to print the age Effect(() { - print('age changed from ${age.previousValue} into ${age.value}'); + print('age changed to ${age.value}'); }); // just update the name, the effect above doesn't run because the age has not changed -user.updateValue((value) => value.copyWith(name: 'new-name')); +user.value = user.value.copyWith(name: 'new-name'); // just update the age, the effect above prints -user.updateValue((value) => value.copyWith(age: 21)); +user.value = user.value.copyWith(age: 21); ``` -A derived signal is not of type `Signal` but is a `ReadSignal`. -The difference with a normal `Signal` is that a `ReadSignal` doesn't have a value setter, in other words it's a __read-only__ signal. +A computed is not a `Signal` but a `ReadonlySignal`. +The difference with a normal `Signal` is that a `ReadonlySignal` doesn't have a value setter. With a `Computed` you can also transform the value type: ```dart final counter = Signal(0); // type: int -final isGreaterThan5 = Computed(() => counter() > 5); // type: bool +final isGreaterThan5 = Computed(() => counter.value > 5); // type: bool ``` `isGreaterThan5` will update only when the `counter` value becomes lower/greater than `5`. diff --git a/docs-v2/src/content/docs/learning/effects.mdx b/docs-v2/src/content/docs/learning/effects.mdx index 90ea2182..5a0074ca 100644 --- a/docs-v2/src/content/docs/learning/effects.mdx +++ b/docs-v2/src/content/docs/learning/effects.mdx @@ -13,32 +13,47 @@ The effect automatically subscribes to any signal and reruns when any of them ch So let's create an Effect that reruns whenever `counter` changes: ```dart -final disposeEffect = Effect(() { - print("The count is now ${counter.value}"); +final counter = Signal(0); +final effect = Effect(() { + print('The count is now ${counter.value}'); }); ``` -The effect run immediately and prints `The count is now 0`; - +The effect runs immediately and prints `The count is now 0`. Try incrementing the counter by 1: ```dart counter.value++; ``` -The effect prints `The count is now 1`; +The effect prints `The count is now 1`. -The `Effect` class returns a `Dispose` callback, invoke it to stop listening and clearing the effect. +To stop the effect, call `dispose()`: ```dart -final disposeEffect = Effect(() { - print("The count is now ${counter.value}"); +final effect = Effect(() { + print('The count is now ${counter.value}'); }); // Somewhere else, dispose the effect -disposeEffect(); +effect.dispose(); ``` - + + +## Lazy effects + +If you want to create an effect without running it immediately, use `Effect.manual` and call `run()` later. + +```dart +final counter = Signal(0); +final effect = Effect.manual(() { + print('Count: ${counter.value}'); +}); + +counter.value = 1; // no output yet + +effect.run(); // prints "Count: 1" and starts tracking +``` diff --git a/docs-v2/src/content/docs/learning/resource.mdx b/docs-v2/src/content/docs/learning/resource.mdx index 7e040582..b342266e 100644 --- a/docs-v2/src/content/docs/learning/resource.mdx +++ b/docs-v2/src/content/docs/learning/resource.mdx @@ -38,6 +38,10 @@ SignalBuilder( ), ``` +`Resource.state` is the primary value. You can also access `previousState` and +`untrackedPreviousState` to read historical values. `previousState` updates +only after a tracked read of `state`. + A Resource can also be driven by a `Stream`. ```dart @@ -51,6 +55,16 @@ final resource = Resource.stream(() => stream); // the widget usage is the same as above, no changes needed ``` +## Lazy resources and resolve + +Resources are lazy by default. They start loading the first time you read +`state`, or when you call `resolve()` explicitly: + +```dart +final user = Resource(fetchUser, lazy: true); +await user.resolve(); // starts the fetch immediately +``` + You may ask yourself: why not just use a `Future` or a `Stream` directly? ## FutureBuilder is overcomplicated diff --git a/docs-v2/src/content/docs/learning/signal.mdx b/docs-v2/src/content/docs/learning/signal.mdx index 17b7e350..854da498 100644 --- a/docs-v2/src/content/docs/learning/signal.mdx +++ b/docs-v2/src/content/docs/learning/signal.mdx @@ -51,39 +51,24 @@ To change the value, you can use: // Set the value to 2 counter.value = 2; // Update the value based on the current value -counter.updateValue((value) => value * 2); +counter.value *= 2; ``` ## Make a read-only signal -If you want to create a signal that can only be read but not modified directly, you can use the `toReadSignal` method. +If you want to create a signal that can only be read but not modified directly, you can use the `toReadonly` method. ```dart final counter = Signal(0); -final readOnlyCounter = counter.toReadSignal(); +final readOnlyCounter = counter.toReadonly(); print(readOnlyCounter.value); // prints 0 // The code below is invalid and will cause a compile-time error because no setter is present readOnlyCounter.value = 2; ``` -## Observe the signal +## React to changes -To react to changes in a Signal you can use the `observe` method: -```dart {2-4} -final counter = Signal(0); -counter.observe((previousValue, value) { - print("Counter changed from $previousValue to $value"); -}); -``` - -By default the observer is called for the next value change, but you can also call it immediately with the current value by passing `fireImmediately: true`: -```dart {3} -counter.observe((previousValue, value) { - print("Counter changed from $previousValue to $value"); -}, fireImmediately: true); -``` - -Or use an `Effect` if you need to react to multiple signals: +Use an `Effect` to react to changes in one or more signals: ```dart {3-5} final counter = Signal(0); final doubleCounter = Signal(0); @@ -92,46 +77,56 @@ Effect(() { }); ``` -The effect will run immediately. +The effect runs immediately once, and then again when any tracked signal changes. ## Access the previous value -A `Signal` contains the previous value in addition to the current value. You can access it using the `previousValue` property: +`previousValue` is updated only after a tracked read. You can also access the +untracked version via `untrackedPreviousValue`. + ```dart final counter = Signal(0); -print(counter.hasPreviousValue); // prints false (no previous value is present) -print(counter.previousValue); // prints null (no previous value is present) +print(counter.previousValue); // null (not read yet) + +final _ = counter.value; // tracked read counter.value = 1; -print(counter.hasPreviousValue); // prints true (previous value is present) -print(counter.previousValue); // prints 0 -``` -As you can see the `previousValue` is `null` until the first time the value is changed. -You can check if a previous value is present using the `hasPreviousValue` property. +print(counter.previousValue); // 0 +print(counter.untrackedPreviousValue); // 0 +``` -By default the previous value is tracked. -If you want to disable it, you can pass `trackPreviousValue: false` to the `Signal` constructor: +By default previous values are tracked. You can disable it per-signal: ```dart final counter = Signal(0, trackPreviousValue: false); ``` -or globally using the `SolidartConfig`: +or globally using `SolidartConfig`: ```dart SolidartConfig.trackPreviousValue = false; // do it before runApp() ``` -## Await until a condition is met +## Wait for a condition (replacement for until) + +`until` was removed in v3. Use a `Completer` plus an `Effect` instead: -You can use the `until` method to wait for a signal to meet a specific condition: ```dart -final counter = Signal(0); -await counter.until((value) => value >= 5, timeout: Duration(seconds: 10)); -``` +import 'dart:async'; -By default no timeout is set, so the `until` method will wait indefinitely until the condition is met. -This is useful when you want to wait for a signal to reach a certain value before proceeding with your code. +final completer = Completer(); +late final Effect effect; + +effect = Effect(() { + final value = counter.value; + if (value >= 5 && !completer.isCompleted) { + completer.complete(value); + effect.dispose(); + } +}); + +await completer.future; +``` diff --git a/docs-v2/src/content/docs/learning/untracked.mdx b/docs-v2/src/content/docs/learning/untracked.mdx index d62a88cc..97684736 100644 --- a/docs-v2/src/content/docs/learning/untracked.mdx +++ b/docs-v2/src/content/docs/learning/untracked.mdx @@ -8,7 +8,7 @@ sidebar: Execute a callback that will not be tracked by the reactive system. -This can be useful inside Effects or Observations to prevent a signal from being tracked. +This can be useful inside Effects to prevent a signal from being tracked. ## Example @@ -17,7 +17,7 @@ final count = Signal(0); final doubleCount = Signal(0); Effect(() { - final value = count(); + final value = count.value; untracked(() { doubleCount.value = value * 2; }); diff --git a/docs-v2/src/content/docs/migration.mdx b/docs-v2/src/content/docs/migration.mdx new file mode 100644 index 00000000..776a0fde --- /dev/null +++ b/docs-v2/src/content/docs/migration.mdx @@ -0,0 +1,276 @@ +--- +title: Migration (v2 -> v3) +description: Upgrade guide for solidart v3 +--- +This guide covers the breaking changes when moving from solidart v2.x to v3. + +## 1) Update package versions + +Update all Solidart ecosystem packages together: + +```yaml +# pubspec.yaml + +dependencies: + solidart: ^3.0.0 + flutter_solidart: ^3.0.0 + solidart_hooks: ^3.1.0 + +dev_dependencies: + solidart_lint: ^3.0.0 +``` + +## 2) Imports and entry points + +The public entry points stay the same in v3. Keep using the standard imports: + +```dart +import 'package:solidart/solidart.dart'; +import 'package:flutter_solidart/flutter_solidart.dart'; +``` + +## 3) API mapping (v2 -> v3) + +### Signals and ReadSignal + +| v2 API | v3 API | Notes | +| --- | --- | --- | +| `ReadSignal` | `ReadonlySignal` | Read-only interface renamed. Use `toReadonly()` to create one. | +| `toReadSignal()` | `toReadonly()` | Read-only conversion rename. | +| `signal()` / `ReadSignal.call()` | `signal.value` | Call operator removed. | +| `signal.set(value)` | `signal.value = value` | Setter only. | +| `signal.updateValue(fn)` | `signal.value = fn(signal.value)` | No helper method. | +| `signal.observe(listener, fireImmediately)` | `Effect(() { ... })` | Use effects for reactive side effects. | +| `signal.until(condition, timeout)` | — | Removed. Use `Completer` + `Effect` (example below). | +| `hasPreviousValue` | — | Removed. Use `previousValue` / `untrackedPreviousValue`. | +| `SignalOptions` | named params | `name`, `autoDispose`, `equals`, `trackPreviousValue`, `trackInDevTools`. | + +**Until replacement (v2 -> v3):** (requires `dart:async`) +```dart +final completer = Completer(); +late final Effect effect; +effect = Effect(() { + final value = counter.value; + if (value >= 10 && !completer.isCompleted) { + completer.complete(value); + effect.dispose(); + } +}); +await completer.future; +``` + +### Computed + +| v2 API | v3 API | Notes | +| --- | --- | --- | +| `Computed(selector, options)` | `Computed(selector, ...)` | Options are named params. | +| `computed()` | `computed.value` | Call operator removed. | +| `Computed.run()` | `computed.value` | No manual run; reading evaluates lazily. | + +### Effect + +| v2 API | v3 API | Notes | +| --- | --- | --- | +| `Effect((dispose) { ... }, onError, options)` | `Effect(() { ... }, autoDispose:, name:, detach:)` | Callback no longer receives `dispose`. | +| `EffectOptions(delay: ...)` | — | Delay removed; use your own debounce/timer if needed. | +| `effect()` to dispose | `effect.dispose()` | Call operator removed. | +| `Effect.manual` | `Effect.manual` | Still available; call `run()` to start tracking. | + +### Resource + +| v2 API | v3 API | Notes | +| --- | --- | --- | +| `Resource(fetcher, source: SignalBase)` | `Resource(fetcher, source: ReadonlySignal)` | `SignalBase` removed. | +| `ResourceOptions` | named params | `name`, `autoDispose`, `equals`, `trackInDevTools`, `useRefreshing`, `debounceDelay`, `trackPreviousState`. | +| `resource.update((state) => ...)` | `resource.state = ...` | Write the new state directly. | +| `resource.select(selector)` | `Computed(() => ...)` | Use `Computed` to derive data. | +| `resource.untilReady()` | — | Removed. Use `Completer` + `Effect`. | +| `ResourceState.on / maybeOn` | `when / maybeWhen` | `on`/`maybeOn` removed. | +| `ResourceState.value` / `call()` | `asReady?.value` / `when` | Use extensions and pattern matching. | +| `ResourceUnresolved` | — | Removed; initial state is `loading`. | + +### Collections + +| v2 API | v3 API | Notes | +| --- | --- | --- | +| `ListSignal` | `ReactiveList` | Copy-on-write collection. | +| `SetSignal` | `ReactiveSet` | Copy-on-write collection. | +| `MapSignal` | `ReactiveMap` | Copy-on-write collection. | +| `useListSignal` | `useReactiveList` | Hook renamed. | +| `useSetSignal` | `useReactiveSet` | Hook renamed. | +| `useMapSignal` | `useReactiveMap` | Hook renamed. | + +### Flutter integration + +| v2 API | v3 API | Notes | +| --- | --- | --- | +| `ReadSignal.toValueNotifier()` | `ReadonlySignal.toValueNotifier()` | Now provided via extension. | +| `ValueListenable.toSignal()` | `ValueListenable.toSignal()` | Still available via extension. | + +## 4) Behavioral differences + +- **Auto-dispose default changed**: v2 defaults `SolidartConfig.autoDispose` to `true`, v3 defaults to `false`. If you relied on auto-dispose, enable it globally or per-instance: + ```dart + SolidartConfig.autoDispose = true; + final signal = Signal(0, autoDispose: true); + ``` +- **DevTools tracking default changed**: v2 defaults to `kDebugMode`, v3 defaults to `false`. Enable it explicitly if you need observer events: + ```dart + SolidartConfig.devToolsEnabled = true; + final signal = Signal(0, trackInDevTools: true); + ``` +- **Previous value tracking is read-driven**: `previousValue` / `previousState` update only after a tracked read. +- **Nullable previous values**: `hasPreviousValue` was removed, so `previousValue == null` can mean either “no previous value” or “previous value was null.” Use a sentinel or wrap nullable values if you need to distinguish. +- **Reactive collections are copy-on-write**: mutating methods replace the internal collection value. Avoid mutating `untrackedValue` directly. +- **Effect timing and errors**: `EffectOptions.delay` and `onError` were removed. Use `Timer`/debounce and `try/catch` inside the effect. +- **Nested effects**: v3 adds `detach` / `SolidartConfig.detachEffects` to control whether inner effects become dependencies of their parent. + +## 5) Signals: API changes + +### Read-only signals + +`toReadSignal()` and `ReadSignal` were removed. Use `ReadonlySignal` and +`toReadonly()` instead. + +**Before (v2):** +```dart +final counter = Signal(0); +final readOnly = counter.toReadSignal(); +``` + +**After (v3):** +```dart +final counter = Signal(0); +final readOnly = counter.toReadonly(); +``` + +### Updating values + +`updateValue` is gone. Update via `.value` and your own logic. + +**Before (v2):** +```dart +counter.updateValue((value) => value + 1); +``` + +**After (v3):** +```dart +counter.value += 1; +``` + +### Observing changes + +`observe` was removed. Use `Effect` for reactive side effects. + +**Before (v2):** +```dart +counter.observe((prev, next) { + print('from $prev to $next'); +}); +``` + +**After (v3):** +```dart +Effect(() { + final next = counter.value; + final prev = counter.untrackedPreviousValue; + print('from $prev to $next'); +}); +``` + +### Previous values + +`previousValue` now updates only after a tracked read. You also get +`untrackedPreviousValue` and can disable tracking with `trackPreviousValue`. + +```dart +final counter = Signal(0, trackPreviousValue: true); +final _ = counter.value; // tracked read +counter.value = 1; +print(counter.previousValue); // 0 +``` + +## 6) Reactive collections + +`ListSignal`, `MapSignal`, and `SetSignal` have been replaced by +`ReactiveList`, `ReactiveMap`, and `ReactiveSet`. + +**Before (v2):** +```dart +final todos = ListSignal([]); +``` + +**After (v3):** +```dart +final todos = ReactiveList([]); +``` + +### Hooks rename + +If you use `solidart_hooks`, update the hook names: + +- `useListSignal` -> `useReactiveList` +- `useSetSignal` -> `useReactiveSet` +- `useMapSignal` -> `useReactiveMap` + +## 7) Untracked and batch helpers + +Use the new global helpers to opt out of tracking or batch updates: + +```dart +untracked(() { + // reads here do not create dependencies + print(counter.value); +}); + +batch(() { + counter.value++; + other.value++; +}); +``` + +## 8) Resource updates + +Resources keep the same concept but have a clearer API: + +- `Resource.state` is the primary value. +- `previousState` / `untrackedPreviousState` are available for history. +- `Resource.stream` is the stream-based constructor. +- `debounceDelay` debounces source-triggered refreshes. +- `useRefreshing` controls `isRefreshing` behavior. + +```dart +final userId = Signal(1); +final user = Resource(fetchUser, source: userId, debounceDelay: Duration(milliseconds: 300)); + +SignalBuilder( + builder: (_, __) => user.state.when( + ready: (data) => Text(data.name), + loading: () => const CircularProgressIndicator(), + error: (e, _) => Text('Error: $e'), + ), +); +``` + +## 9) Flutter integration + +`flutter_solidart` now re-exports `solidart/solidart.dart` directly and only +adds Flutter widgets and extensions. Core wrappers were removed. + +New conversion helpers: + +```dart +final notifier = counter.toReadonly().toValueNotifier(); +final signal = someValueListenable.toSignal(); +``` + +## 10) Removed APIs (summary) + +- `toReadSignal`, `ReadSignal`, `SignalBase` +- `SignalOptions`, `ResourceOptions`, `EffectOptions` +- `updateValue`, `observe`, `until` +- `ListSignal`, `MapSignal`, `SetSignal` +- `Debouncer` and other v2-only utils + +If you hit a missing API, prefer the v3 primitives (`Signal`, `Computed`, +`Effect`, `Resource`) and the new helpers (`untracked`, `batch`). From 25e87d88b49422919bfc581c8f0fd17cfb7f3da8 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 10:04:06 +0800 Subject: [PATCH 052/121] Bump alien_signals and fix toggle_theme icons --- examples/toggle_theme/pubspec.yaml | 1 + packages/solidart/pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/toggle_theme/pubspec.yaml b/examples/toggle_theme/pubspec.yaml index ca388e58..c94f79b4 100644 --- a/examples/toggle_theme/pubspec.yaml +++ b/examples/toggle_theme/pubspec.yaml @@ -31,6 +31,7 @@ resolution: workspace # the latest version available on pub.dev. To see which dependencies have newer # versions available, run `flutter pub outdated`. dependencies: + cupertino_icons: ^1.0.2 disco: ^1.0.0 flutter: sdk: flutter diff --git a/packages/solidart/pubspec.yaml b/packages/solidart/pubspec.yaml index bfb05ca4..6fe538e3 100644 --- a/packages/solidart/pubspec.yaml +++ b/packages/solidart/pubspec.yaml @@ -14,7 +14,7 @@ resolution: workspace dependencies: # we depend on the alien signals reactivity implementation because it's the fastest available right now (30/12/2024) - alien_signals: ^2.1.0 + alien_signals: ^2.1.1 collection: ^1.18.0 meta: ^1.11.0 From 502cc567c9578da9da1f9bc7feee5eabb92de24c Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 10:37:01 +0800 Subject: [PATCH 053/121] Enable web builds for examples --- examples/auth_flow/.metadata | 2 +- examples/auth_flow/lib/routes/app_router.dart | 3 ++- examples/auth_flow/pubspec.yaml | 1 + examples/counter/.metadata | 23 ++++++------------- examples/github_search/.metadata | 23 ++++++------------- examples/github_search/pubspec.yaml | 1 + examples/infinite_scroll/.metadata | 12 +++++----- examples/infinite_scroll/pubspec.yaml | 1 + examples/todos/.metadata | 10 ++++---- examples/todos/lib/controllers/todos.dart | 6 ++--- examples/todos/lib/widgets/todos_list.dart | 2 +- examples/todos/lib/widgets/toolbar.dart | 8 +++---- examples/todos/pubspec.yaml | 1 + 13 files changed, 40 insertions(+), 53 deletions(-) diff --git a/examples/auth_flow/.metadata b/examples/auth_flow/.metadata index d5973ee1..7e9cd504 100644 --- a/examples/auth_flow/.metadata +++ b/examples/auth_flow/.metadata @@ -15,7 +15,7 @@ migration: - platform: root create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - - platform: macos + - platform: web create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 diff --git a/examples/auth_flow/lib/routes/app_router.dart b/examples/auth_flow/lib/routes/app_router.dart index 489ad514..7958b694 100644 --- a/examples/auth_flow/lib/routes/app_router.dart +++ b/examples/auth_flow/lib/routes/app_router.dart @@ -2,6 +2,7 @@ import 'package:auth_flow/notifiers/auth_notifier.dart'; import 'package:auth_flow/ui/home_page.dart'; import 'package:auth_flow/ui/login_page.dart'; import 'package:auth_flow/ui/profile_page.dart'; +import 'package:flutter_solidart/flutter_solidart.dart'; import 'package:go_router/go_router.dart'; class AppRouter { @@ -10,7 +11,7 @@ class AppRouter { final AuthNotifier authNotifier; late final router = GoRouter( - refreshListenable: authNotifier.isLoggedIn, + refreshListenable: authNotifier.isLoggedIn.toValueNotifier(), redirect: (context, state) { final isLoggedIn = authNotifier.isLoggedIn.value; if (!isLoggedIn && state.matchedLocation != '/login') { diff --git a/examples/auth_flow/pubspec.yaml b/examples/auth_flow/pubspec.yaml index a3be1e0a..0f735097 100644 --- a/examples/auth_flow/pubspec.yaml +++ b/examples/auth_flow/pubspec.yaml @@ -9,6 +9,7 @@ environment: resolution: workspace dependencies: + cupertino_icons: ^1.0.2 flutter: sdk: flutter disco: ^1.0.3+1 diff --git a/examples/counter/.metadata b/examples/counter/.metadata index d52ff02e..7e9cd504 100644 --- a/examples/counter/.metadata +++ b/examples/counter/.metadata @@ -1,11 +1,11 @@ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # -# This file should be version controlled. +# This file should be version controlled and should not be manually edited. version: - revision: 135454af32477f815a7525073027a3ff9eff1bfd - channel: stable + revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407" + channel: "stable" project_type: app @@ -13,20 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 135454af32477f815a7525073027a3ff9eff1bfd - base_revision: 135454af32477f815a7525073027a3ff9eff1bfd - - platform: android - create_revision: 135454af32477f815a7525073027a3ff9eff1bfd - base_revision: 135454af32477f815a7525073027a3ff9eff1bfd - - platform: ios - create_revision: 135454af32477f815a7525073027a3ff9eff1bfd - base_revision: 135454af32477f815a7525073027a3ff9eff1bfd - - platform: macos - create_revision: 135454af32477f815a7525073027a3ff9eff1bfd - base_revision: 135454af32477f815a7525073027a3ff9eff1bfd + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - platform: web - create_revision: 135454af32477f815a7525073027a3ff9eff1bfd - base_revision: 135454af32477f815a7525073027a3ff9eff1bfd + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 # User provided section diff --git a/examples/github_search/.metadata b/examples/github_search/.metadata index 7073f9fa..7e9cd504 100644 --- a/examples/github_search/.metadata +++ b/examples/github_search/.metadata @@ -1,11 +1,11 @@ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # -# This file should be version controlled. +# This file should be version controlled and should not be manually edited. version: - revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 - channel: stable + revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407" + channel: "stable" project_type: app @@ -13,20 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 - base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 - - platform: android - create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 - base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 - - platform: ios - create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 - base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 - - platform: macos - create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 - base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - platform: web - create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 - base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 # User provided section diff --git a/examples/github_search/pubspec.yaml b/examples/github_search/pubspec.yaml index 5342dc67..32ca2707 100644 --- a/examples/github_search/pubspec.yaml +++ b/examples/github_search/pubspec.yaml @@ -30,6 +30,7 @@ resolution: workspace # the latest version available on pub.dev. To see which dependencies have newer # versions available, run `flutter pub outdated`. dependencies: + cupertino_icons: ^1.0.2 disco: ^1.0.0 flutter: sdk: flutter diff --git a/examples/infinite_scroll/.metadata b/examples/infinite_scroll/.metadata index e9009f25..7e9cd504 100644 --- a/examples/infinite_scroll/.metadata +++ b/examples/infinite_scroll/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "f5a8537f90d143abd5bb2f658fa69c388da9677b" + revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407" channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b - base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b - - platform: macos - create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b - base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + - platform: web + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 # User provided section diff --git a/examples/infinite_scroll/pubspec.yaml b/examples/infinite_scroll/pubspec.yaml index c2102896..e617d040 100644 --- a/examples/infinite_scroll/pubspec.yaml +++ b/examples/infinite_scroll/pubspec.yaml @@ -9,6 +9,7 @@ environment: resolution: workspace dependencies: + cupertino_icons: ^1.0.2 disco: ^1.0.3+1 flutter: sdk: flutter diff --git a/examples/todos/.metadata b/examples/todos/.metadata index e5df1621..7e9cd504 100644 --- a/examples/todos/.metadata +++ b/examples/todos/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "09de023485e95e6d1225c2baa44b8feb85e0d45f" + revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407" channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 09de023485e95e6d1225c2baa44b8feb85e0d45f - base_revision: 09de023485e95e6d1225c2baa44b8feb85e0d45f + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - platform: web - create_revision: 09de023485e95e6d1225c2baa44b8feb85e0d45f - base_revision: 09de023485e95e6d1225c2baa44b8feb85e0d45f + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 # User provided section diff --git a/examples/todos/lib/controllers/todos.dart b/examples/todos/lib/controllers/todos.dart index 0bd5f103..2edbfc05 100644 --- a/examples/todos/lib/controllers/todos.dart +++ b/examples/todos/lib/controllers/todos.dart @@ -12,16 +12,16 @@ final todosControllerProvider = Provider( /// - `add`: Add a todo in the list of [todos] /// - `remove`: Removes a todo with the given id from the list of [todos] /// - `toggle`: Toggles a todo with the given id -/// The list of todos exposed is a [ReadSignal] so the user cannot mutate +/// The list of todos exposed is a [ReadonlySignal] so the user cannot mutate /// the signal without using this controller. @immutable class TodosController { TodosController({ List initialTodos = const [], - }) : todos = ListSignal(initialTodos); + }) : todos = ReactiveList(initialTodos); // The list of todos - final ListSignal todos; + final ReactiveList todos; /// The list of completed todos late final completedTodos = Computed( diff --git a/examples/todos/lib/widgets/todos_list.dart b/examples/todos/lib/widgets/todos_list.dart index c33d7ab2..cc7cf911 100644 --- a/examples/todos/lib/widgets/todos_list.dart +++ b/examples/todos/lib/widgets/todos_list.dart @@ -22,7 +22,7 @@ class _TodoListState extends State { late final todosController = todosControllerProvider.of(context); // Given a [filter] return the correct list of todos - ReadSignal> mapFilterToTodosList(TodosFilter filter) { + ReadonlySignal> mapFilterToTodosList(TodosFilter filter) { switch (filter) { case TodosFilter.all: return todosController.todos; diff --git a/examples/todos/lib/widgets/toolbar.dart b/examples/todos/lib/widgets/toolbar.dart index 5047bcd7..c8b4889e 100644 --- a/examples/todos/lib/widgets/toolbar.dart +++ b/examples/todos/lib/widgets/toolbar.dart @@ -17,12 +17,12 @@ class _ToolbarState extends State { /// All the derived signals, they will react only when the `length` property /// changes - late final allTodosCount = Computed(() => todosController.todos().length); + late final allTodosCount = Computed(() => todosController.todos.length); late final incompleteTodosCount = Computed( - () => todosController.incompleteTodos().length, + () => todosController.incompleteTodos.value.length, ); late final completedTodosCount = Computed( - () => todosController.completedTodos().length, + () => todosController.completedTodos.value.length, ); @override @@ -34,7 +34,7 @@ class _ToolbarState extends State { } /// Maps the given [filter] to the correct list of todos - ReadSignal mapFilterToTodosList(TodosFilter filter) { + ReadonlySignal mapFilterToTodosList(TodosFilter filter) { switch (filter) { case TodosFilter.all: return allTodosCount; diff --git a/examples/todos/pubspec.yaml b/examples/todos/pubspec.yaml index d2699714..634dcc9c 100644 --- a/examples/todos/pubspec.yaml +++ b/examples/todos/pubspec.yaml @@ -9,6 +9,7 @@ environment: resolution: workspace dependencies: + cupertino_icons: ^1.0.2 disco: ^1.0.0 flutter: sdk: flutter From be1517b813b5ac456f780f29598afa8dda62e462 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 10:53:00 +0800 Subject: [PATCH 054/121] Handle SignalBuilder build errors --- .../lib/src/widgets/signal_builder.dart | 5 +- .../test/signal_builder_exceptions_test.dart | 72 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 packages/flutter_solidart/test/signal_builder_exceptions_test.dart diff --git a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart index 7e3761be..26643d10 100644 --- a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart +++ b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart @@ -104,12 +104,11 @@ class _SignalBuilderElement extends StatelessElement { preset.cycle++; try { SolidartConfig.detachEffects = true; - final built = super.build(); + return super.build(); + } finally { preset.purgeDeps(_effect); _depsHead = _effect.deps; _depsTail = _effect.depsTail; - return built; - } finally { SolidartConfig.detachEffects = prevDetach; preset.setActiveSub(prevSub); _effect.flags &= ~system.ReactiveFlags.recursedCheck; diff --git a/packages/flutter_solidart/test/signal_builder_exceptions_test.dart b/packages/flutter_solidart/test/signal_builder_exceptions_test.dart new file mode 100644 index 00000000..9416c33e --- /dev/null +++ b/packages/flutter_solidart/test/signal_builder_exceptions_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:solidart/deps/preset.dart' as preset; + +class _ThrowAfterBuildSignalBuilder extends SignalBuilder { + const _ThrowAfterBuildSignalBuilder({required this.signal}) + : super(builder: _noopBuilder); + + final Signal signal; + + static Widget _noopBuilder(BuildContext context, Widget? child) { + return child ?? const SizedBox(); + } + + @override + Widget build(BuildContext context) { + signal.value; + super.build(context); + throw StateError('boom'); + } +} + +void main() { + testWidgets('SignalBuilder restores context when builder throws', ( + tester, + ) async { + final prevDetach = SolidartConfig.detachEffects; + SolidartConfig.detachEffects = false; + addTearDown(() => SolidartConfig.detachEffects = prevDetach); + + final signal = Signal(0); + await tester.pumpWidget( + MaterialApp( + home: SignalBuilder( + builder: (context, child) { + signal.value; + throw StateError('boom'); + }, + ), + ), + ); + + final exception = tester.takeException(); + expect(exception, isA()); + expect(SolidartConfig.detachEffects, isFalse); + expect(preset.getActiveSub(), isNull); + }); + + testWidgets( + 'SignalBuilder cleans up dependencies when build throws', + (tester) async { + final prevAutoDispose = SolidartConfig.autoDispose; + SolidartConfig.autoDispose = true; + addTearDown(() => SolidartConfig.autoDispose = prevAutoDispose); + + final signal = Signal(0); + await tester.pumpWidget( + MaterialApp( + home: _ThrowAfterBuildSignalBuilder(signal: signal), + ), + ); + + final exception = tester.takeException(); + expect(exception, isA()); + + await tester.pumpWidget(const SizedBox()); + expect(signal.isDisposed, isTrue); + }, + timeout: const Timeout(Duration(seconds: 1)), + ); +} From f77c2f89818c330afa6ce5373e1513d2cb34f33a Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 11:02:01 +0800 Subject: [PATCH 055/121] Fix ReactiveMap addAll no-op check --- packages/solidart/lib/src/solidart.dart | 15 +++++++++++++-- packages/solidart/test/collections_test.dart | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index a9de917c..335f4b4a 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -1048,6 +1048,16 @@ class ReactiveMap extends Signal> with MapMixin { Map _copy() => Map.of(untrackedValue); + bool _mapEquals(Map a, Map b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (final entry in a.entries) { + if (!b.containsKey(entry.key)) return false; + if (b[entry.key] != entry.value) return false; + } + return true; + } + @override V? operator [](Object? key) { value; @@ -1119,8 +1129,9 @@ class ReactiveMap extends Signal> with MapMixin { @override void addAll(Map other) { if (other.isEmpty) return; - final next = _copy()..addAll(other); - if (next.length == untrackedValue.length) return; + final current = untrackedValue; + final next = Map.of(current)..addAll(other); + if (_mapEquals(next, current)) return; value = next; } diff --git a/packages/solidart/test/collections_test.dart b/packages/solidart/test/collections_test.dart index 46055744..9367cb34 100644 --- a/packages/solidart/test/collections_test.dart +++ b/packages/solidart/test/collections_test.dart @@ -153,6 +153,24 @@ void main() { expect(runs, 1); }); + test('addAll updates existing keys', () { + final map = ReactiveMap({'a': 1}); + var runs = 0; + + Effect(() { + map['a']; + runs++; + }); + + expect(runs, 1); + + map.addAll({'a': 1}); + expect(runs, 1); + + map.addAll({'a': 2}); + expect(runs, 2); + }); + test('empty map no-op mutations do not notify', () { final map = ReactiveMap({}); var runs = 0; From f935c6e339d23ac405472bbe7f131d3c3e20b61b Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 11:16:57 +0800 Subject: [PATCH 056/121] Clarify auto-dispose behavior --- docs-v2/src/content/docs/advanced/automatic_disposal.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx index 9be856d4..1bb21728 100644 --- a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx +++ b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx @@ -6,7 +6,7 @@ sidebar: --- import { Aside } from '@astrojs/starlight/components'; - + In v3, `SolidartConfig.autoDispose` defaults to `false`. That means signals, computeds, resources, and effects are not automatically disposed unless you @@ -25,8 +25,8 @@ final counter = Signal(0, autoDispose: true); ``` When auto-dispose is enabled, `Signal`, `Computed`, and `Resource` will dispose -when they have no subscribers. `Effect` will dispose when its tracked -dependencies are disposed. +when they have no subscribers. `Effect` will dispose only after all of its +tracked dependencies are disposed (not when any single dependency is disposed). ## Disposing effects in widgets From e4ee6f64bb1128f305b11e611116b469c7b15c0b Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 11:18:58 +0800 Subject: [PATCH 057/121] Improve ReactiveMap docs --- .../src/content/docs/advanced/map_signal.mdx | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs-v2/src/content/docs/advanced/map_signal.mdx b/docs-v2/src/content/docs/advanced/map_signal.mdx index 55d1b3da..1fada863 100644 --- a/docs-v2/src/content/docs/advanced/map_signal.mdx +++ b/docs-v2/src/content/docs/advanced/map_signal.mdx @@ -6,10 +6,15 @@ sidebar: --- A `ReactiveMap` is a reactive wrapper around a `Map` that copies on write. +Copy on write means each modification produces a new map instance, so observers +always see a consistent, immutable-like snapshot of the data. -When you modify any element on the map, the `ReactiveMap` will automatically notify its observers. +When you modify any element on the map, the `ReactiveMap` will automatically +notify its observers. ```dart +import 'package:solidart/solidart.dart'; + final items = ReactiveMap({'a': 1, 'b': 2}); Effect(() { @@ -19,3 +24,20 @@ Effect(() { items['c'] = 3; // prints "Items: {a: 1, b: 2, c: 3}" items.remove('a'); // prints "Items: {b: 2, c: 3}" ``` + +## Memory management + +In Flutter contexts, signals and effects can auto-dispose when +`SolidartConfig.autoDispose` is enabled and nothing depends on them. In pure +Dart, you must dispose manually to avoid leaks: + +```dart +final items = ReactiveMap({'a': 1}); +final disposeEffect = Effect(() { + print(items.value); +}).dispose; + +// Later +disposeEffect(); +items.dispose(); +``` From 9ca698b1b72f127a769f89c8be0e11934dff2172 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 11:26:36 +0800 Subject: [PATCH 058/121] Guard devtools JSON against cycles --- packages/solidart/lib/src/solidart.dart | 37 +++++++++++++++++++++---- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index 335f4b4a..c9e52dda 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -168,20 +168,45 @@ enum _DevToolsEventType { disposed, } -dynamic _toJson(Object? obj) { +dynamic _toJson(Object? obj, [int depth = 0, Set? visited]) { + const maxDepth = 20; + if (depth > maxDepth) return ''; try { return jsonEncode(obj); } catch (_) { if (obj is List) { - return obj.map(_toJson).toList().toString(); + visited ??= Set.identity(); + if (!visited.add(obj)) return ''; + try { + return obj.map((e) => _toJson(e, depth + 1, visited)).toList().toString(); + } finally { + visited.remove(obj); + } } if (obj is Set) { - return obj.map(_toJson).toList().toString(); + visited ??= Set.identity(); + if (!visited.add(obj)) return ''; + try { + return obj.map((e) => _toJson(e, depth + 1, visited)).toList().toString(); + } finally { + visited.remove(obj); + } } if (obj is Map) { - return obj - .map((key, value) => MapEntry(_toJson(key), _toJson(value))) - .toString(); + visited ??= Set.identity(); + if (!visited.add(obj)) return ''; + try { + return obj + .map( + (key, value) => MapEntry( + _toJson(key, depth + 1, visited), + _toJson(value, depth + 1, visited), + ), + ) + .toString(); + } finally { + visited.remove(obj); + } } return jsonEncode(obj.toString()); } From 0b9623b64a7398700ff085e6bb7c856ddc510355 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 11:45:39 +0800 Subject: [PATCH 059/121] Refine docs and JSON guard --- .../docs/advanced/automatic_disposal.mdx | 7 +++- .../src/content/docs/advanced/map_signal.mdx | 4 +-- packages/solidart/lib/src/solidart.dart | 32 +++++++++++-------- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx index 1bb21728..bc3f01a5 100644 --- a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx +++ b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx @@ -6,7 +6,12 @@ sidebar: --- import { Aside } from '@astrojs/starlight/components'; - + In v3, `SolidartConfig.autoDispose` defaults to `false`. That means signals, computeds, resources, and effects are not automatically disposed unless you diff --git a/docs-v2/src/content/docs/advanced/map_signal.mdx b/docs-v2/src/content/docs/advanced/map_signal.mdx index 1fada863..300713af 100644 --- a/docs-v2/src/content/docs/advanced/map_signal.mdx +++ b/docs-v2/src/content/docs/advanced/map_signal.mdx @@ -5,8 +5,8 @@ sidebar: order: 3 --- -A `ReactiveMap` is a reactive wrapper around a `Map` that copies on write. -Copy on write means each modification produces a new map instance, so observers +A `ReactiveMap` is a reactive wrapper around a `Map` that uses copy-on-write. +Copy-on-write means each modification produces a new map instance, so observers always see a consistent, immutable-like snapshot of the data. When you modify any element on the map, the `ReactiveMap` will automatically diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index c9e52dda..fcd89ade 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -175,37 +175,43 @@ dynamic _toJson(Object? obj, [int depth = 0, Set? visited]) { return jsonEncode(obj); } catch (_) { if (obj is List) { - visited ??= Set.identity(); - if (!visited.add(obj)) return ''; + final visitedSet = visited ?? Set.identity(); + if (!visitedSet.add(obj)) return ''; try { - return obj.map((e) => _toJson(e, depth + 1, visited)).toList().toString(); + return obj + .map((e) => _toJson(e, depth + 1, visitedSet)) + .toList() + .toString(); } finally { - visited.remove(obj); + visitedSet.remove(obj); } } if (obj is Set) { - visited ??= Set.identity(); - if (!visited.add(obj)) return ''; + final visitedSet = visited ?? Set.identity(); + if (!visitedSet.add(obj)) return ''; try { - return obj.map((e) => _toJson(e, depth + 1, visited)).toList().toString(); + return obj + .map((e) => _toJson(e, depth + 1, visitedSet)) + .toList() + .toString(); } finally { - visited.remove(obj); + visitedSet.remove(obj); } } if (obj is Map) { - visited ??= Set.identity(); - if (!visited.add(obj)) return ''; + final visitedSet = visited ?? Set.identity(); + if (!visitedSet.add(obj)) return ''; try { return obj .map( (key, value) => MapEntry( - _toJson(key, depth + 1, visited), - _toJson(value, depth + 1, visited), + _toJson(key, depth + 1, visitedSet), + _toJson(value, depth + 1, visitedSet), ), ) .toString(); } finally { - visited.remove(obj); + visitedSet.remove(obj); } } return jsonEncode(obj.toString()); From 402b4e309a889c99f1c52ec1ee415837874d1030 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 11:56:01 +0800 Subject: [PATCH 060/121] Use idiomatic Effect disposal in docs --- docs-v2/src/content/docs/advanced/map_signal.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs-v2/src/content/docs/advanced/map_signal.mdx b/docs-v2/src/content/docs/advanced/map_signal.mdx index 300713af..d4fd5d9b 100644 --- a/docs-v2/src/content/docs/advanced/map_signal.mdx +++ b/docs-v2/src/content/docs/advanced/map_signal.mdx @@ -33,11 +33,11 @@ Dart, you must dispose manually to avoid leaks: ```dart final items = ReactiveMap({'a': 1}); -final disposeEffect = Effect(() { +final effect = Effect(() { print(items.value); -}).dispose; +}); // Later -disposeEffect(); +effect.dispose(); items.dispose(); ``` From 24779cc292549fa9aa1fea3999a61eea4caea268 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 11:58:16 +0800 Subject: [PATCH 061/121] Use ReactiveMap _copy helper --- packages/solidart/lib/src/solidart.dart | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index fcd89ade..9d60c54a 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -1100,7 +1100,7 @@ class ReactiveMap extends Signal> with MapMixin { final current = untrackedValue; final existing = current[key]; if (current.containsKey(key) && existing == value) return; - final next = Map.of(current); + final next = _copy(); next[key] = value; this.value = next; } @@ -1121,7 +1121,7 @@ class ReactiveMap extends Signal> with MapMixin { V? remove(Object? key) { final current = untrackedValue; if (!current.containsKey(key)) return null; - final next = Map.of(current); + final next = _copy(); final removed = next.remove(key); value = next; return removed; @@ -1161,7 +1161,7 @@ class ReactiveMap extends Signal> with MapMixin { void addAll(Map other) { if (other.isEmpty) return; final current = untrackedValue; - final next = Map.of(current)..addAll(other); + final next = _copy()..addAll(other); if (_mapEquals(next, current)) return; value = next; } @@ -1172,7 +1172,7 @@ class ReactiveMap extends Signal> with MapMixin { if (current.containsKey(key)) { return current[key] as V; } - final next = Map.of(current); + final next = _copy(); final value = ifAbsent(); next[key] = value; this.value = next; @@ -1190,14 +1190,14 @@ class ReactiveMap extends Signal> with MapMixin { if (ifAbsent == null) { throw ArgumentError.value(key, 'key', 'Key not in map.'); } - final next = Map.of(current); + final next = _copy(); final value = ifAbsent(); next[key] = value; this.value = next; return value; } - final next = Map.of(current); + final next = _copy(); final value = update(next[key] as V); next[key] = value; this.value = next; @@ -1208,7 +1208,7 @@ class ReactiveMap extends Signal> with MapMixin { void updateAll(V Function(K key, V value) update) { final current = untrackedValue; if (current.isEmpty) return; - final next = Map.of(current)..updateAll(update); + final next = _copy()..updateAll(update); if (next.length == current.length && next.keys.every((key) { return current.containsKey(key) && current[key] == next[key]; @@ -1222,7 +1222,7 @@ class ReactiveMap extends Signal> with MapMixin { void removeWhere(bool Function(K key, V value) test) { final current = untrackedValue; if (current.isEmpty) return; - final next = Map.of(current)..removeWhere(test); + final next = _copy()..removeWhere(test); if (next.length == current.length) return; value = next; } From ff72cd1ea272f040018421db7e5611280d97fa2d Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 12:12:00 +0800 Subject: [PATCH 062/121] Clarify Effect disposal guidance --- docs-v2/src/content/docs/advanced/map_signal.mdx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs-v2/src/content/docs/advanced/map_signal.mdx b/docs-v2/src/content/docs/advanced/map_signal.mdx index d4fd5d9b..71449436 100644 --- a/docs-v2/src/content/docs/advanced/map_signal.mdx +++ b/docs-v2/src/content/docs/advanced/map_signal.mdx @@ -27,9 +27,10 @@ items.remove('a'); // prints "Items: {b: 2, c: 3}" ## Memory management -In Flutter contexts, signals and effects can auto-dispose when -`SolidartConfig.autoDispose` is enabled and nothing depends on them. In pure -Dart, you must dispose manually to avoid leaks: +Signals (including `ReactiveMap`) can auto-dispose in Flutter contexts when +`SolidartConfig.autoDispose` is enabled and nothing depends on them. Effects +should always be disposed manually (automatic Effect disposal is rare in +practice), and in pure Dart you must dispose all primitives to avoid leaks: ```dart final items = ReactiveMap({'a': 1}); From ab56156d743db87e26688ee112e5e4e40ad86885 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 12:16:28 +0800 Subject: [PATCH 063/121] Use untracked in Computed --- packages/solidart/lib/src/solidart.dart | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index 9d60c54a..43f83f1f 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -1302,13 +1302,7 @@ class Computed extends preset.ComputedNode if (currentValue != null || null is T) { return currentValue as T; } - - final prevSub = preset.setActiveSub(); - try { - return value; - } finally { - preset.activeSub = prevSub; - } + return untracked(() => value); } @override From df64bc74e33075194a1356f7bc2794678b284fbc Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 21 Dec 2025 12:28:08 +0800 Subject: [PATCH 064/121] Fix collections_test lint --- packages/solidart/test/collections_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/solidart/test/collections_test.dart b/packages/solidart/test/collections_test.dart index 9367cb34..b4b952cc 100644 --- a/packages/solidart/test/collections_test.dart +++ b/packages/solidart/test/collections_test.dart @@ -158,7 +158,7 @@ void main() { var runs = 0; Effect(() { - map['a']; + final _ = map['a']; runs++; }); From 8ca2021fe943588b56c2ff78214cd95771e9a096 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 9 Jan 2026 00:02:36 +0800 Subject: [PATCH 065/121] Rename ReactiveList, ReactiveSet, and ReactiveMap to ListSignal, SetSignal, and MapSignal Update all references across the codebase including documentation, examples, and DevTools integration to use the new names. This aligns the collection signal naming with the core Signal class naming convention. --- .../src/content/docs/advanced/list_signal.mdx | 10 ++-- .../src/content/docs/advanced/map_signal.mdx | 12 ++--- .../src/content/docs/advanced/set_signal.mdx | 10 ++-- .../content/docs/flutter/solidart_hooks.mdx | 12 ++--- docs-v2/src/content/docs/migration.mdx | 12 ++--- examples/todos/lib/controllers/todos.dart | 4 +- .../example/lib/pages/list_signal.dart | 4 +- .../example/lib/pages/map_signal.dart | 4 +- .../example/lib/pages/set_signal.dart | 4 +- packages/solidart/lib/solidart.dart | 6 +-- packages/solidart/lib/src/solidart.dart | 48 +++++++++---------- packages/solidart/test/collections_test.dart | 34 ++++++------- .../solidart_devtools_extension/lib/main.dart | 14 +++--- packages/solidart_hooks/README.md | 12 ++--- .../example/lib/pages/use_reactive_list.dart | 4 +- .../example/lib/pages/use_reactive_map.dart | 4 +- .../example/lib/pages/use_reactive_set.dart | 4 +- .../solidart_hooks/lib/solidart_hooks.dart | 30 ++++++------ 18 files changed, 114 insertions(+), 114 deletions(-) diff --git a/docs-v2/src/content/docs/advanced/list_signal.mdx b/docs-v2/src/content/docs/advanced/list_signal.mdx index fe58f893..10af428a 100644 --- a/docs-v2/src/content/docs/advanced/list_signal.mdx +++ b/docs-v2/src/content/docs/advanced/list_signal.mdx @@ -1,16 +1,16 @@ --- -title: Reactive List -description: Learn ReactiveList in solidart +title: List Signal +description: Learn ListSignal in solidart sidebar: order: 1 --- -A `ReactiveList` is a reactive wrapper around a `List` that copies on write. +A `ListSignal` is a reactive wrapper around a `List` that copies on write. -When you modify any element on the list, the `ReactiveList` will automatically notify its observers. +When you modify any element on the list, the `ListSignal` will automatically notify its observers. ```dart -final items = ReactiveList([1, 2]); +final items = ListSignal([1, 2]); Effect(() { print('Items: ${items.value}'); diff --git a/docs-v2/src/content/docs/advanced/map_signal.mdx b/docs-v2/src/content/docs/advanced/map_signal.mdx index 71449436..b4c4032e 100644 --- a/docs-v2/src/content/docs/advanced/map_signal.mdx +++ b/docs-v2/src/content/docs/advanced/map_signal.mdx @@ -1,21 +1,21 @@ --- title: Reactive Map -description: Learn ReactiveMap in solidart +description: Learn MapSignal in solidart sidebar: order: 3 --- -A `ReactiveMap` is a reactive wrapper around a `Map` that uses copy-on-write. +A `MapSignal` is a reactive wrapper around a `Map` that uses copy-on-write. Copy-on-write means each modification produces a new map instance, so observers always see a consistent, immutable-like snapshot of the data. -When you modify any element on the map, the `ReactiveMap` will automatically +When you modify any element on the map, the `MapSignal` will automatically notify its observers. ```dart import 'package:solidart/solidart.dart'; -final items = ReactiveMap({'a': 1, 'b': 2}); +final items = MapSignal({'a': 1, 'b': 2}); Effect(() { print('Items: ${items.value}'); @@ -27,13 +27,13 @@ items.remove('a'); // prints "Items: {b: 2, c: 3}" ## Memory management -Signals (including `ReactiveMap`) can auto-dispose in Flutter contexts when +Signals (including `MapSignal`) can auto-dispose in Flutter contexts when `SolidartConfig.autoDispose` is enabled and nothing depends on them. Effects should always be disposed manually (automatic Effect disposal is rare in practice), and in pure Dart you must dispose all primitives to avoid leaks: ```dart -final items = ReactiveMap({'a': 1}); +final items = MapSignal({'a': 1}); final effect = Effect(() { print(items.value); }); diff --git a/docs-v2/src/content/docs/advanced/set_signal.mdx b/docs-v2/src/content/docs/advanced/set_signal.mdx index 8fd7aa12..3aa638d5 100644 --- a/docs-v2/src/content/docs/advanced/set_signal.mdx +++ b/docs-v2/src/content/docs/advanced/set_signal.mdx @@ -1,16 +1,16 @@ --- -title: Reactive Set -description: Learn ReactiveSet in solidart +title: Set Signal +description: Learn SetSignal in solidart sidebar: order: 2 --- -A `ReactiveSet` is a reactive wrapper around a `Set` that copies on write. +A `SetSignal` is a reactive wrapper around a `Set` that copies on write. -When you modify any element on the set, the `ReactiveSet` will automatically notify its observers. +When you modify any element on the set, the `SetSignal` will automatically notify its observers. ```dart -final items = ReactiveSet({1, 2}); +final items = SetSignal({1, 2}); Effect(() { print('Items: ${items.value}'); diff --git a/docs-v2/src/content/docs/flutter/solidart_hooks.mdx b/docs-v2/src/content/docs/flutter/solidart_hooks.mdx index be3e9dff..b9e39195 100644 --- a/docs-v2/src/content/docs/flutter/solidart_hooks.mdx +++ b/docs-v2/src/content/docs/flutter/solidart_hooks.mdx @@ -200,7 +200,7 @@ class Example extends HookWidget { } ``` -## **useReactiveList** +## **useListSignal** How to create a new list signal inside of a hook widget: @@ -208,7 +208,7 @@ How to create a new list signal inside of a hook widget: class Example extends HookWidget { @override Widget build(BuildContext context) { - final items = useReactiveList(['Item1', 'Item2']); + final items = useListSignal(['Item1', 'Item2']); return Scaffold( body: Center( child: SignalBuilder( @@ -229,7 +229,7 @@ class Example extends HookWidget { The widget will automatically rebuild when the list changes. The signal will get disposed when the widget gets unmounted. -## **useReactiveSet** +## **useSetSignal** How to create a new set signal inside of a hook widget: @@ -237,7 +237,7 @@ How to create a new set signal inside of a hook widget: class Example extends HookWidget { @override Widget build(BuildContext context) { - final uniqueItems = useReactiveSet({'Item1', 'Item2'}); + final uniqueItems = useSetSignal({'Item1', 'Item2'}); return Scaffold( body: Center( child: SignalBuilder( @@ -258,7 +258,7 @@ class Example extends HookWidget { The widget will automatically rebuild when the set changes. The signal will get disposed when the widget gets unmounted. -## **useReactiveMap** +## **useMapSignal** How to create a new map signal inside of a hook widget: @@ -266,7 +266,7 @@ How to create a new map signal inside of a hook widget: class Example extends HookWidget { @override Widget build(BuildContext context) { - final userRoles = useReactiveMap({'admin': 'John'}); + final userRoles = useMapSignal({'admin': 'John'}); return Scaffold( body: Center( child: Column( diff --git a/docs-v2/src/content/docs/migration.mdx b/docs-v2/src/content/docs/migration.mdx index 776a0fde..559870fc 100644 --- a/docs-v2/src/content/docs/migration.mdx +++ b/docs-v2/src/content/docs/migration.mdx @@ -93,12 +93,12 @@ await completer.future; | v2 API | v3 API | Notes | | --- | --- | --- | -| `ListSignal` | `ReactiveList` | Copy-on-write collection. | -| `SetSignal` | `ReactiveSet` | Copy-on-write collection. | -| `MapSignal` | `ReactiveMap` | Copy-on-write collection. | -| `useListSignal` | `useReactiveList` | Hook renamed. | -| `useSetSignal` | `useReactiveSet` | Hook renamed. | -| `useMapSignal` | `useReactiveMap` | Hook renamed. | +| `ListSignal` | `ListSignal` | Copy-on-write collection. | +| `SetSignal` | `SetSignal` | Copy-on-write collection. | +| `MapSignal` | `MapSignal` | Copy-on-write collection. | +| `useListSignal` | `useListSignal` | Hook for list signal. | +| `useSetSignal` | `useSetSignal` | Hook for set signal. | +| `useMapSignal` | `useMapSignal` | Hook for map signal. | ### Flutter integration diff --git a/examples/todos/lib/controllers/todos.dart b/examples/todos/lib/controllers/todos.dart index 2edbfc05..f81fae7f 100644 --- a/examples/todos/lib/controllers/todos.dart +++ b/examples/todos/lib/controllers/todos.dart @@ -18,10 +18,10 @@ final todosControllerProvider = Provider( class TodosController { TodosController({ List initialTodos = const [], - }) : todos = ReactiveList(initialTodos); + }) : todos = ListSignal(initialTodos); // The list of todos - final ReactiveList todos; + final ListSignal todos; /// The list of completed todos late final completedTodos = Computed( diff --git a/packages/flutter_solidart/example/lib/pages/list_signal.dart b/packages/flutter_solidart/example/lib/pages/list_signal.dart index 863b8ee8..ff00861e 100644 --- a/packages/flutter_solidart/example/lib/pages/list_signal.dart +++ b/packages/flutter_solidart/example/lib/pages/list_signal.dart @@ -11,7 +11,7 @@ class ListSignalPage extends StatefulWidget { } class _ListSignalPageState extends State { - final items = ReactiveList([1, 2], name: 'items'); + final items = ListSignal([1, 2], name: 'items'); @override void dispose() { @@ -22,7 +22,7 @@ class _ListSignalPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('ReactiveList')), + appBar: AppBar(title: const Text('ListSignal')), body: Padding( padding: const EdgeInsets.all(24), child: Column( diff --git a/packages/flutter_solidart/example/lib/pages/map_signal.dart b/packages/flutter_solidart/example/lib/pages/map_signal.dart index 961c7e2b..0dac848a 100644 --- a/packages/flutter_solidart/example/lib/pages/map_signal.dart +++ b/packages/flutter_solidart/example/lib/pages/map_signal.dart @@ -13,7 +13,7 @@ class MapSignalPage extends StatefulWidget { } class _MapSignalPageState extends State { - final items = ReactiveMap({'a': 1, 'b': 2}, name: 'items'); + final items = MapSignal({'a': 1, 'b': 2}, name: 'items'); @override void dispose() { @@ -31,7 +31,7 @@ class _MapSignalPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('ReactiveMap')), + appBar: AppBar(title: const Text('MapSignal')), body: Padding( padding: const EdgeInsets.all(24), child: Column( diff --git a/packages/flutter_solidart/example/lib/pages/set_signal.dart b/packages/flutter_solidart/example/lib/pages/set_signal.dart index 067cdea8..5ddfc1bc 100644 --- a/packages/flutter_solidart/example/lib/pages/set_signal.dart +++ b/packages/flutter_solidart/example/lib/pages/set_signal.dart @@ -11,7 +11,7 @@ class SetSignalPage extends StatefulWidget { } class _SetSignalPageState extends State { - final items = ReactiveSet({1, 2}, name: 'items'); + final items = SetSignal({1, 2}, name: 'items'); @override void dispose() { @@ -22,7 +22,7 @@ class _SetSignalPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('ReactiveSet')), + appBar: AppBar(title: const Text('SetSignal')), body: Padding( padding: const EdgeInsets.all(24), child: Column( diff --git a/packages/solidart/lib/solidart.dart b/packages/solidart/lib/solidart.dart index 71da3dfd..bb25138a 100644 --- a/packages/solidart/lib/solidart.dart +++ b/packages/solidart/lib/solidart.dart @@ -3,9 +3,9 @@ export 'src/solidart.dart' Computed, Effect, LazySignal, - ReactiveList, - ReactiveMap, - ReactiveSet, + ListSignal, + MapSignal, + SetSignal, ReadonlySignal, Resource, ResourceError, diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index 43f83f1f..3fe60518 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -247,9 +247,9 @@ void _notifyDevToolsAboutSignal( String _signalType(ReadonlySignal signal) => switch (signal) { Resource() => 'Resource', - ReactiveList() => 'ReactiveList', - ReactiveMap() => 'ReactiveMap', - ReactiveSet() => 'ReactiveSet', + ListSignal() => 'ListSignal', + MapSignal() => 'MapSignal', + SetSignal() => 'SetSignal', LazySignal() => 'LazySignal', Signal() => 'Signal', Computed() => 'Computed', @@ -713,12 +713,12 @@ class LazySignal extends Signal { } } -/// {@template v3-reactive-list} +/// {@template v3-list-signal} /// A reactive wrapper around a [List] that copies on write. /// /// Mutations create a new list instance so that updates are observable: /// ```dart -/// final list = ReactiveList([1, 2]); +/// final list = ListSignal([1, 2]); /// Effect(() => print(list.length)); /// list.add(3); // triggers effect /// ``` @@ -726,11 +726,11 @@ class LazySignal extends Signal { /// Reads (like `length` or index access) establish dependencies; the usual /// list API is supported. /// {@endtemplate} -class ReactiveList extends Signal> with ListMixin { - /// {@macro v3-reactive-list} +class ListSignal extends Signal> with ListMixin { + /// {@macro v3-list-signal} /// /// Creates a reactive list with the provided initial values. - ReactiveList( + ListSignal( Iterable initialValue, { bool? autoDispose, String? name, @@ -908,31 +908,31 @@ class ReactiveList extends Signal> with ListMixin { } @override - List cast() => ReactiveList(untrackedValue.cast()); + List cast() => ListSignal(untrackedValue.cast()); @override String toString() => - 'ReactiveList<$E>(value: $untrackedValue, ' + 'ListSignal<$E>(value: $untrackedValue, ' 'previousValue: $untrackedPreviousValue)'; } -/// {@template v3-reactive-set} +/// {@template v3-set-signal} /// A reactive wrapper around a [Set] that copies on write. /// /// Mutations create a new set instance so that updates are observable: /// ```dart -/// final set = ReactiveSet({1}); +/// final set = SetSignal({1}); /// Effect(() => print(set.length)); /// set.add(2); // triggers effect /// ``` /// /// Reads (like `length` or `contains`) establish dependencies. /// {@endtemplate} -class ReactiveSet extends Signal> with SetMixin { - /// {@macro v3-reactive-set} +class SetSignal extends Signal> with SetMixin { + /// {@macro v3-set-signal} /// /// Creates a reactive set with the provided initial values. - ReactiveSet( + SetSignal( Iterable initialValue, { bool? autoDispose, String? name, @@ -1037,31 +1037,31 @@ class ReactiveSet extends Signal> with SetMixin { Set toSet() => Set.of(untrackedValue); @override - Set cast() => ReactiveSet(untrackedValue.cast()); + Set cast() => SetSignal(untrackedValue.cast()); @override String toString() => - 'ReactiveSet<$E>(value: $untrackedValue, ' + 'SetSignal<$E>(value: $untrackedValue, ' 'previousValue: $untrackedPreviousValue)'; } -/// {@template v3-reactive-map} +/// {@template v3-map-signal} /// A reactive wrapper around a [Map] that copies on write. /// /// Mutations create a new map instance so that updates are observable: /// ```dart -/// final map = ReactiveMap({'a': 1}); +/// final map = MapSignal({'a': 1}); /// Effect(() => print(map['a'])); /// map['a'] = 2; // triggers effect /// ``` /// /// Reads (like `[]`, `keys`, or `length`) establish dependencies. /// {@endtemplate} -class ReactiveMap extends Signal> with MapMixin { - /// {@macro v3-reactive-map} +class MapSignal extends Signal> with MapMixin { + /// {@macro v3-map-signal} /// /// Creates a reactive map with the provided initial values. - ReactiveMap( + MapSignal( Map initialValue, { bool? autoDispose, String? name, @@ -1229,11 +1229,11 @@ class ReactiveMap extends Signal> with MapMixin { @override Map cast() => - ReactiveMap(untrackedValue.cast()); + MapSignal(untrackedValue.cast()); @override String toString() => - 'ReactiveMap<$K, $V>(value: $untrackedValue, ' + 'MapSignal<$K, $V>(value: $untrackedValue, ' 'previousValue: $untrackedPreviousValue)'; } diff --git a/packages/solidart/test/collections_test.dart b/packages/solidart/test/collections_test.dart index b4b952cc..15bdb069 100644 --- a/packages/solidart/test/collections_test.dart +++ b/packages/solidart/test/collections_test.dart @@ -2,9 +2,9 @@ import 'package:solidart/solidart.dart'; import 'package:test/test.dart'; void main() { - group('ReactiveList', () { + group('ListSignal', () { test('reacts to mutations', () { - final list = ReactiveList([1, 2]); + final list = ListSignal([1, 2]); var runs = 0; Effect(() { @@ -31,7 +31,7 @@ void main() { }); test('tracks previous value after read', () { - final list = ReactiveList([1, 2]); + final list = ListSignal([1, 2]); final values = ( initial: list.previousValue, @@ -43,7 +43,7 @@ void main() { }); test('respects trackPreviousValue false', () { - final list = ReactiveList([1], trackPreviousValue: false); + final list = ListSignal([1], trackPreviousValue: false); final values = ( previous: (list..add(2)).previousValue, @@ -55,7 +55,7 @@ void main() { }); test('no-op mutations do not notify', () { - final list = ReactiveList([1, 2, 3]); + final list = ListSignal([1, 2, 3]); var runs = 0; Effect(() { @@ -80,7 +80,7 @@ void main() { }); test('empty list no-op mutations do not notify', () { - final list = ReactiveList([]); + final list = ListSignal([]); var runs = 0; Effect(() { @@ -100,9 +100,9 @@ void main() { }); }); - group('ReactiveMap', () { + group('MapSignal', () { test('reacts to mutations', () { - final map = ReactiveMap({'a': 1}); + final map = MapSignal({'a': 1}); var runs = 0; Effect(() { @@ -126,7 +126,7 @@ void main() { }); test('tracks previous value after read', () { - final map = ReactiveMap({'a': 1}); + final map = MapSignal({'a': 1}); final previous = (map..['a'] = 2).previousValue; @@ -134,7 +134,7 @@ void main() { }); test('no-op mutations do not notify', () { - final map = ReactiveMap({'a': 1, 'b': 2}); + final map = MapSignal({'a': 1, 'b': 2}); var runs = 0; Effect(() { @@ -154,7 +154,7 @@ void main() { }); test('addAll updates existing keys', () { - final map = ReactiveMap({'a': 1}); + final map = MapSignal({'a': 1}); var runs = 0; Effect(() { @@ -172,7 +172,7 @@ void main() { }); test('empty map no-op mutations do not notify', () { - final map = ReactiveMap({}); + final map = MapSignal({}); var runs = 0; Effect(() { @@ -192,9 +192,9 @@ void main() { }); }); - group('ReactiveSet', () { + group('SetSignal', () { test('reacts to mutations', () { - final set = ReactiveSet({1}); + final set = SetSignal({1}); var runs = 0; Effect(() { @@ -218,7 +218,7 @@ void main() { }); test('tracks previous value after read', () { - final set = ReactiveSet({1}); + final set = SetSignal({1}); final previous = (set..add(2)).previousValue; @@ -226,7 +226,7 @@ void main() { }); test('no-op mutations do not notify', () { - final set = ReactiveSet({1, 2}); + final set = SetSignal({1, 2}); var runs = 0; Effect(() { @@ -247,7 +247,7 @@ void main() { }); test('empty set no-op mutations do not notify', () { - final set = ReactiveSet({}); + final set = SetSignal({}); var runs = 0; Effect(() { diff --git a/packages/solidart_devtools_extension/lib/main.dart b/packages/solidart_devtools_extension/lib/main.dart index 6e32f652..a7d3a259 100644 --- a/packages/solidart_devtools_extension/lib/main.dart +++ b/packages/solidart_devtools_extension/lib/main.dart @@ -45,9 +45,9 @@ enum SignalType { lazySignal, computed, resource, - reactiveList, - reactiveMap, - reactiveSet; + listSignal, + mapSignal, + setSignal; static SignalType byName(String name) { return switch (name) { @@ -56,9 +56,9 @@ enum SignalType { 'LazySignal' => SignalType.lazySignal, 'Computed' => SignalType.computed, 'Resource' => SignalType.resource, - 'ReactiveList' => SignalType.reactiveList, - 'ReactiveMap' => SignalType.reactiveMap, - 'ReactiveSet' => SignalType.reactiveSet, + 'ListSignal' => SignalType.listSignal, + 'MapSignal' => SignalType.mapSignal, + 'SetSignal' => SignalType.setSignal, _ => SignalType.signal, }; } @@ -108,7 +108,7 @@ class _SignalsState extends State { final searchText = Signal(''); final filterType = Signal(null); final showDisposed = Signal(true); - final signals = ReactiveMap({}); + final signals = MapSignal({}); late final filteredSignals = Computed(() { final lowercasedSearch = searchText.value.toLowerCase(); diff --git a/packages/solidart_hooks/README.md b/packages/solidart_hooks/README.md index 8c9eed81..c194fcfd 100644 --- a/packages/solidart_hooks/README.md +++ b/packages/solidart_hooks/README.md @@ -164,7 +164,7 @@ class Example extends HookWidget { } ``` -## useReactiveList +## useListSignal How to create a new list signal inside of a hook widget: @@ -172,7 +172,7 @@ How to create a new list signal inside of a hook widget: class Example extends HookWidget { @override Widget build(BuildContext context) { - final items = useReactiveList(['Item1', 'Item2']); + final items = useListSignal(['Item1', 'Item2']); return Scaffold( body: Center( child: SignalBuilder( @@ -193,7 +193,7 @@ class Example extends HookWidget { The widget will automatically rebuild when the list changes. The signal will get disposed when the widget gets unmounted. -## useReactiveSet +## useSetSignal How to create a new set signal inside of a hook widget: @@ -201,7 +201,7 @@ How to create a new set signal inside of a hook widget: class Example extends HookWidget { @override Widget build(BuildContext context) { - final uniqueItems = useReactiveSet({'Item1', 'Item2'}); + final uniqueItems = useSetSignal({'Item1', 'Item2'}); return Scaffold( body: Center( child: SignalBuilder( @@ -222,7 +222,7 @@ class Example extends HookWidget { The widget will automatically rebuild when the set changes. The signal will get disposed when the widget gets unmounted. -## useReactiveMap +## useMapSignal How to create a new map signal inside of a hook widget: @@ -230,7 +230,7 @@ How to create a new map signal inside of a hook widget: class Example extends HookWidget { @override Widget build(BuildContext context) { - final userRoles = useReactiveMap({'admin': 'John'}); + final userRoles = useMapSignal({'admin': 'John'}); return Scaffold( body: Center( child: Column( diff --git a/packages/solidart_hooks/example/lib/pages/use_reactive_list.dart b/packages/solidart_hooks/example/lib/pages/use_reactive_list.dart index ee25eafe..e25485fb 100644 --- a/packages/solidart_hooks/example/lib/pages/use_reactive_list.dart +++ b/packages/solidart_hooks/example/lib/pages/use_reactive_list.dart @@ -7,10 +7,10 @@ class UseReactiveListExample extends HookWidget { @override Widget build(BuildContext context) { - final items = useReactiveList(['Item1', 'Item2']); + final items = useListSignal(['Item1', 'Item2']); return Scaffold( - appBar: AppBar(title: const Text('useReactiveList')), + appBar: AppBar(title: const Text('useListSignal')), body: Center( child: SignalBuilder( builder: (context, child) { diff --git a/packages/solidart_hooks/example/lib/pages/use_reactive_map.dart b/packages/solidart_hooks/example/lib/pages/use_reactive_map.dart index 417ef9eb..63c36d0f 100644 --- a/packages/solidart_hooks/example/lib/pages/use_reactive_map.dart +++ b/packages/solidart_hooks/example/lib/pages/use_reactive_map.dart @@ -7,9 +7,9 @@ class UseReactiveMapExample extends HookWidget { @override Widget build(BuildContext context) { - final userRoles = useReactiveMap({'admin': 'John'}); + final userRoles = useMapSignal({'admin': 'John'}); return Scaffold( - appBar: AppBar(title: const Text('useReactiveMap')), + appBar: AppBar(title: const Text('useMapSignal')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/packages/solidart_hooks/example/lib/pages/use_reactive_set.dart b/packages/solidart_hooks/example/lib/pages/use_reactive_set.dart index 0f4524e0..1c8c8426 100644 --- a/packages/solidart_hooks/example/lib/pages/use_reactive_set.dart +++ b/packages/solidart_hooks/example/lib/pages/use_reactive_set.dart @@ -7,10 +7,10 @@ class UseReactiveSetExample extends HookWidget { @override Widget build(BuildContext context) { - final uniqueItems = useReactiveSet({'Item1', 'Item2'}); + final uniqueItems = useSetSignal({'Item1', 'Item2'}); return Scaffold( - appBar: AppBar(title: const Text('useReactiveSet')), + appBar: AppBar(title: const Text('useSetSignal')), body: Center( child: SignalBuilder( builder: (context, child) { diff --git a/packages/solidart_hooks/lib/solidart_hooks.dart b/packages/solidart_hooks/lib/solidart_hooks.dart index 5c9898e5..b0573ace 100644 --- a/packages/solidart_hooks/lib/solidart_hooks.dart +++ b/packages/solidart_hooks/lib/solidart_hooks.dart @@ -49,15 +49,15 @@ Signal useSignal( return use(_SignalHook('useSignal', target)); } -/// Create a [ReactiveList] inside a hook widget. -ReactiveList useReactiveList( +/// Create a [ListSignal] inside a hook widget. +ListSignal useListSignal( /// The initial value of the signal. Iterable initialValue, { /// Optional name used by DevTools. String? name, - /// Whether the reactive list should auto-dispose when unused. + /// Whether the list signal should auto-dispose when unused. bool? autoDispose, /// Whether to report updates to DevTools. @@ -70,7 +70,7 @@ ReactiveList useReactiveList( bool? trackPreviousValue, }) { final target = useMemoized( - () => ReactiveList( + () => ListSignal( initialValue, autoDispose: autoDispose, name: name, @@ -80,18 +80,18 @@ ReactiveList useReactiveList( ), [], ); - return use(_SignalHook('useReactiveList', target)); + return use(_SignalHook('useListSignal', target)); } -/// Create a [ReactiveSet] inside a hook widget. -ReactiveSet useReactiveSet( +/// Create a [SetSignal] inside a hook widget. +SetSignal useSetSignal( /// The initial value of the signal. Iterable initialValue, { /// Optional name used by DevTools. String? name, - /// Whether the reactive set should auto-dispose when unused. + /// Whether the set signal should auto-dispose when unused. bool? autoDispose, /// Whether to report updates to DevTools. @@ -104,7 +104,7 @@ ReactiveSet useReactiveSet( bool? trackPreviousValue, }) { final target = useMemoized( - () => ReactiveSet( + () => SetSignal( initialValue, autoDispose: autoDispose, name: name, @@ -114,18 +114,18 @@ ReactiveSet useReactiveSet( ), [], ); - return use(_SignalHook('useReactiveSet', target)); + return use(_SignalHook('useSetSignal', target)); } -/// Create a [ReactiveMap] inside a hook widget. -ReactiveMap useReactiveMap( +/// Create a [MapSignal] inside a hook widget. +MapSignal useMapSignal( /// The initial value of the signal. Map initialValue, { /// Optional name used by DevTools. String? name, - /// Whether the reactive map should auto-dispose when unused. + /// Whether the map signal should auto-dispose when unused. bool? autoDispose, /// Whether to report updates to DevTools. @@ -138,7 +138,7 @@ ReactiveMap useReactiveMap( bool? trackPreviousValue, }) { final target = useMemoized( - () => ReactiveMap( + () => MapSignal( initialValue, autoDispose: autoDispose, name: name, @@ -148,7 +148,7 @@ ReactiveMap useReactiveMap( ), [], ); - return use(_SignalHook('useReactiveMap', target)); + return use(_SignalHook('useMapSignal', target)); } /// Create a new computed signal From 0968d977b85c30451b873b8e3670bd37092beed0 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 9 Jan 2026 00:12:26 +0800 Subject: [PATCH 066/121] remove unused config --- benchmark.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/benchmark.dart b/benchmark.dart index 4d5289f4..3574917e 100644 --- a/benchmark.dart +++ b/benchmark.dart @@ -39,7 +39,6 @@ void main() { solidart.SolidartConfig.devToolsEnabled = false; solidart.SolidartConfig.trackPreviousValue = false; solidart.SolidartConfig.autoDispose = false; - solidart.SolidartConfig.equals = true; const framework = SolidartReactiveFramework(); runFrameworkBench(framework); } From b7a5bb8981caba4b393549acbf35797768ab2548 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 9 Jan 2026 00:25:35 +0800 Subject: [PATCH 067/121] Add comprehensive collection signal tests and fix formatting --- examples/auth_flow/lib/domain/user.dart | 7 +- examples/auth_flow/lib/main.dart | 8 +- examples/auth_flow/lib/routes/app_router.dart | 4 +- examples/auth_flow/lib/ui/login_page.dart | 8 +- .../flutter_solidart/example/lib/main.dart | 4 +- packages/solidart/lib/solidart.dart | 2 +- packages/solidart/lib/src/solidart.dart | 6 + packages/solidart/test/collections_test.dart | 330 ++++++++++++++++++ packages/solidart/test/effect_test.dart | 1 - packages/solidart/test/equals_test.dart | 21 ++ packages/solidart/test/option_test.dart | 26 ++ packages/solidart/test/resource_test.dart | 69 ++++ .../solidart_devtools_extension/lib/main.dart | 3 +- .../solidart_hooks/lib/solidart_hooks.dart | 8 +- 14 files changed, 478 insertions(+), 19 deletions(-) create mode 100644 packages/solidart/test/option_test.dart diff --git a/examples/auth_flow/lib/domain/user.dart b/examples/auth_flow/lib/domain/user.dart index 46f4d755..1eb2d622 100644 --- a/examples/auth_flow/lib/domain/user.dart +++ b/examples/auth_flow/lib/domain/user.dart @@ -8,8 +8,11 @@ class User { final String name; final String email; - factory User.fromMap(Map map) => - User(id: map['id'] as String, name: map['name'] as String, email: map['email'] as String); + factory User.fromMap(Map map) => User( + id: map['id'] as String, + name: map['name'] as String, + email: map['email'] as String, + ); Map toMap() => {'id': id, 'name': name, 'email': email}; } diff --git a/examples/auth_flow/lib/main.dart b/examples/auth_flow/lib/main.dart index 3fe4f614..a6d4245a 100644 --- a/examples/auth_flow/lib/main.dart +++ b/examples/auth_flow/lib/main.dart @@ -9,7 +9,9 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await initLocalStorage(); - runApp(ProviderScope(providers: [AuthNotifier.provider], child: const MyApp())); + runApp( + ProviderScope(providers: [AuthNotifier.provider], child: const MyApp()), + ); } class MyApp extends StatefulWidget { @@ -39,7 +41,9 @@ class _MyAppState extends State { return MaterialApp.router( title: 'Auth Demo - GoRouter', debugShowCheckedModeBanner: false, - theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple)), + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + ), routerConfig: router, ); } diff --git a/examples/auth_flow/lib/routes/app_router.dart b/examples/auth_flow/lib/routes/app_router.dart index 7958b694..58aceffd 100644 --- a/examples/auth_flow/lib/routes/app_router.dart +++ b/examples/auth_flow/lib/routes/app_router.dart @@ -26,7 +26,9 @@ class AppRouter { GoRoute( path: '/', builder: (_, _) => const HomePage(title: 'Home'), - routes: [GoRoute(path: 'profile', builder: (_, _) => const ProfilePage())], + routes: [ + GoRoute(path: 'profile', builder: (_, _) => const ProfilePage()), + ], ), GoRoute(path: '/login', builder: (_, _) => const LoginPage()), ], diff --git a/examples/auth_flow/lib/ui/login_page.dart b/examples/auth_flow/lib/ui/login_page.dart index aba7ae49..a89e10d7 100644 --- a/examples/auth_flow/lib/ui/login_page.dart +++ b/examples/auth_flow/lib/ui/login_page.dart @@ -16,7 +16,13 @@ class LoginPage extends StatelessWidget { ElevatedButton( onPressed: () { final controller = AuthNotifier.provider.of(context); - controller.login(User(id: '1', name: 'John Doe', email: 'john.doe@example.com')); + controller.login( + User( + id: '1', + name: 'John Doe', + email: 'john.doe@example.com', + ), + ); }, child: Text('Login'), ), diff --git a/packages/flutter_solidart/example/lib/main.dart b/packages/flutter_solidart/example/lib/main.dart index 4140ede2..5744bfd5 100644 --- a/packages/flutter_solidart/example/lib/main.dart +++ b/packages/flutter_solidart/example/lib/main.dart @@ -19,9 +19,7 @@ class Logger implements SolidartObserver { @override void didCreateSignal(ReadonlySignal signal) { final value = _safeValue(signal); - dev.log( - 'didCreateSignal(name: ${signal.identifier.name}, value: $value)', - ); + dev.log('didCreateSignal(name: ${signal.identifier.name}, value: $value)'); } @override diff --git a/packages/solidart/lib/solidart.dart b/packages/solidart/lib/solidart.dart index bb25138a..4a4fed2b 100644 --- a/packages/solidart/lib/solidart.dart +++ b/packages/solidart/lib/solidart.dart @@ -5,7 +5,6 @@ export 'src/solidart.dart' LazySignal, ListSignal, MapSignal, - SetSignal, ReadonlySignal, Resource, ResourceError, @@ -13,6 +12,7 @@ export 'src/solidart.dart' ResourceReady, ResourceState, ResourceStateExtensions, + SetSignal, Signal, SolidartConfig, SolidartObserver, diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index 3fe60518..b62295b8 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -168,6 +168,7 @@ enum _DevToolsEventType { disposed, } +// coverage:ignore-start dynamic _toJson(Object? obj, [int depth = 0, Set? visited]) { const maxDepth = 20; if (depth > maxDepth) return ''; @@ -217,6 +218,7 @@ dynamic _toJson(Object? obj, [int depth = 0, Set? visited]) { return jsonEncode(obj.toString()); } } +// coverage:ignore-end void _notifyDevToolsAboutSignal( ReadonlySignal signal, { @@ -256,6 +258,7 @@ String _signalType(ReadonlySignal signal) => switch (signal) { _ => 'ReadonlySignal', }; +// coverage:ignore-start int _listenerCount(system.ReactiveNode node) { var count = 0; var link = node.subs; @@ -265,7 +268,9 @@ int _listenerCount(system.ReactiveNode node) { } return count; } +// coverage:ignore-end +// coverage:ignore-start bool _hasPreviousValue(ReadonlySignal signal) { if (!signal.trackPreviousValue) return false; if (signal is Signal) { @@ -309,6 +314,7 @@ Object? _computedValue(Computed signal) { } return null; } +// coverage:ignore-end /// Runs [callback] without tracking dependencies. /// diff --git a/packages/solidart/test/collections_test.dart b/packages/solidart/test/collections_test.dart index 15bdb069..d8c334a9 100644 --- a/packages/solidart/test/collections_test.dart +++ b/packages/solidart/test/collections_test.dart @@ -98,6 +98,164 @@ void main() { expect(runs, 1); }); + + test('length setter modifies list', () { + final list = ListSignal([1, 2, 3, 4]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + expect(list.length, 4); + + // Test shortening the list + list.length = 2; + expect(runs, 2); + expect(list.value, [1, 2]); + + // Test setting to current length (no-op) + list.length = 2; + expect(runs, 2); + }); + + test('[] operator reads elements', () { + final list = ListSignal([1, 2, 3]); + var runs = 0; + + Effect(() { + list[1]; + runs++; + }); + + expect(runs, 1); + expect(list[0], 1); + expect(list[1], 2); + expect(list[2], 3); + + list[1] = 5; + expect(runs, 2); + }); + + test('insert and insertAll add elements', () { + final list = ListSignal([1, 3]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.insert(1, 2); + expect(runs, 2); + expect(list.value, [1, 2, 3]); + + list.insertAll(0, [-1, 0]); + expect(runs, 3); + expect(list.value, [-1, 0, 1, 2, 3]); + }); + + test('removeRange removes elements', () { + final list = ListSignal([1, 2, 3, 4, 5]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.removeRange(1, 3); + expect(runs, 2); + expect(list.value, [1, 4, 5]); + }); + + test('replaceRange replaces elements', () { + final list = ListSignal([1, 2, 3, 4]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.replaceRange(1, 3, [10, 20]); + expect(runs, 2); + expect(list.value, [1, 10, 20, 4]); + }); + + test('setAll modifies elements', () { + final list = ListSignal([1, 2, 3, 4]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.setAll(1, [10, 20]); + expect(runs, 2); + expect(list.value, [1, 10, 20, 4]); + }); + + test('setRange modifies elements', () { + final list = ListSignal([1, 2, 3, 4]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.setRange(1, 3, [10, 20]); + expect(runs, 2); + expect(list.value, [1, 10, 20, 4]); + }); + + test('fillRange fills elements', () { + final list = ListSignal([1, 2, 3, 4]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.fillRange(1, 3, 0); + expect(runs, 2); + expect(list.value, [1, 0, 0, 4]); + }); + + test('shuffle randomizes list', () { + final list = ListSignal([1, 2, 3, 4, 5]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.shuffle(); + expect(runs, 2); + expect(list.length, 5); + // Can't test exact order due to randomness, but all elements should still exist + expect(list.value.toSet(), {1, 2, 3, 4, 5}); + }); }); group('MapSignal', () { @@ -190,6 +348,113 @@ void main() { expect(runs, 1); }); + + test('putIfAbsent adds new keys', () { + final map = MapSignal({'a': 1}); + var runs = 0; + + Effect(() { + map.length; + runs++; + }); + + expect(runs, 1); + + final value1 = map.putIfAbsent('a', () => 99); + expect(value1, 1); + expect(runs, 1); + + final value2 = map.putIfAbsent('b', () => 2); + expect(value2, 2); + expect(runs, 2); + expect(map.value, {'a': 1, 'b': 2}); + }); + + test('update modifies existing keys', () { + final map = MapSignal({'a': 1, 'b': 2}); + var runs = 0; + + Effect(() { + map['a']; + runs++; + }); + + expect(runs, 1); + + map.update('a', (value) => value + 10); + expect(runs, 2); + expect(map['a'], 11); + + map.update('c', (value) => value, ifAbsent: () => 3); + expect(runs, 3); + expect(map['c'], 3); + }); + + test('updateAll modifies all values', () { + final map = MapSignal({'a': 1, 'b': 2}); + var runs = 0; + + Effect(() { + map.length; + runs++; + }); + + expect(runs, 1); + + map.updateAll((key, value) => value * 2); + expect(runs, 2); + expect(map.value, {'a': 2, 'b': 4}); + }); + + test('removeWhere removes matching entries', () { + final map = MapSignal({'a': 1, 'b': 2, 'c': 3}); + var runs = 0; + + Effect(() { + map.length; + runs++; + }); + + expect(runs, 1); + + map.removeWhere((key, value) => value % 2 == 0); + expect(runs, 2); + expect(map.value, {'a': 1, 'c': 3}); + }); + + test('containsKey checks for keys', () { + final map = MapSignal({'a': 1, 'b': 2}); + var runs = 0; + + Effect(() { + map.containsKey('a'); + runs++; + }); + + expect(runs, 1); + expect(map.containsKey('a'), true); + expect(map.containsKey('c'), false); + + map['c'] = 3; + expect(runs, 2); + }); + + test('containsValue checks for values', () { + final map = MapSignal({'a': 1, 'b': 2}); + var runs = 0; + + Effect(() { + map.containsValue(1); + runs++; + }); + + expect(runs, 1); + expect(map.containsValue(1), true); + expect(map.containsValue(3), false); + + map['a'] = 10; + expect(runs, 2); + }); }); group('SetSignal', () { @@ -265,5 +530,70 @@ void main() { expect(runs, 1); }); + + test('lookup finds elements', () { + final set = SetSignal({1, 2, 3}); + var runs = 0; + + Effect(() { + set.lookup(2); + runs++; + }); + + expect(runs, 1); + expect(set.lookup(2), 2); + expect(set.lookup(4), isNull); + + set.add(4); + expect(runs, 2); + }); + + test('addAll with existing elements', () { + final set = SetSignal({1, 2}); + var runs = 0; + + Effect(() { + set.length; + runs++; + }); + + expect(runs, 1); + + set.addAll([1, 2, 3, 4]); + expect(runs, 2); + expect(set.value, {1, 2, 3, 4}); + }); + + test('removeAll removes multiple elements', () { + final set = SetSignal({1, 2, 3, 4, 5}); + var runs = 0; + + Effect(() { + set.length; + runs++; + }); + + expect(runs, 1); + + set.removeAll([2, 4]); + expect(runs, 2); + expect(set.value, {1, 3, 5}); + }); + + test('retainAll keeps only specified elements', () { + final set = SetSignal({1, 2, 3, 4, 5}); + var runs = 0; + + Effect(() { + set.length; + runs++; + }); + + expect(runs, 1); + + set.retainAll([2, 4, 6]); + expect(runs, 2); + expect(set.value, {2, 4}); + }); }); } diff --git a/packages/solidart/test/effect_test.dart b/packages/solidart/test/effect_test.dart index f2709614..5917c0e0 100644 --- a/packages/solidart/test/effect_test.dart +++ b/packages/solidart/test/effect_test.dart @@ -142,7 +142,6 @@ void main() { child.dispose(); }); - } List _depsOf(system.ReactiveNode node) { diff --git a/packages/solidart/test/equals_test.dart b/packages/solidart/test/equals_test.dart index a56bc938..6e8bccbd 100644 --- a/packages/solidart/test/equals_test.dart +++ b/packages/solidart/test/equals_test.dart @@ -50,4 +50,25 @@ void main() { source.value = 3; // parity changed (odd), recompute expect(runs, 2); }); + + test('Signal.toReadonly() converts to readonly', () { + final signal = Signal(42); + final readonly = signal.toReadonly(); + + expect(readonly, isA>()); + expect(readonly.value, 42); + + // Verify it tracks changes + var runs = 0; + Effect(() { + readonly.value; + runs++; + }); + + expect(runs, 1); + + signal.value = 100; + expect(runs, 2); + expect(readonly.value, 100); + }); } diff --git a/packages/solidart/test/option_test.dart b/packages/solidart/test/option_test.dart new file mode 100644 index 00000000..4cfeaf95 --- /dev/null +++ b/packages/solidart/test/option_test.dart @@ -0,0 +1,26 @@ +import 'package:solidart/src/solidart.dart'; +import 'package:test/test.dart'; + +void main() { + group('Option', () { + test('Some.unwrap() returns value', () { + final some = Some(42); + expect(some.unwrap(), 42); + }); + + test('None.unwrap() throws StateError', () { + final none = None(); + expect(() => none.unwrap(), throwsStateError); + }); + + test('Some.safeUnwrap() returns value', () { + final some = Some(42); + expect(some.safeUnwrap(), 42); + }); + + test('None.safeUnwrap() returns null', () { + final none = None(); + expect(none.safeUnwrap(), isNull); + }); + }); +} diff --git a/packages/solidart/test/resource_test.dart b/packages/solidart/test/resource_test.dart index ecb31161..1ea2c87a 100644 --- a/packages/solidart/test/resource_test.dart +++ b/packages/solidart/test/resource_test.dart @@ -504,5 +504,74 @@ void main() { 'loading', ); }); + + test('ResourceReady equality and copyWith', () { + final ready1 = ResourceReady(42); + final ready2 = ResourceReady(42); + final ready3 = ResourceReady(43); + + expect(ready1, equals(ready2)); + expect(ready1, isNot(equals(ready3))); + expect(ready1.hashCode, equals(ready2.hashCode)); + + final copied = ready1.copyWith(value: 100); + expect(copied.value, 100); + expect(copied, isNot(equals(ready1))); + }); + + test('ResourceError equality and copyWith', () { + final error1 = ResourceError('error1', stackTrace: StackTrace.empty); + final error2 = ResourceError('error1', stackTrace: StackTrace.empty); + final error3 = ResourceError('error2', stackTrace: StackTrace.empty); + + expect(error1, equals(error2)); + expect(error1, isNot(equals(error3))); + expect(error1.hashCode, equals(error2.hashCode)); + + final copied = error1.copyWith(error: 'new error'); + expect(copied.error, 'new error'); + expect(copied, isNot(equals(error1))); + + final copiedStack = error1.copyWith(stackTrace: StackTrace.current); + expect(copiedStack.stackTrace, isNot(equals(error1.stackTrace))); + }); + + test('ResourceLoading equality and hashCode', () { + const loading1 = ResourceLoading(); + const loading2 = ResourceLoading(); + + expect(loading1, equals(loading2)); + expect(loading1.hashCode, equals(loading2.hashCode)); + }); + }); + + group('Resource previousState', () { + test('tracks previous state after transitions', () async { + final resource = Resource(() async => 42); + + expect(resource.previousState, isNull); + + await resource.resolve(); + expect(resource.state.asReady?.value, 42); + expect(resource.previousState?.isLoading, isTrue); + + await resource.refresh(); + expect(resource.previousState?.asReady?.value, 42); + }); + + test('untrackedPreviousState does not create dependencies', () async { + final resource = Resource(() async => 42); + var runs = 0; + + Effect(() { + resource.untrackedPreviousState; + runs++; + }); + + expect(runs, 1); + + await resource.resolve(); + expect(runs, 1); // Should not trigger effect + }); }); } diff --git a/packages/solidart_devtools_extension/lib/main.dart b/packages/solidart_devtools_extension/lib/main.dart index a7d3a259..d83be792 100644 --- a/packages/solidart_devtools_extension/lib/main.dart +++ b/packages/solidart_devtools_extension/lib/main.dart @@ -138,8 +138,7 @@ class _SignalsState extends State { sub = vmService.onExtensionEvent .where((e) { final kind = e.extensionKind; - return kind != null && - kind.startsWith('ext.solidart.v3.signal'); + return kind != null && kind.startsWith('ext.solidart.v3.signal'); }) .listen((event) { final data = event.extensionData?.data; diff --git a/packages/solidart_hooks/lib/solidart_hooks.dart b/packages/solidart_hooks/lib/solidart_hooks.dart index b0573ace..edb7f263 100644 --- a/packages/solidart_hooks/lib/solidart_hooks.dart +++ b/packages/solidart_hooks/lib/solidart_hooks.dart @@ -284,17 +284,13 @@ Resource useResourceStream( ), [], ); - return use( - _SignalHook( - 'useResourceStream', - target, - ), - ); + return use(_SignalHook('useResourceStream', target)); } /// Create an effect inside a hook widget. void useSolidartEffect( VoidCallback cb, { + /// The name of the effect, useful for logging. String? name, From b4221dc78ddf4a56fa50573e0217796fe992cd52 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 9 Jan 2026 00:34:58 +0800 Subject: [PATCH 068/121] Rename reactive collection hooks to use *Signal naming --- .../src/content/docs/advanced/map_signal.mdx | 2 +- docs-v2/src/content/docs/migration.mdx | 18 +++++------------- packages/solidart_hooks/example/lib/main.dart | 18 +++++++++--------- .../example/lib/pages/use_reactive_list.dart | 4 ++-- .../example/lib/pages/use_reactive_map.dart | 4 ++-- .../example/lib/pages/use_reactive_set.dart | 4 ++-- .../solidart_hooks/lib/solidart_hooks.dart | 4 ++-- 7 files changed, 23 insertions(+), 31 deletions(-) diff --git a/docs-v2/src/content/docs/advanced/map_signal.mdx b/docs-v2/src/content/docs/advanced/map_signal.mdx index b4c4032e..6510929f 100644 --- a/docs-v2/src/content/docs/advanced/map_signal.mdx +++ b/docs-v2/src/content/docs/advanced/map_signal.mdx @@ -1,5 +1,5 @@ --- -title: Reactive Map +title: MapSignal description: Learn MapSignal in solidart sidebar: order: 3 diff --git a/docs-v2/src/content/docs/migration.mdx b/docs-v2/src/content/docs/migration.mdx index 559870fc..03d57bd2 100644 --- a/docs-v2/src/content/docs/migration.mdx +++ b/docs-v2/src/content/docs/migration.mdx @@ -192,27 +192,19 @@ print(counter.previousValue); // 0 ## 6) Reactive collections -`ListSignal`, `MapSignal`, and `SetSignal` have been replaced by -`ReactiveList`, `ReactiveMap`, and `ReactiveSet`. +`ReactiveList`, `ReactiveMap`, and `ReactiveSet` have been replaced by +`ListSignal`, `MapSignal`, and `SetSignal`. **Before (v2):** ```dart -final todos = ListSignal([]); +final todos = ReactiveList([]); ``` **After (v3):** ```dart -final todos = ReactiveList([]); +final todos = ListSignal([]); ``` -### Hooks rename - -If you use `solidart_hooks`, update the hook names: - -- `useListSignal` -> `useReactiveList` -- `useSetSignal` -> `useReactiveSet` -- `useMapSignal` -> `useReactiveMap` - ## 7) Untracked and batch helpers Use the new global helpers to opt out of tracking or batch updates: @@ -269,7 +261,7 @@ final signal = someValueListenable.toSignal(); - `toReadSignal`, `ReadSignal`, `SignalBase` - `SignalOptions`, `ResourceOptions`, `EffectOptions` - `updateValue`, `observe`, `until` -- `ListSignal`, `MapSignal`, `SetSignal` +- `ReactiveList`, `ReactiveMap`, `ReactiveSet` - `Debouncer` and other v2-only utils If you hit a missing API, prefer the v3 primitives (`Signal`, `Computed`, diff --git a/packages/solidart_hooks/example/lib/main.dart b/packages/solidart_hooks/example/lib/main.dart index 77fb2ed2..c9c87093 100644 --- a/packages/solidart_hooks/example/lib/main.dart +++ b/packages/solidart_hooks/example/lib/main.dart @@ -54,19 +54,19 @@ class HookListScreen extends HookWidget { example: () => const UseSignalExample(), ), HookInfo( - title: 'useReactiveList', - description: 'Create a reactive list', - example: () => const UseReactiveListExample(), + title: 'useListSignal', + description: 'Create a list signal', + example: () => const UseListSignalExample(), ), HookInfo( - title: 'useReactiveSet', - description: 'Create a reactive set', - example: () => const UseReactiveSetExample(), + title: 'useSetSignal', + description: 'Create a set signal', + example: () => const UseSetSignalExample(), ), HookInfo( - title: 'useReactiveMap', - description: 'Create a reactive map', - example: () => const UseReactiveMapExample(), + title: 'useMapSignal', + description: 'Create a map signal', + example: () => const UseMapSignalExample(), ), HookInfo( title: 'useComputed', diff --git a/packages/solidart_hooks/example/lib/pages/use_reactive_list.dart b/packages/solidart_hooks/example/lib/pages/use_reactive_list.dart index e25485fb..dbdff8b0 100644 --- a/packages/solidart_hooks/example/lib/pages/use_reactive_list.dart +++ b/packages/solidart_hooks/example/lib/pages/use_reactive_list.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:solidart_hooks/solidart_hooks.dart'; -class UseReactiveListExample extends HookWidget { - const UseReactiveListExample({super.key}); +class UseListSignalExample extends HookWidget { + const UseListSignalExample({super.key}); @override Widget build(BuildContext context) { diff --git a/packages/solidart_hooks/example/lib/pages/use_reactive_map.dart b/packages/solidart_hooks/example/lib/pages/use_reactive_map.dart index 63c36d0f..5d11c257 100644 --- a/packages/solidart_hooks/example/lib/pages/use_reactive_map.dart +++ b/packages/solidart_hooks/example/lib/pages/use_reactive_map.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:solidart_hooks/solidart_hooks.dart'; -class UseReactiveMapExample extends HookWidget { - const UseReactiveMapExample({super.key}); +class UseMapSignalExample extends HookWidget { + const UseMapSignalExample({super.key}); @override Widget build(BuildContext context) { diff --git a/packages/solidart_hooks/example/lib/pages/use_reactive_set.dart b/packages/solidart_hooks/example/lib/pages/use_reactive_set.dart index 1c8c8426..5dace88a 100644 --- a/packages/solidart_hooks/example/lib/pages/use_reactive_set.dart +++ b/packages/solidart_hooks/example/lib/pages/use_reactive_set.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:solidart_hooks/solidart_hooks.dart'; -class UseReactiveSetExample extends HookWidget { - const UseReactiveSetExample({super.key}); +class UseSetSignalExample extends HookWidget { + const UseSetSignalExample({super.key}); @override Widget build(BuildContext context) { diff --git a/packages/solidart_hooks/lib/solidart_hooks.dart b/packages/solidart_hooks/lib/solidart_hooks.dart index edb7f263..07b9e2fd 100644 --- a/packages/solidart_hooks/lib/solidart_hooks.dart +++ b/packages/solidart_hooks/lib/solidart_hooks.dart @@ -189,7 +189,7 @@ Computed useComputed( /// Create a [Resource] from a future-producing [fetcher]. Resource useResource( - /// The asynchrounous function used to retrieve data. + /// The asynchronous function used to retrieve data. final Future Function()? fetcher, { /// Optional name used by DevTools. @@ -239,7 +239,7 @@ Resource useResource( /// Create a [Resource] from a stream factory. Resource useResourceStream( - /// The asynchrounous function used to retrieve data. + /// The asynchronous function used to retrieve data. final Stream Function()? stream, { /// Optional name used by DevTools. From 6311e6b52b53852965cc6155878712da9161d052 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 9 Jan 2026 00:40:29 +0800 Subject: [PATCH 069/121] Fix unused expression warnings in test files --- packages/solidart/test/collections_test.dart | 9 +++++---- packages/solidart/test/option_test.dart | 10 +++++----- packages/solidart/test/resource_test.dart | 12 ++++++------ 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/solidart/test/collections_test.dart b/packages/solidart/test/collections_test.dart index d8c334a9..99d4dc2e 100644 --- a/packages/solidart/test/collections_test.dart +++ b/packages/solidart/test/collections_test.dart @@ -126,7 +126,7 @@ void main() { var runs = 0; Effect(() { - list[1]; + final _ = list[1]; runs++; }); @@ -253,7 +253,8 @@ void main() { list.shuffle(); expect(runs, 2); expect(list.length, 5); - // Can't test exact order due to randomness, but all elements should still exist + // Can't test exact order due to randomness, but all elements should still + // exist. expect(list.value.toSet(), {1, 2, 3, 4, 5}); }); }); @@ -375,7 +376,7 @@ void main() { var runs = 0; Effect(() { - map['a']; + final _ = map['a']; runs++; }); @@ -417,7 +418,7 @@ void main() { expect(runs, 1); - map.removeWhere((key, value) => value % 2 == 0); + map.removeWhere((key, value) => value.isEven); expect(runs, 2); expect(map.value, {'a': 1, 'c': 3}); }); diff --git a/packages/solidart/test/option_test.dart b/packages/solidart/test/option_test.dart index 4cfeaf95..7ca95dc6 100644 --- a/packages/solidart/test/option_test.dart +++ b/packages/solidart/test/option_test.dart @@ -4,22 +4,22 @@ import 'package:test/test.dart'; void main() { group('Option', () { test('Some.unwrap() returns value', () { - final some = Some(42); + const some = Some(42); expect(some.unwrap(), 42); }); test('None.unwrap() throws StateError', () { - final none = None(); - expect(() => none.unwrap(), throwsStateError); + const none = None(); + expect(none.unwrap, throwsStateError); }); test('Some.safeUnwrap() returns value', () { - final some = Some(42); + const some = Some(42); expect(some.safeUnwrap(), 42); }); test('None.safeUnwrap() returns null', () { - final none = None(); + const none = None(); expect(none.safeUnwrap(), isNull); }); }); diff --git a/packages/solidart/test/resource_test.dart b/packages/solidart/test/resource_test.dart index 1ea2c87a..19c796aa 100644 --- a/packages/solidart/test/resource_test.dart +++ b/packages/solidart/test/resource_test.dart @@ -506,9 +506,9 @@ void main() { }); test('ResourceReady equality and copyWith', () { - final ready1 = ResourceReady(42); - final ready2 = ResourceReady(42); - final ready3 = ResourceReady(43); + const ready1 = ResourceReady(42); + const ready2 = ResourceReady(42); + const ready3 = ResourceReady(43); expect(ready1, equals(ready2)); expect(ready1, isNot(equals(ready3))); @@ -520,9 +520,9 @@ void main() { }); test('ResourceError equality and copyWith', () { - final error1 = ResourceError('error1', stackTrace: StackTrace.empty); - final error2 = ResourceError('error1', stackTrace: StackTrace.empty); - final error3 = ResourceError('error2', stackTrace: StackTrace.empty); + const error1 = ResourceError('error1', stackTrace: StackTrace.empty); + const error2 = ResourceError('error1', stackTrace: StackTrace.empty); + const error3 = ResourceError('error2', stackTrace: StackTrace.empty); expect(error1, equals(error2)); expect(error1, isNot(equals(error3))); From 4a7a8b788304fb12c2eecb64be790e9f16370cfe Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 9 Jan 2026 01:32:05 +0800 Subject: [PATCH 070/121] Add comprehensive tests for collection signals and utilities - Test ListSignal methods: addAll, removeAt, removeLast, clear, sort, removeWhere, retainWhere, cast, toString - Test MapSignal methods: keys, isEmpty, isNotEmpty, clear, update, toString - Test SetSignal methods: iterator, toSet, cast, removeWhere, retainWhere, toString - Add advanced tests for DisposableMixin and unlinkDeps - Test DevTools observer const subclasses and Computed participation - Test ResourceState maybeWhen, maybeMap, and toString - Test Computed untrackedValue when not initialized --- packages/solidart/lib/src/solidart.dart | 2 +- packages/solidart/test/advanced_test.dart | 55 ++++++ packages/solidart/test/collections_test.dart | 182 ++++++++++++++++++ packages/solidart/test/devtools_test.dart | 29 +++ .../solidart/test/previous_value_test.dart | 9 + packages/solidart/test/resource_test.dart | 62 ++++++ 6 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 packages/solidart/test/advanced_test.dart diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index b62295b8..bf005ea2 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -63,7 +63,7 @@ final class None extends Option { /// You can override them per-instance via constructor parameters. /// {@endtemplate} final class SolidartConfig { - const SolidartConfig._(); + const SolidartConfig._(); // coverage:ignore-line /// Whether nodes auto-dispose when they lose all subscribers. /// diff --git a/packages/solidart/test/advanced_test.dart b/packages/solidart/test/advanced_test.dart new file mode 100644 index 00000000..73f64c22 --- /dev/null +++ b/packages/solidart/test/advanced_test.dart @@ -0,0 +1,55 @@ +import 'package:solidart/advanced.dart'; +import 'package:solidart/deps/system.dart' as system; +import 'package:test/test.dart'; + +class _TestDisposable with DisposableMixin {} + +class _TestNode extends system.ReactiveNode + with DisposableMixin + implements Configuration { + _TestNode({required this.autoDispose}) + : super(flags: system.ReactiveFlags.none); + + @override + final bool autoDispose; + + @override + Identifier get identifier => throw UnimplementedError(); +} + +void main() { + test('DisposableMixin runs cleanup callbacks once', () { + final disposable = _TestDisposable(); + var calls = 0; + + disposable + ..onDispose(() => calls++) + ..onDispose(() => calls++); + + disposable.dispose(); + expect(calls, 2); + + // Subsequent dispose should be a no-op. + disposable.dispose(); + expect(calls, 2); + }); + + test('Disposable.unlinkDeps disposes autoDispose deps with no subs', () { + final dep = _TestNode(autoDispose: true); + final node = _TestNode(autoDispose: true); + final link = system.Link( + version: 0, + dep: dep, + sub: node, + ); + + // Set up a minimal dependency link where dep has no subscriber list. + node + ..deps = link + ..depsTail = link; + + Disposable.unlinkDeps(node); + + expect(dep.isDisposed, isTrue); + }); +} diff --git a/packages/solidart/test/collections_test.dart b/packages/solidart/test/collections_test.dart index 99d4dc2e..ee636602 100644 --- a/packages/solidart/test/collections_test.dart +++ b/packages/solidart/test/collections_test.dart @@ -257,6 +257,109 @@ void main() { // exist. expect(list.value.toSet(), {1, 2, 3, 4, 5}); }); + + test('addAll appends elements', () { + final list = ListSignal([1]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.addAll([2, 3]); + + expect(runs, 2); + expect(list.value, [1, 2, 3]); + }); + + test('removeAt and removeLast return removed values', () { + final list = ListSignal([1, 2, 3]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + final removedAt = list.removeAt(1); + expect(removedAt, 2); + expect(runs, 2); + expect(list.value, [1, 3]); + + final removedLast = list.removeLast(); + expect(removedLast, 3); + expect(runs, 3); + expect(list.value, [1]); + }); + + test('clear empties non-empty list', () { + final list = ListSignal([1, 2]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.clear(); + + expect(runs, 2); + expect(list.value, isEmpty); + }); + + test('sort reorders list when needed', () { + final list = ListSignal([2, 1]); + var runs = 0; + + Effect(() { + list[0]; + runs++; + }); + + expect(runs, 1); + + list.sort(); + + expect(runs, 2); + expect(list.value, [1, 2]); + }); + + test('removeWhere and retainWhere update list', () { + final list = ListSignal([1, 2, 3, 4]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.removeWhere((value) => value.isEven); + expect(runs, 2); + expect(list.value, [1, 3]); + + list.retainWhere((value) => value > 1); + expect(runs, 3); + expect(list.value, [3]); + }); + + test('cast and toString expose list details', () { + final list = ListSignal([1, 2]); + final casted = list.cast(); + + expect(casted, isA>()); + expect((casted as ListSignal).value, [1, 2]); + expect(list.toString(), contains('ListSignal')); + expect(list.toString(), contains('value: [1, 2]')); + }); }); group('MapSignal', () { @@ -456,6 +559,48 @@ void main() { map['a'] = 10; expect(runs, 2); }); + + test('keys/isEmpty/isNotEmpty and clear reflect state', () { + final map = MapSignal({'a': 1, 'b': 2}); + var runs = 0; + + Effect(() { + map.keys; + runs++; + }); + + expect(runs, 1); + expect(map.keys.toSet(), {'a', 'b'}); + expect(map.isEmpty, isFalse); + expect(map.isNotEmpty, isTrue); + + map.clear(); + + expect(runs, 2); + expect(map.value, isEmpty); + expect(map.isEmpty, isTrue); + expect(map.isNotEmpty, isFalse); + }); + + test('update throws when key missing and no ifAbsent', () { + final map = MapSignal({'a': 1}); + + expect( + () => map.update('b', (value) => value + 1), + throwsA(isA()), + ); + }); + + test('toString reports current and previous values', () { + final map = MapSignal({'a': 1}); + map['a'] = 2; + map.value; + + final description = map.toString(); + + expect(description, contains('MapSignal')); + expect(description, contains('value: {a: 2}')); + }); }); group('SetSignal', () { @@ -596,5 +741,42 @@ void main() { expect(runs, 2); expect(set.value, {2, 4}); }); + + test('iterator, toSet, cast, and toString work as expected', () { + final set = SetSignal({1, 2}); + + final iterator = set.iterator; + expect(iterator.moveNext(), isTrue); + + expect(set.toSet(), {1, 2}); + + final casted = set.cast(); + expect(casted, isA>()); + expect((casted as SetSignal).value, {1, 2}); + + final description = set.toString(); + expect(description, contains('SetSignal')); + expect(description, contains('value: {1, 2}')); + }); + + test('removeWhere and retainWhere update set', () { + final set = SetSignal({1, 2, 3, 4}); + var runs = 0; + + Effect(() { + set.length; + runs++; + }); + + expect(runs, 1); + + set.removeWhere((value) => value.isEven); + expect(runs, 2); + expect(set.value, {1, 3}); + + set.retainWhere((value) => value > 1); + expect(runs, 3); + expect(set.value, {3}); + }); }); } diff --git a/packages/solidart/test/devtools_test.dart b/packages/solidart/test/devtools_test.dart index 13d5d39d..1060c805 100644 --- a/packages/solidart/test/devtools_test.dart +++ b/packages/solidart/test/devtools_test.dart @@ -22,6 +22,19 @@ class _Observer implements SolidartObserver { } } +class _ConstObserver extends SolidartObserver { + const _ConstObserver(); + + @override + void didCreateSignal(ReadonlySignal signal) {} + + @override + void didUpdateSignal(ReadonlySignal signal) {} + + @override + void didDisposeSignal(ReadonlySignal signal) {} +} + void main() { late bool previousDevToolsEnabled; late List previousObservers; @@ -84,4 +97,20 @@ void main() { expect(observer.created, 1); }); + + test('SolidartObserver supports const subclasses', () { + const observer = _ConstObserver(); + expect(observer, isA()); + }); + + test('Computed participates in DevTools events', () { + final source = Signal(1); + final computed = Computed(() => source.value * 2); + + expect(computed.value, 2); + + source.value = 2; + + expect(computed.value, 4); + }); } diff --git a/packages/solidart/test/previous_value_test.dart b/packages/solidart/test/previous_value_test.dart index 1cf49247..a599c4de 100644 --- a/packages/solidart/test/previous_value_test.dart +++ b/packages/solidart/test/previous_value_test.dart @@ -117,6 +117,15 @@ void main() { expectPreviousValues(computed, previous: null, untracked: null); }); + + test('untrackedValue computes when not initialized', () { + final source = Signal(2); + final computed = Computed(() => source.value * 2); + + final value = computed.untrackedValue; + + expect(value, 4); + }); }); group('LazySignal previous value', () { diff --git a/packages/solidart/test/resource_test.dart b/packages/solidart/test/resource_test.dart index 19c796aa..6710e8d7 100644 --- a/packages/solidart/test/resource_test.dart +++ b/packages/solidart/test/resource_test.dart @@ -505,6 +505,52 @@ void main() { ); }); + test('maybeWhen and maybeMap use provided handlers', () { + const ready = ResourceState.ready(3); + final error = ResourceState.error(StateError('boom')); + const loading = ResourceState.loading(); + + expect( + ready.maybeWhen( + orElse: () => 'fallback', + ready: (value) => 'ready $value', + ), + 'ready 3', + ); + + expect( + error.maybeWhen( + orElse: () => 'fallback', + error: (err, stackTrace) => err.runtimeType.toString(), + ), + 'StateError', + ); + + expect( + loading.maybeWhen( + orElse: () => 'fallback', + loading: () => 'loading', + ), + 'loading', + ); + + expect( + ready.maybeMap( + orElse: () => 'fallback', + ready: (state) => 'ready ${state.value}', + ), + 'ready 3', + ); + + expect( + error.maybeMap( + orElse: () => 'fallback', + error: (state) => state.error.runtimeType.toString(), + ), + 'StateError', + ); + }); + test('ResourceReady equality and copyWith', () { const ready1 = ResourceReady(42); const ready2 = ResourceReady(42); @@ -543,6 +589,22 @@ void main() { expect(loading1, equals(loading2)); expect(loading1.hashCode, equals(loading2.hashCode)); }); + + test('ResourceState toString outputs useful descriptions', () { + const ready = ResourceReady(1, isRefreshing: true); + const loading = ResourceLoading(); + const error = ResourceError( + 'boom', + stackTrace: StackTrace.empty, + isRefreshing: true, + ); + + expect(ready.toString(), contains('ResourceReady')); + expect(ready.toString(), contains('refreshing: true')); + expect(loading.toString(), 'ResourceLoading()'); + expect(error.toString(), contains('ResourceError')); + expect(error.toString(), contains('refreshing: true')); + }); }); group('Resource previousState', () { From 928909f6ebd5e4cce6286c496f045dc376a96c1c Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 9 Jan 2026 01:46:07 +0800 Subject: [PATCH 071/121] Add test coverage for edge cases and improve test reliability - Test `SetSignal.clear` triggers effects and empties set - Test `MapSignal.cast` returns properly typed signal - Test `Computed.untrackedValue` returns cached value when available - Test `ResourceState.maybeWhen` and `maybeMap` fallback to orElse - Test `ResourceState` factories can be invoked at runtime - Store `Disposable.dispose` reference to avoid calling method on disposed object - Add coverage ignore comment to ResourceState base constructor - Use `final _` pattern for unused values in tests --- packages/solidart/lib/src/solidart.dart | 2 +- packages/solidart/test/advanced_test.dart | 5 ++- packages/solidart/test/collections_test.dart | 27 ++++++++++++- .../solidart/test/previous_value_test.dart | 11 +++++ packages/solidart/test/resource_test.dart | 40 +++++++++++++++++++ 5 files changed, 81 insertions(+), 4 deletions(-) diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index bf005ea2..7051609d 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -1803,7 +1803,7 @@ class Resource extends Signal> { @immutable sealed class ResourceState { /// Base constructor for resource states. - const ResourceState(); + const ResourceState(); // coverage:ignore-line /// {@macro v3-resource-state} /// diff --git a/packages/solidart/test/advanced_test.dart b/packages/solidart/test/advanced_test.dart index 73f64c22..1046f1e9 100644 --- a/packages/solidart/test/advanced_test.dart +++ b/packages/solidart/test/advanced_test.dart @@ -26,11 +26,12 @@ void main() { ..onDispose(() => calls++) ..onDispose(() => calls++); - disposable.dispose(); + final dispose = disposable.dispose; + dispose(); expect(calls, 2); // Subsequent dispose should be a no-op. - disposable.dispose(); + dispose(); expect(calls, 2); }); diff --git a/packages/solidart/test/collections_test.dart b/packages/solidart/test/collections_test.dart index ee636602..7e0b2ade 100644 --- a/packages/solidart/test/collections_test.dart +++ b/packages/solidart/test/collections_test.dart @@ -319,7 +319,7 @@ void main() { var runs = 0; Effect(() { - list[0]; + final _ = list[0]; runs++; }); @@ -759,6 +759,23 @@ void main() { expect(description, contains('value: {1, 2}')); }); + test('clear empties non-empty set', () { + final set = SetSignal({1, 2}); + var runs = 0; + + Effect(() { + set.length; + runs++; + }); + + expect(runs, 1); + + set.clear(); + + expect(runs, 2); + expect(set.value, isEmpty); + }); + test('removeWhere and retainWhere update set', () { final set = SetSignal({1, 2, 3, 4}); var runs = 0; @@ -779,4 +796,12 @@ void main() { expect(set.value, {3}); }); }); + + test('MapSignal cast returns a new typed signal', () { + final map = MapSignal({'a': 1}); + final casted = map.cast(); + + expect(casted, isA>()); + expect((casted as MapSignal)['a'], 1); + }); } diff --git a/packages/solidart/test/previous_value_test.dart b/packages/solidart/test/previous_value_test.dart index a599c4de..2a552051 100644 --- a/packages/solidart/test/previous_value_test.dart +++ b/packages/solidart/test/previous_value_test.dart @@ -126,6 +126,17 @@ void main() { expect(value, 4); }); + + test('untrackedValue returns cached value when available', () { + final source = Signal(3); + final computed = Computed(() => source.value * 2); + + expect(computed.value, 6); + + final value = computed.untrackedValue; + + expect(value, 6); + }); }); group('LazySignal previous value', () { diff --git a/packages/solidart/test/resource_test.dart b/packages/solidart/test/resource_test.dart index 6710e8d7..185650b2 100644 --- a/packages/solidart/test/resource_test.dart +++ b/packages/solidart/test/resource_test.dart @@ -551,6 +551,40 @@ void main() { ); }); + test( + 'maybeWhen and maybeMap fall back to orElse when handler is absent', + () { + final error = ResourceState.error(StateError('boom')); + const loading = ResourceState.loading(); + final ready = ResourceState.ready(DateTime.now().microsecond); + + expect( + error.maybeWhen(orElse: () => 'fallback'), + 'fallback', + ); + + expect( + loading.maybeWhen(orElse: () => 'fallback'), + 'fallback', + ); + + expect( + ready.maybeMap(orElse: () => 'fallback'), + 'fallback', + ); + + expect( + error.maybeMap(orElse: () => 'fallback'), + 'fallback', + ); + + expect( + loading.maybeMap(orElse: () => 'fallback'), + 'fallback', + ); + }, + ); + test('ResourceReady equality and copyWith', () { const ready1 = ResourceReady(42); const ready2 = ResourceReady(42); @@ -605,6 +639,12 @@ void main() { expect(error.toString(), contains('ResourceError')); expect(error.toString(), contains('refreshing: true')); }); + + test('ResourceState factories can be invoked at runtime', () { + final state = ResourceState.ready(DateTime.now().microsecond); + + expect(state, isA>()); + }); }); group('Resource previousState', () { From 094286aefff3499bd99bdbd1ba1c68b869f3e977 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 9 Jan 2026 01:51:19 +0800 Subject: [PATCH 072/121] Add coverage ignore comment to SolidartObserver constructor --- packages/solidart/lib/src/solidart.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index 7051609d..847ed5df 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -123,7 +123,7 @@ final class SolidartConfig { /// {@endtemplate} abstract class SolidartObserver { /// {@macro v3-observer} - const SolidartObserver(); + const SolidartObserver(); // coverage:ignore-line /// Called when a signal is created. void didCreateSignal(ReadonlySignal signal); From b875d6f8ad55909e3f168dde318f9bf80cd2bcd7 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 9 Jan 2026 01:57:45 +0800 Subject: [PATCH 073/121] Fix capitalization in LazySignal error message --- packages/solidart/lib/src/solidart.dart | 2 +- packages/solidart/test/devtools_test.dart | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index 847ed5df..b39c4d3f 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -703,7 +703,7 @@ class LazySignal extends Signal { return super.value; } throw StateError( - 'LazySignal is not initialized, Please call `.value = ` first.', + 'LazySignal is not initialized, please call `.value = ` first.', ); } diff --git a/packages/solidart/test/devtools_test.dart b/packages/solidart/test/devtools_test.dart index 1060c805..2c18c562 100644 --- a/packages/solidart/test/devtools_test.dart +++ b/packages/solidart/test/devtools_test.dart @@ -104,13 +104,27 @@ void main() { }); test('Computed participates in DevTools events', () { + final observer = _Observer(); + SolidartConfig.observers.add(observer); + final source = Signal(1); final computed = Computed(() => source.value * 2); + // Verify creation events for both source and computed. + expect(observer.created, 2); + expect(computed.value, 2); + final updatedAfterFirstRead = observer.updated; source.value = 2; + expect(source.value, 2); + final updatedAfterSource = observer.updated; + expect(updatedAfterSource, greaterThan(updatedAfterFirstRead)); expect(computed.value, 4); + expect(observer.updated, greaterThan(updatedAfterSource)); + + source.dispose(); + computed.dispose(); }); } From ffae0bdf9c3d0afb6a310673d56ba6c606488859 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:41:30 +0800 Subject: [PATCH 074/121] Update docs-v2/src/content/docs/advanced/automatic_disposal.mdx Co-authored-by: Alexandru Mariuti --- docs-v2/src/content/docs/advanced/automatic_disposal.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx index bc3f01a5..8babb731 100644 --- a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx +++ b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx @@ -8,7 +8,7 @@ import { Aside } from '@astrojs/starlight/components'; From 16d419fd80d38304d6140c6d23b9af55773896e6 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:41:58 +0800 Subject: [PATCH 075/121] Update docs-v2/src/content/docs/advanced/automatic_disposal.mdx Co-authored-by: Alexandru Mariuti --- docs-v2/src/content/docs/advanced/automatic_disposal.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx index 8babb731..4d9080ad 100644 --- a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx +++ b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx @@ -38,7 +38,7 @@ tracked dependencies are disposed (not when any single dependency is disposed). Effects created inside widgets should be disposed in `dispose()`: ```dart -class ExampleState extends State { +class _ExampleState extends State { late final Effect _effect; final count = Signal(0); From 3b78030847cc1006dbf8244ee803405e5e9b54ab Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:42:19 +0800 Subject: [PATCH 076/121] Update docs-v2/src/content/docs/advanced/automatic_disposal.mdx Co-authored-by: Alexandru Mariuti --- docs-v2/src/content/docs/advanced/automatic_disposal.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx index 4d9080ad..7e54c81e 100644 --- a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx +++ b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx @@ -45,7 +45,7 @@ class _ExampleState extends State { @override void initState() { super.initState(); - _effect = Effect(() { + effect = Effect(() { print('count: ${count.value}'); }); } From d0bd9066b1c90f7d494948831dd66e1c27249cc5 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:42:45 +0800 Subject: [PATCH 077/121] Update docs-v2/src/content/docs/advanced/automatic_disposal.mdx Co-authored-by: Alexandru Mariuti --- docs-v2/src/content/docs/advanced/automatic_disposal.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx index 7e54c81e..37bda2da 100644 --- a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx +++ b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx @@ -52,7 +52,7 @@ class _ExampleState extends State { @override void dispose() { - _effect.dispose(); + effect.dispose(); count.dispose(); super.dispose(); } From 9a48fbeadd3b15c650e4ae8e55b479be33381108 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:43:08 +0800 Subject: [PATCH 078/121] Update docs-v2/src/content/docs/flutter/signal_builder.mdx Co-authored-by: Alexandru Mariuti --- docs-v2/src/content/docs/flutter/signal_builder.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-v2/src/content/docs/flutter/signal_builder.mdx b/docs-v2/src/content/docs/flutter/signal_builder.mdx index 08abb89e..daa53821 100644 --- a/docs-v2/src/content/docs/flutter/signal_builder.mdx +++ b/docs-v2/src/content/docs/flutter/signal_builder.mdx @@ -5,7 +5,7 @@ sidebar: order: 1 --- -A magic widget builder that automatically rebuilds every time a signal used inside its builder changes. +A magic widget builder that automatically rebuilds every time signals used inside its builder change. Reacts to any number of signals, calling the `builder` each time. From fe54312490c449ce0a1c67ad187630bfcfda7d71 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:43:43 +0800 Subject: [PATCH 079/121] Update docs-v2/src/content/docs/advanced/automatic_disposal.mdx Co-authored-by: Alexandru Mariuti --- docs-v2/src/content/docs/advanced/automatic_disposal.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx index 37bda2da..11124314 100644 --- a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx +++ b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx @@ -39,7 +39,7 @@ Effects created inside widgets should be disposed in `dispose()`: ```dart class _ExampleState extends State { - late final Effect _effect; + late final Effect effect; final count = Signal(0); @override From aa662ed06a7d22865b02cc60d2c087f53588c9cd Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:10:50 +0800 Subject: [PATCH 080/121] Update v3 docs and signal builder checks --- .../docs/advanced/automatic_disposal.mdx | 17 +++++++++++----- .../src/content/docs/advanced/list_signal.mdx | 19 ++++++++++++++++++ .../src/content/docs/advanced/map_signal.mdx | 20 ++----------------- .../src/content/docs/advanced/set_signal.mdx | 2 +- docs-v2/src/content/docs/flutter/show.mdx | 4 +++- .../content/docs/flutter/signal_builder.mdx | 18 +++++++++++++++-- .../src/content/docs/learning/computed.mdx | 4 ++-- docs-v2/src/content/docs/migration.mdx | 11 +++++----- .../lib/src/widgets/signal_builder.dart | 10 +++++++++- packages/solidart/lib/src/solidart.dart | 20 +++++++++++++++++++ 10 files changed, 90 insertions(+), 35 deletions(-) diff --git a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx index 11124314..c6ca3724 100644 --- a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx +++ b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx @@ -8,8 +8,9 @@ import { Aside } from '@astrojs/starlight/components'; @@ -29,11 +30,17 @@ Or per instance: final counter = Signal(0, autoDispose: true); ``` -When auto-dispose is enabled, `Signal`, `Computed`, and `Resource` will dispose -when they have no subscribers. `Effect` will dispose only after all of its -tracked dependencies are disposed (not when any single dependency is disposed). +When auto-dispose is enabled: +- `Signal`, `Computed`, and `Resource` auto-dispose when they have no subscribers +- `Effect` auto-disposes only after ALL its tracked dependencies are disposed or disconnected -## Disposing effects in widgets +**Best practice:** Even with auto-dispose enabled, manually dispose Effects in widgets +for explicit lifecycle control and easier debugging. + +## Disposing effects you create manually + +Effects can be created in widgets or in business-logic classes (for example, +`Notifier` classes with disco). Dispose them where they are owned. Effects created inside widgets should be disposed in `dispose()`: diff --git a/docs-v2/src/content/docs/advanced/list_signal.mdx b/docs-v2/src/content/docs/advanced/list_signal.mdx index 10af428a..81e0a6fc 100644 --- a/docs-v2/src/content/docs/advanced/list_signal.mdx +++ b/docs-v2/src/content/docs/advanced/list_signal.mdx @@ -4,6 +4,7 @@ description: Learn ListSignal in solidart sidebar: order: 1 --- +import { Aside } from '@astrojs/starlight/components'; A `ListSignal` is a reactive wrapper around a `List` that copies on write. @@ -19,3 +20,21 @@ Effect(() { items.add(3); // prints "Items: [1, 2, 3]" items[0] = 10; // prints "Items: [10, 2, 3]" ``` + + diff --git a/docs-v2/src/content/docs/advanced/map_signal.mdx b/docs-v2/src/content/docs/advanced/map_signal.mdx index 6510929f..90eacff6 100644 --- a/docs-v2/src/content/docs/advanced/map_signal.mdx +++ b/docs-v2/src/content/docs/advanced/map_signal.mdx @@ -2,7 +2,7 @@ title: MapSignal description: Learn MapSignal in solidart sidebar: - order: 3 + order: 1 --- A `MapSignal` is a reactive wrapper around a `Map` that uses copy-on-write. @@ -25,20 +25,4 @@ items['c'] = 3; // prints "Items: {a: 1, b: 2, c: 3}" items.remove('a'); // prints "Items: {b: 2, c: 3}" ``` -## Memory management - -Signals (including `MapSignal`) can auto-dispose in Flutter contexts when -`SolidartConfig.autoDispose` is enabled and nothing depends on them. Effects -should always be disposed manually (automatic Effect disposal is rare in -practice), and in pure Dart you must dispose all primitives to avoid leaks: - -```dart -final items = MapSignal({'a': 1}); -final effect = Effect(() { - print(items.value); -}); - -// Later -effect.dispose(); -items.dispose(); -``` +For disposal guidelines, see the automatic disposal guide. diff --git a/docs-v2/src/content/docs/advanced/set_signal.mdx b/docs-v2/src/content/docs/advanced/set_signal.mdx index 3aa638d5..04140fbd 100644 --- a/docs-v2/src/content/docs/advanced/set_signal.mdx +++ b/docs-v2/src/content/docs/advanced/set_signal.mdx @@ -2,7 +2,7 @@ title: Set Signal description: Learn SetSignal in solidart sidebar: - order: 2 + order: 1 --- A `SetSignal` is a reactive wrapper around a `Set` that copies on write. diff --git a/docs-v2/src/content/docs/flutter/show.mdx b/docs-v2/src/content/docs/flutter/show.mdx index 6c42c4ad..c7f4c6db 100644 --- a/docs-v2/src/content/docs/flutter/show.mdx +++ b/docs-v2/src/content/docs/flutter/show.mdx @@ -48,7 +48,7 @@ class _SampleState extends State { @override Widget build(BuildContext context) { return Show( - when: () => loggedIn.value, + when: loggedIn, builder: (context) => const Text('Logged In'), fallback: (context) => const Text('Logged out'), ); @@ -60,6 +60,8 @@ The `Show` widget conditionally renders its `builder` or the `fallback` widget b The `fallback` widget builder is optional, by default nothing is rendered. The `Show` widget takes a function that returns a `bool`. +Signals are callable, so you can pass them directly (for example, `when: loggedIn`) +or use `.value` if you prefer. You can easily convert any type to `bool`, for example: ```dart {9} {13-17} diff --git a/docs-v2/src/content/docs/flutter/signal_builder.mdx b/docs-v2/src/content/docs/flutter/signal_builder.mdx index daa53821..b12efa19 100644 --- a/docs-v2/src/content/docs/flutter/signal_builder.mdx +++ b/docs-v2/src/content/docs/flutter/signal_builder.mdx @@ -4,6 +4,7 @@ description: Learn SignalBuilder in flutter_solidart sidebar: order: 1 --- +import { Aside } from '@astrojs/starlight/components'; A magic widget builder that automatically rebuilds every time signals used inside its builder change. @@ -36,5 +37,18 @@ class _SampleCounterState extends State { } ``` -`SignalBuilder` tracks dependencies automatically. If no signal is read inside -`builder`, it simply builds once and does not subscribe to updates. +`SignalBuilder` tracks dependencies automatically. + + + +If you disable the assertion, a `SignalBuilder` without dependencies builds +once and does not subscribe to updates. diff --git a/docs-v2/src/content/docs/learning/computed.mdx b/docs-v2/src/content/docs/learning/computed.mdx index b9d5fb61..21fd182a 100644 --- a/docs-v2/src/content/docs/learning/computed.mdx +++ b/docs-v2/src/content/docs/learning/computed.mdx @@ -67,10 +67,10 @@ Effect(() { }); // just update the name, the effect above doesn't run because the age has not changed -user.value = user.value.copyWith(name: 'new-name'); +user.value = user.untrackedValue.copyWith(name: 'new-name'); // just update the age, the effect above prints -user.value = user.value.copyWith(age: 21); +user.value = user.untrackedValue.copyWith(age: 21); ``` A computed is not a `Signal` but a `ReadonlySignal`. diff --git a/docs-v2/src/content/docs/migration.mdx b/docs-v2/src/content/docs/migration.mdx index 03d57bd2..72e072a2 100644 --- a/docs-v2/src/content/docs/migration.mdx +++ b/docs-v2/src/content/docs/migration.mdx @@ -37,9 +37,9 @@ import 'package:flutter_solidart/flutter_solidart.dart'; | --- | --- | --- | | `ReadSignal` | `ReadonlySignal` | Read-only interface renamed. Use `toReadonly()` to create one. | | `toReadSignal()` | `toReadonly()` | Read-only conversion rename. | -| `signal()` / `ReadSignal.call()` | `signal.value` | Call operator removed. | +| `signal()` / `ReadSignal.call()` | `signal.value` | Call operator still works; `.value` is preferred. | | `signal.set(value)` | `signal.value = value` | Setter only. | -| `signal.updateValue(fn)` | `signal.value = fn(signal.value)` | No helper method. | +| `signal.updateValue(fn)` | `signal.value = fn(signal.untrackedValue)` | Use `untrackedValue` to match v2 semantics. | | `signal.observe(listener, fireImmediately)` | `Effect(() { ... })` | Use effects for reactive side effects. | | `signal.until(condition, timeout)` | — | Removed. Use `Completer` + `Effect` (example below). | | `hasPreviousValue` | — | Removed. Use `previousValue` / `untrackedPreviousValue`. | @@ -64,7 +64,7 @@ await completer.future; | v2 API | v3 API | Notes | | --- | --- | --- | | `Computed(selector, options)` | `Computed(selector, ...)` | Options are named params. | -| `computed()` | `computed.value` | Call operator removed. | +| `computed()` | `computed.value` | Call operator still works; `.value` is preferred. | | `Computed.run()` | `computed.value` | No manual run; reading evaluates lazily. | ### Effect @@ -146,7 +146,8 @@ final readOnly = counter.toReadonly(); ### Updating values -`updateValue` is gone. Update via `.value` and your own logic. +`updateValue` is gone. Update via `.value` and your own logic. Use +`untrackedValue` if you want the old no-tracking behavior. **Before (v2):** ```dart @@ -155,7 +156,7 @@ counter.updateValue((value) => value + 1); **After (v3):** ```dart -counter.value += 1; +counter.value = counter.untrackedValue + 1; ``` ### Observing changes diff --git a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart index 26643d10..0a07cb22 100644 --- a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart +++ b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart @@ -104,7 +104,15 @@ class _SignalBuilderElement extends StatelessElement { preset.cycle++; try { SolidartConfig.detachEffects = true; - return super.build(); + final built = super.build(); + if (SolidartConfig.assertSignalBuilderWithoutDependencies) { + assert(_effect.depsTail != null, ''' +SignalBuilder must detect at least one Signal, Computed, or Resource during the build. +This may mean your reactive values were disposed. +You can disable this check by setting `SolidartConfig.assertSignalBuilderWithoutDependencies = false` before `runApp()`. + '''); + } + return built; } finally { preset.purgeDeps(_effect); _depsHead = _effect.deps; diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index b39c4d3f..b338e046 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -95,6 +95,17 @@ final class SolidartConfig { /// `trackInDevTools` are `true`. static bool devToolsEnabled = false; + /// Whether to assert that SignalBuilder has at least one dependency during + /// its build. Defaults to true. + /// + /// If you set this to false, you must ensure that the SignalBuilder has at + /// least one dependency, otherwise it won't rebuild when the signals change. + /// + /// The ability to disable this assertion is provided for advanced use cases + /// where you might have a SignalBuilder that builds something based on + /// disposed signals where you might be interested in their latest values. + static bool assertSignalBuilderWithoutDependencies = true; + /// Registered observers for signal lifecycle events. /// /// Observers are notified only when `trackInDevTools` is enabled for the @@ -475,6 +486,9 @@ abstract interface class ReadonlySignal /// Returns the current value and tracks dependencies. T get value; + /// Returns [value]. This allows using a signal as a callable. + T call(); + /// Returns the current value without tracking. T get untrackedValue; @@ -606,6 +620,9 @@ class Signal extends preset.SignalNode> return super.get().unwrap(); } + @override + T call() => value; + /// Sets the current value. /// /// {@macro v3-signal-equals} @@ -1303,6 +1320,9 @@ class Computed extends preset.ComputedNode return get(); } + @override + T call() => value; + @override T get untrackedValue { if (currentValue != null || null is T) { From e4763130e0c7e74a757bd23c4c123f888df95a88 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:21:47 +0800 Subject: [PATCH 081/121] Add call operator to Signal, ReadonlySignal, and Computed --- .../solidart/test/call_operator_test.dart | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 packages/solidart/test/call_operator_test.dart diff --git a/packages/solidart/test/call_operator_test.dart b/packages/solidart/test/call_operator_test.dart new file mode 100644 index 00000000..cfc51d20 --- /dev/null +++ b/packages/solidart/test/call_operator_test.dart @@ -0,0 +1,46 @@ +import 'package:solidart/solidart.dart'; +import 'package:test/test.dart'; + +void main() { + test('Signal call() returns value and tracks dependencies', () { + final counter = Signal(0); + var runs = 0; + + Effect(() { + counter(); + runs++; + }); + + expect(runs, 1); + + counter.value = 1; + expect(runs, 2); + }); + + test('ReadonlySignal call() works on typed reference', () { + final counter = Signal(0); + final ReadonlySignal readonly = counter.toReadonly(); + + expect(readonly(), 0); + + counter.value = 2; + expect(readonly(), 2); + }); + + test('Computed call() returns value and tracks dependencies', () { + final source = Signal(1); + final doubled = Computed(() => source.value * 2); + var runs = 0; + + Effect(() { + doubled(); + runs++; + }); + + expect(runs, 1); + + source.value = 3; + expect(runs, 2); + expect(doubled(), 6); + }); +} From 725dfc25505b1176a1f9d294f86382f8b6e4ade3 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:26:58 +0800 Subject: [PATCH 082/121] Add SignalBuilder and Show widget tests --- .../test/flutter_solidart_test.dart | 41 +++++++++++++++++-- .../test/signal_builder_exceptions_test.dart | 24 +++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/flutter_solidart/test/flutter_solidart_test.dart b/packages/flutter_solidart/test/flutter_solidart_test.dart index 234d9fa2..90351d56 100644 --- a/packages/flutter_solidart/test/flutter_solidart_test.dart +++ b/packages/flutter_solidart/test/flutter_solidart_test.dart @@ -60,13 +60,15 @@ void main() { ), ); }); - testWidgets('Show widget works properly', (tester) async { + testWidgets('Show widget toggles branches and rebuilds on changes', ( + tester, + ) async { final s = Signal(true); await tester.pumpWidget( MaterialApp( home: Scaffold( body: Show( - when: () => s.value, + when: s, builder: (context) => const Text('Builder'), fallback: (context) => const Text('Fallback'), ), @@ -80,10 +82,43 @@ void main() { expect(fallbackFinder, findsNothing); s.value = false; - await tester.pumpAndSettle(); + await tester.pump(); expect(builderFinder, findsNothing); expect(fallbackFinder, findsOneWidget); + + s.value = true; + await tester.pump(); + + expect(builderFinder, findsOneWidget); + expect(fallbackFinder, findsNothing); + }); + + testWidgets('Show widget cleans up subscriptions on unmount', (tester) async { + final previousAutoDispose = SolidartConfig.autoDispose; + SolidartConfig.autoDispose = true; + addTearDown(() => SolidartConfig.autoDispose = previousAutoDispose); + + final s = Signal(true, autoDispose: true); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Show( + when: s, + builder: (context) => const Text('Builder'), + fallback: (context) => const Text('Fallback'), + ), + ), + ), + ); + + expect(s.isDisposed, isFalse); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + + expect(s.isDisposed, isTrue); }); testWidgets('SignalBuilder widget works properly in ResourceReady state', ( diff --git a/packages/flutter_solidart/test/signal_builder_exceptions_test.dart b/packages/flutter_solidart/test/signal_builder_exceptions_test.dart index 9416c33e..e9c5d5b7 100644 --- a/packages/flutter_solidart/test/signal_builder_exceptions_test.dart +++ b/packages/flutter_solidart/test/signal_builder_exceptions_test.dart @@ -69,4 +69,28 @@ void main() { }, timeout: const Timeout(Duration(seconds: 1)), ); + + testWidgets( + 'SignalBuilder.build asserts without dependencies (covers _isBuilding guard)', + (tester) async { + final previousAssert = + SolidartConfig.assertSignalBuilderWithoutDependencies; + SolidartConfig.assertSignalBuilderWithoutDependencies = true; + addTearDown( + () => SolidartConfig.assertSignalBuilderWithoutDependencies = + previousAssert, + ); + + await tester.pumpWidget( + MaterialApp( + home: SignalBuilder( + builder: (context, child) => const SizedBox(), + ), + ), + ); + + final exception = tester.takeException(); + expect(exception, isA()); + }, + ); } From 0348d1f1e5b12172abcd25eaeeb7a70ac4ca7deb Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:30:03 +0800 Subject: [PATCH 083/121] Update sidebar order for collection signal documentation --- docs-v2/src/content/docs/advanced/list_signal.mdx | 2 +- docs-v2/src/content/docs/advanced/map_signal.mdx | 2 +- docs-v2/src/content/docs/advanced/set_signal.mdx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs-v2/src/content/docs/advanced/list_signal.mdx b/docs-v2/src/content/docs/advanced/list_signal.mdx index 81e0a6fc..a1e4ba0e 100644 --- a/docs-v2/src/content/docs/advanced/list_signal.mdx +++ b/docs-v2/src/content/docs/advanced/list_signal.mdx @@ -2,7 +2,7 @@ title: List Signal description: Learn ListSignal in solidart sidebar: - order: 1 + order: 2 --- import { Aside } from '@astrojs/starlight/components'; diff --git a/docs-v2/src/content/docs/advanced/map_signal.mdx b/docs-v2/src/content/docs/advanced/map_signal.mdx index 90eacff6..c522600c 100644 --- a/docs-v2/src/content/docs/advanced/map_signal.mdx +++ b/docs-v2/src/content/docs/advanced/map_signal.mdx @@ -2,7 +2,7 @@ title: MapSignal description: Learn MapSignal in solidart sidebar: - order: 1 + order: 3 --- A `MapSignal` is a reactive wrapper around a `Map` that uses copy-on-write. diff --git a/docs-v2/src/content/docs/advanced/set_signal.mdx b/docs-v2/src/content/docs/advanced/set_signal.mdx index 04140fbd..c2c37301 100644 --- a/docs-v2/src/content/docs/advanced/set_signal.mdx +++ b/docs-v2/src/content/docs/advanced/set_signal.mdx @@ -2,7 +2,7 @@ title: Set Signal description: Learn SetSignal in solidart sidebar: - order: 1 + order: 4 --- A `SetSignal` is a reactive wrapper around a `Set` that copies on write. From f017c7d2c7970d515172c90245ec4f371761fb14 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:31:38 +0800 Subject: [PATCH 084/121] Fix SignalBuilder test line length --- .../flutter_solidart/test/signal_builder_exceptions_test.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/flutter_solidart/test/signal_builder_exceptions_test.dart b/packages/flutter_solidart/test/signal_builder_exceptions_test.dart index e9c5d5b7..b76a6442 100644 --- a/packages/flutter_solidart/test/signal_builder_exceptions_test.dart +++ b/packages/flutter_solidart/test/signal_builder_exceptions_test.dart @@ -71,7 +71,8 @@ void main() { ); testWidgets( - 'SignalBuilder.build asserts without dependencies (covers _isBuilding guard)', + 'SignalBuilder.build asserts without dependencies ' + '(covers _isBuilding guard)', (tester) async { final previousAssert = SolidartConfig.assertSignalBuilderWithoutDependencies; From e1a90a5d16bbab026153e8749525baa3e82bb93e Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:38:11 +0800 Subject: [PATCH 085/121] Fix call operator test to use type inference --- packages/solidart/test/call_operator_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/solidart/test/call_operator_test.dart b/packages/solidart/test/call_operator_test.dart index cfc51d20..c6c63c1d 100644 --- a/packages/solidart/test/call_operator_test.dart +++ b/packages/solidart/test/call_operator_test.dart @@ -19,7 +19,7 @@ void main() { test('ReadonlySignal call() works on typed reference', () { final counter = Signal(0); - final ReadonlySignal readonly = counter.toReadonly(); + final readonly = counter.toReadonly(); expect(readonly(), 0); From 74bfca79c9c6839ea43c1e8c0c0dd404bb8d4ceb Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:40:38 +0800 Subject: [PATCH 086/121] Fix SignalBuilder doc grammar --- packages/flutter_solidart/lib/src/widgets/signal_builder.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart index 0a07cb22..5a0d8601 100644 --- a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart +++ b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart @@ -47,8 +47,8 @@ class SignalBuilder extends StatelessWidget { /// /// This argument is optional and can be null if the entire widget subtree /// the [builder] builds depends on the value of the signals. - /// If you have a widget in the subtree that do not depend on the values of - /// the signals, use this argument, because it won't be rebuilded. + /// If you have a widget in the subtree that does not depend on the values of + /// the signals, use this argument, because it won't be rebuilt. /// {@endtemplate} final Widget? child; From 437f7200968fa005c9b691a0e37e05615c89e770 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:45:25 +0800 Subject: [PATCH 087/121] Document effect disposal in collection examples --- docs-v2/src/content/docs/advanced/list_signal.mdx | 3 +++ docs-v2/src/content/docs/advanced/map_signal.mdx | 3 +++ docs-v2/src/content/docs/advanced/set_signal.mdx | 3 +++ 3 files changed, 9 insertions(+) diff --git a/docs-v2/src/content/docs/advanced/list_signal.mdx b/docs-v2/src/content/docs/advanced/list_signal.mdx index a1e4ba0e..7054dda8 100644 --- a/docs-v2/src/content/docs/advanced/list_signal.mdx +++ b/docs-v2/src/content/docs/advanced/list_signal.mdx @@ -17,6 +17,9 @@ Effect(() { print('Items: ${items.value}'); }); +// Remember to call effect.dispose() when you're done. +// See: /advanced/automatic-disposal + items.add(3); // prints "Items: [1, 2, 3]" items[0] = 10; // prints "Items: [10, 2, 3]" ``` diff --git a/docs-v2/src/content/docs/advanced/map_signal.mdx b/docs-v2/src/content/docs/advanced/map_signal.mdx index c522600c..54da919a 100644 --- a/docs-v2/src/content/docs/advanced/map_signal.mdx +++ b/docs-v2/src/content/docs/advanced/map_signal.mdx @@ -21,6 +21,9 @@ Effect(() { print('Items: ${items.value}'); }); +// Remember to call effect.dispose() when you're done. +// See: /advanced/automatic-disposal + items['c'] = 3; // prints "Items: {a: 1, b: 2, c: 3}" items.remove('a'); // prints "Items: {b: 2, c: 3}" ``` diff --git a/docs-v2/src/content/docs/advanced/set_signal.mdx b/docs-v2/src/content/docs/advanced/set_signal.mdx index c2c37301..4448103d 100644 --- a/docs-v2/src/content/docs/advanced/set_signal.mdx +++ b/docs-v2/src/content/docs/advanced/set_signal.mdx @@ -16,6 +16,9 @@ Effect(() { print('Items: ${items.value}'); }); +// Remember to call effect.dispose() when you're done. +// See: /advanced/automatic-disposal + items.add(3); // prints "Items: {1, 2, 3}" items.remove(1); // prints "Items: {2, 3}" ``` From 1e20c86e31a0a22a679e2745ffbfe7a8897ed74d Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 13 Jan 2026 21:38:37 +0800 Subject: [PATCH 088/121] Rename solidart_hooks example pages --- packages/solidart_hooks/example/lib/main.dart | 6 +++--- .../pages/{use_reactive_list.dart => use_list_signal.dart} | 0 .../pages/{use_reactive_map.dart => use_map_signal.dart} | 0 .../pages/{use_reactive_set.dart => use_set_signal.dart} | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename packages/solidart_hooks/example/lib/pages/{use_reactive_list.dart => use_list_signal.dart} (100%) rename packages/solidart_hooks/example/lib/pages/{use_reactive_map.dart => use_map_signal.dart} (100%) rename packages/solidart_hooks/example/lib/pages/{use_reactive_set.dart => use_set_signal.dart} (100%) diff --git a/packages/solidart_hooks/example/lib/main.dart b/packages/solidart_hooks/example/lib/main.dart index c9c87093..7206a740 100644 --- a/packages/solidart_hooks/example/lib/main.dart +++ b/packages/solidart_hooks/example/lib/main.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'pages/use_signal.dart'; -import 'pages/use_reactive_list.dart'; -import 'pages/use_reactive_set.dart'; -import 'pages/use_reactive_map.dart'; +import 'pages/use_list_signal.dart'; +import 'pages/use_set_signal.dart'; +import 'pages/use_map_signal.dart'; import 'pages/use_computed.dart'; import 'pages/use_resource.dart'; import 'pages/use_resource_stream.dart'; diff --git a/packages/solidart_hooks/example/lib/pages/use_reactive_list.dart b/packages/solidart_hooks/example/lib/pages/use_list_signal.dart similarity index 100% rename from packages/solidart_hooks/example/lib/pages/use_reactive_list.dart rename to packages/solidart_hooks/example/lib/pages/use_list_signal.dart diff --git a/packages/solidart_hooks/example/lib/pages/use_reactive_map.dart b/packages/solidart_hooks/example/lib/pages/use_map_signal.dart similarity index 100% rename from packages/solidart_hooks/example/lib/pages/use_reactive_map.dart rename to packages/solidart_hooks/example/lib/pages/use_map_signal.dart diff --git a/packages/solidart_hooks/example/lib/pages/use_reactive_set.dart b/packages/solidart_hooks/example/lib/pages/use_set_signal.dart similarity index 100% rename from packages/solidart_hooks/example/lib/pages/use_reactive_set.dart rename to packages/solidart_hooks/example/lib/pages/use_set_signal.dart From d459be92bce6630f2feb2657976cb35eb4f58b25 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:38:56 +0800 Subject: [PATCH 089/121] Wrap Computed and Resource as Listenable --- .../lib/flutter_solidart.dart | 7 +++- .../lib/src/core/computed.dart | 16 +++++++++ .../lib/src/core/resource.dart | 33 +++++++++++++++++++ .../lib/src/utils/extensions.dart | 22 +++++++------ .../test/flutter_solidart_test.dart | 14 ++++++++ 5 files changed, 81 insertions(+), 11 deletions(-) create mode 100644 packages/flutter_solidart/lib/src/core/computed.dart create mode 100644 packages/flutter_solidart/lib/src/core/resource.dart diff --git a/packages/flutter_solidart/lib/flutter_solidart.dart b/packages/flutter_solidart/lib/flutter_solidart.dart index 0f3cf6d1..9826b109 100644 --- a/packages/flutter_solidart/lib/flutter_solidart.dart +++ b/packages/flutter_solidart/lib/flutter_solidart.dart @@ -2,7 +2,12 @@ /// Flutter solidart library. library; -export 'package:solidart/solidart.dart'; +export 'package:solidart/solidart.dart' + hide Computed, LazySignal, ReadonlySignal, Resource, Signal; + +export 'src/core/computed.dart'; +export 'src/core/resource.dart'; +export 'src/core/signal.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 new file mode 100644 index 00000000..024e3b50 --- /dev/null +++ b/packages/flutter_solidart/lib/src/core/computed.dart @@ -0,0 +1,16 @@ +import 'package:flutter_solidart/src/core/signal.dart' show ReadonlySignal; +import 'package:flutter_solidart/src/core/value_listenable_signal_mixin.dart'; +import 'package:solidart/solidart.dart' as core; + +class Computed extends core.Computed + with SignalValueListenableMixin + implements ReadonlySignal { + Computed( + super.getter, { + super.equals, + super.autoDispose, + super.name, + super.trackPreviousValue, + super.trackInDevTools, + }); +} diff --git a/packages/flutter_solidart/lib/src/core/resource.dart b/packages/flutter_solidart/lib/src/core/resource.dart new file mode 100644 index 00000000..5a21588d --- /dev/null +++ b/packages/flutter_solidart/lib/src/core/resource.dart @@ -0,0 +1,33 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:flutter_solidart/src/core/value_listenable_signal_mixin.dart'; +import 'package:solidart/solidart.dart' as core; + +class Resource extends core.Resource + with SignalValueListenableMixin> + implements ReadonlySignal> { + Resource( + super.fetcher, { + super.source, + super.lazy, + super.useRefreshing, + super.trackPreviousState, + super.debounceDelay, + super.autoDispose, + super.name, + super.trackInDevTools, + super.equals, + }); + + Resource.stream( + super.stream, { + super.source, + super.lazy, + super.useRefreshing, + super.trackPreviousState, + super.debounceDelay, + super.autoDispose, + super.name, + super.trackInDevTools, + super.equals, + }) : super.stream(); +} diff --git a/packages/flutter_solidart/lib/src/utils/extensions.dart b/packages/flutter_solidart/lib/src/utils/extensions.dart index f6412afd..1b1217c3 100644 --- a/packages/flutter_solidart/lib/src/utils/extensions.dart +++ b/packages/flutter_solidart/lib/src/utils/extensions.dart @@ -1,20 +1,21 @@ import 'package:flutter/foundation.dart'; -import 'package:solidart/solidart.dart'; +import 'package:flutter_solidart/src/core/signal.dart'; +import 'package:solidart/solidart.dart' as core; /// {@template readonly-signal-to-value-notifier} -/// Converts a [ReadonlySignal] into a [ValueNotifier]. +/// Converts a [core.ReadonlySignal] into a [ValueNotifier]. /// /// The returned notifier stays in sync with the signal and disposes its /// internal effect when the notifier or the signal is disposed. /// {@endtemplate} -extension ReadonlySignalToValueNotifier on ReadonlySignal { +extension ReadonlySignalToValueNotifier on core.ReadonlySignal { /// {@macro readonly-signal-to-value-notifier} ValueNotifier toValueNotifier() => _SignalValueNotifier(this); } class _SignalValueNotifier extends ValueNotifier { _SignalValueNotifier(this._signal) : super(_readValue(_signal)) { - _effect = Effect( + _effect = core.Effect( () => value = _readValue(_signal), autoDispose: false, detach: true, @@ -22,8 +23,8 @@ class _SignalValueNotifier extends ValueNotifier { _signal.onDispose(_effect.dispose); } - final ReadonlySignal _signal; - late final Effect _effect; + final core.ReadonlySignal _signal; + late final core.Effect _effect; @override void dispose() { @@ -32,9 +33,9 @@ class _SignalValueNotifier extends ValueNotifier { } } -T _readValue(ReadonlySignal signal) { - if (signal is Resource) { - return (signal as Resource).state as T; +T _readValue(core.ReadonlySignal signal) { + if (signal is core.Resource) { + return (signal as core.Resource).state as T; } return signal.value; } @@ -52,7 +53,7 @@ extension ValueListenableToSignal on ValueListenable { bool? autoDispose, bool? trackPreviousValue, bool? trackInDevTools, - ValueComparator equals = identical, + core.ValueComparator equals = identical, }) { final signal = Signal( value, @@ -66,6 +67,7 @@ extension ValueListenableToSignal on ValueListenable { void sync() => signal.value = value; addListener(sync); signal.onDispose(() => removeListener(sync)); + return signal; } } diff --git a/packages/flutter_solidart/test/flutter_solidart_test.dart b/packages/flutter_solidart/test/flutter_solidart_test.dart index 90351d56..5c4fefd4 100644 --- a/packages/flutter_solidart/test/flutter_solidart_test.dart +++ b/packages/flutter_solidart/test/flutter_solidart_test.dart @@ -32,6 +32,20 @@ class NumberContainer { } void main() { + test('Signals, Computed, and Resource implement Listenable', () { + final signal = Signal(0); + final computed = Computed(() => signal.value * 2); + final resource = Resource(() async => 1); + + expect(signal, isA()); + expect(computed, isA()); + expect(resource, isA()); + + signal.dispose(); + computed.dispose(); + resource.dispose(); + }); + testWidgets('(Provider) Not found signal throws an error', (tester) async { final counterProvider = Provider((_) => Signal(0)); final invalidCounterProvider = Provider((_) => Signal(0)); From 48d1319b8ebf0ec8ab349422b6a8478604240170 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:40:08 +0800 Subject: [PATCH 090/121] Add Listenable Signal wrapper --- .../flutter_solidart/lib/src/core/signal.dart | 38 +++++++++++++ .../core/value_listenable_signal_mixin.dart | 54 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 packages/flutter_solidart/lib/src/core/signal.dart create mode 100644 packages/flutter_solidart/lib/src/core/value_listenable_signal_mixin.dart diff --git a/packages/flutter_solidart/lib/src/core/signal.dart b/packages/flutter_solidart/lib/src/core/signal.dart new file mode 100644 index 00000000..2d034367 --- /dev/null +++ b/packages/flutter_solidart/lib/src/core/signal.dart @@ -0,0 +1,38 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_solidart/src/core/value_listenable_signal_mixin.dart'; +import 'package:solidart/solidart.dart' as core; + +abstract interface class ReadonlySignal implements ValueListenable {} + +class Signal extends core.Signal + with SignalValueListenableMixin + implements ReadonlySignal { + Signal( + super.initialValue, { + super.autoDispose, + super.name, + super.equals, + super.trackPreviousValue, + super.trackInDevTools, + }); + + factory Signal.lazy({ + bool? autoDispose, + String? name, + core.ValueComparator equals, + bool? trackPreviousValue, + bool? trackInDevTools, + }) = LazySignal; +} + +class LazySignal extends core.LazySignal + with SignalValueListenableMixin + implements Signal, ReadonlySignal { + LazySignal({ + super.autoDispose, + super.name, + super.equals, + super.trackPreviousValue, + super.trackInDevTools, + }); +} 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 new file mode 100644 index 00000000..0613cae0 --- /dev/null +++ b/packages/flutter_solidart/lib/src/core/value_listenable_signal_mixin.dart @@ -0,0 +1,54 @@ +import 'package:flutter/foundation.dart'; +import 'package:solidart/solidart.dart'; + +mixin SignalValueListenableMixin on ReadonlySignal + implements ValueListenable { + final List _listeners = []; + + Effect? _effect; + bool _skipped = false; + bool _disposeAttached = false; + + void _ensureEffect() { + if (_effect != null) return; + _skipped = false; + _effect = Effect( + () { + value; + if (!_skipped) { + _skipped = true; + return; + } + if (_listeners.isEmpty) return; + for (final callback in List.from(_listeners)) { + callback(); + } + }, + autoDispose: false, + detach: true, + ); + if (!_disposeAttached) { + _disposeAttached = true; + onDispose(() { + _effect?.dispose(); + _effect = null; + _listeners.clear(); + }); + } + } + + @override + void addListener(VoidCallback listener) { + _listeners.add(listener); + _ensureEffect(); + } + + @override + void removeListener(VoidCallback listener) { + _listeners.remove(listener); + if (_listeners.isEmpty) { + _effect?.dispose(); + _effect = null; + } + } +} From 91ae8175093ed755cf128f011b7358aa20b0eab4 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:45:54 +0800 Subject: [PATCH 091/121] Document listenable wrappers --- packages/flutter_solidart/lib/src/core/computed.dart | 2 ++ packages/flutter_solidart/lib/src/core/resource.dart | 3 +++ packages/flutter_solidart/lib/src/core/signal.dart | 9 ++++++++- .../lib/src/core/value_listenable_signal_mixin.dart | 1 + 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/flutter_solidart/lib/src/core/computed.dart b/packages/flutter_solidart/lib/src/core/computed.dart index 024e3b50..4aa5b2e1 100644 --- a/packages/flutter_solidart/lib/src/core/computed.dart +++ b/packages/flutter_solidart/lib/src/core/computed.dart @@ -2,9 +2,11 @@ import 'package:flutter_solidart/src/core/signal.dart' show ReadonlySignal; import 'package:flutter_solidart/src/core/value_listenable_signal_mixin.dart'; import 'package:solidart/solidart.dart' as core; +/// A Solidart [core.Computed] that is also a Flutter [ValueListenable]. class Computed extends core.Computed with SignalValueListenableMixin implements ReadonlySignal { + /// Creates a new [Computed] and exposes it as a [ValueListenable]. Computed( super.getter, { super.equals, diff --git a/packages/flutter_solidart/lib/src/core/resource.dart b/packages/flutter_solidart/lib/src/core/resource.dart index 5a21588d..1ebb31bf 100644 --- a/packages/flutter_solidart/lib/src/core/resource.dart +++ b/packages/flutter_solidart/lib/src/core/resource.dart @@ -2,9 +2,11 @@ import 'package:flutter_solidart/flutter_solidart.dart'; import 'package:flutter_solidart/src/core/value_listenable_signal_mixin.dart'; import 'package:solidart/solidart.dart' as core; +/// A Solidart [core.Resource] that is also a Flutter [ValueListenable]. class Resource extends core.Resource with SignalValueListenableMixin> implements ReadonlySignal> { + /// Creates a new [Resource] and exposes it as a [ValueListenable]. Resource( super.fetcher, { super.source, @@ -18,6 +20,7 @@ class Resource extends core.Resource super.equals, }); + /// Creates a stream-based [Resource] and exposes it as a [ValueListenable]. Resource.stream( super.stream, { super.source, diff --git a/packages/flutter_solidart/lib/src/core/signal.dart b/packages/flutter_solidart/lib/src/core/signal.dart index 2d034367..8df8145a 100644 --- a/packages/flutter_solidart/lib/src/core/signal.dart +++ b/packages/flutter_solidart/lib/src/core/signal.dart @@ -2,11 +2,15 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_solidart/src/core/value_listenable_signal_mixin.dart'; import 'package:solidart/solidart.dart' as core; -abstract interface class ReadonlySignal implements ValueListenable {} +/// A Solidart [core.ReadonlySignal] that is also a Flutter [ValueListenable]. +abstract interface class ReadonlySignal + implements core.ReadonlySignal, ValueListenable {} +/// A Solidart [core.Signal] that is also a Flutter [ValueListenable]. class Signal extends core.Signal with SignalValueListenableMixin implements ReadonlySignal { + /// Creates a new [Signal] and exposes it as a [ValueListenable]. Signal( super.initialValue, { super.autoDispose, @@ -16,6 +20,7 @@ class Signal extends core.Signal super.trackInDevTools, }); + /// Creates a lazy [Signal] and exposes it as a [ValueListenable]. factory Signal.lazy({ bool? autoDispose, String? name, @@ -25,9 +30,11 @@ class Signal extends core.Signal }) = LazySignal; } +/// A lazy [Signal] that is also a Flutter [ValueListenable]. class LazySignal extends core.LazySignal with SignalValueListenableMixin implements Signal, ReadonlySignal { + /// Creates a lazy [Signal] and exposes it as a [ValueListenable]. LazySignal({ super.autoDispose, super.name, 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 index 0613cae0..e0d2ce68 100644 --- a/packages/flutter_solidart/lib/src/core/value_listenable_signal_mixin.dart +++ b/packages/flutter_solidart/lib/src/core/value_listenable_signal_mixin.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:solidart/solidart.dart'; +/// Adds Flutter [ValueListenable] behavior to a Solidart [ReadonlySignal]. mixin SignalValueListenableMixin on ReadonlySignal implements ValueListenable { final List _listeners = []; From db073db50e2a9cb2c5b0c1d8804c645219666c50 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:51:37 +0800 Subject: [PATCH 092/121] Wrap collection signals as listenable --- .../lib/flutter_solidart.dart | 10 ++++- .../flutter_solidart/lib/src/core/signal.dart | 45 +++++++++++++++++++ .../test/flutter_solidart_test.dart | 11 ++++- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/flutter_solidart/lib/flutter_solidart.dart b/packages/flutter_solidart/lib/flutter_solidart.dart index 9826b109..9e56da38 100644 --- a/packages/flutter_solidart/lib/flutter_solidart.dart +++ b/packages/flutter_solidart/lib/flutter_solidart.dart @@ -3,7 +3,15 @@ library; export 'package:solidart/solidart.dart' - hide Computed, LazySignal, ReadonlySignal, Resource, Signal; + hide + Computed, + LazySignal, + ListSignal, + MapSignal, + ReadonlySignal, + Resource, + SetSignal, + Signal; export 'src/core/computed.dart'; export 'src/core/resource.dart'; diff --git a/packages/flutter_solidart/lib/src/core/signal.dart b/packages/flutter_solidart/lib/src/core/signal.dart index 8df8145a..40f08b07 100644 --- a/packages/flutter_solidart/lib/src/core/signal.dart +++ b/packages/flutter_solidart/lib/src/core/signal.dart @@ -43,3 +43,48 @@ class LazySignal extends core.LazySignal super.trackInDevTools, }); } + +/// A Solidart [core.ListSignal] that is also a Flutter [ValueListenable]. +class ListSignal extends core.ListSignal + with SignalValueListenableMixin> + implements ReadonlySignal> { + /// Creates a new [ListSignal] and exposes it as a [ValueListenable]. + ListSignal( + super.initialValue, { + super.autoDispose, + super.name, + super.equals, + super.trackPreviousValue, + super.trackInDevTools, + }); +} + +/// A Solidart [core.SetSignal] that is also a Flutter [ValueListenable]. +class SetSignal extends core.SetSignal + with SignalValueListenableMixin> + implements ReadonlySignal> { + /// Creates a new [SetSignal] and exposes it as a [ValueListenable]. + SetSignal( + super.initialValue, { + super.autoDispose, + super.name, + super.equals, + super.trackPreviousValue, + super.trackInDevTools, + }); +} + +/// A Solidart [core.MapSignal] that is also a Flutter [ValueListenable]. +class MapSignal extends core.MapSignal + with SignalValueListenableMixin> + implements ReadonlySignal> { + /// Creates a new [MapSignal] and exposes it as a [ValueListenable]. + MapSignal( + super.initialValue, { + super.autoDispose, + super.name, + super.equals, + super.trackPreviousValue, + super.trackInDevTools, + }); +} diff --git a/packages/flutter_solidart/test/flutter_solidart_test.dart b/packages/flutter_solidart/test/flutter_solidart_test.dart index 5c4fefd4..8f95524c 100644 --- a/packages/flutter_solidart/test/flutter_solidart_test.dart +++ b/packages/flutter_solidart/test/flutter_solidart_test.dart @@ -32,18 +32,27 @@ class NumberContainer { } void main() { - test('Signals, Computed, and Resource implement Listenable', () { + test('Signals, Computed, Resources, and collections implement Listenable', () { final signal = Signal(0); final computed = Computed(() => signal.value * 2); final resource = Resource(() async => 1); + final listSignal = ListSignal([1, 2, 3]); + final setSignal = SetSignal({1, 2, 3}); + final mapSignal = MapSignal({'a': 1}); expect(signal, isA()); expect(computed, isA()); expect(resource, isA()); + expect(listSignal, isA()); + expect(setSignal, isA()); + expect(mapSignal, isA()); signal.dispose(); computed.dispose(); resource.dispose(); + listSignal.dispose(); + setSignal.dispose(); + mapSignal.dispose(); }); testWidgets('(Provider) Not found signal throws an error', (tester) async { From deef83d4a39fe3ae49d6c33db035c5fe115a6a44 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:59:54 +0800 Subject: [PATCH 093/121] Align listenable signal wrappers --- .../lib/flutter_solidart.dart | 1 - .../lib/src/core/computed.dart | 6 ++---- .../lib/src/core/resource.dart | 5 ++--- .../flutter_solidart/lib/src/core/signal.dart | 19 +++++-------------- .../core/value_listenable_signal_mixin.dart | 10 +++++----- .../test/flutter_solidart_test.dart | 8 ++++++-- 6 files changed, 20 insertions(+), 29 deletions(-) diff --git a/packages/flutter_solidart/lib/flutter_solidart.dart b/packages/flutter_solidart/lib/flutter_solidart.dart index 9e56da38..131cd0e2 100644 --- a/packages/flutter_solidart/lib/flutter_solidart.dart +++ b/packages/flutter_solidart/lib/flutter_solidart.dart @@ -8,7 +8,6 @@ export 'package:solidart/solidart.dart' LazySignal, ListSignal, MapSignal, - ReadonlySignal, Resource, SetSignal, Signal; diff --git a/packages/flutter_solidart/lib/src/core/computed.dart b/packages/flutter_solidart/lib/src/core/computed.dart index 4aa5b2e1..295b0739 100644 --- a/packages/flutter_solidart/lib/src/core/computed.dart +++ b/packages/flutter_solidart/lib/src/core/computed.dart @@ -1,11 +1,9 @@ -import 'package:flutter_solidart/src/core/signal.dart' show ReadonlySignal; +import 'package:flutter/foundation.dart'; import 'package:flutter_solidart/src/core/value_listenable_signal_mixin.dart'; import 'package:solidart/solidart.dart' as core; /// A Solidart [core.Computed] that is also a Flutter [ValueListenable]. -class Computed extends core.Computed - with SignalValueListenableMixin - implements ReadonlySignal { +class Computed extends core.Computed with SignalValueListenableMixin { /// Creates a new [Computed] and exposes it as a [ValueListenable]. Computed( super.getter, { diff --git a/packages/flutter_solidart/lib/src/core/resource.dart b/packages/flutter_solidart/lib/src/core/resource.dart index 1ebb31bf..72034ab9 100644 --- a/packages/flutter_solidart/lib/src/core/resource.dart +++ b/packages/flutter_solidart/lib/src/core/resource.dart @@ -1,11 +1,10 @@ -import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_solidart/src/core/value_listenable_signal_mixin.dart'; import 'package:solidart/solidart.dart' as core; /// A Solidart [core.Resource] that is also a Flutter [ValueListenable]. class Resource extends core.Resource - with SignalValueListenableMixin> - implements ReadonlySignal> { + with SignalValueListenableMixin> { /// Creates a new [Resource] and exposes it as a [ValueListenable]. Resource( super.fetcher, { diff --git a/packages/flutter_solidart/lib/src/core/signal.dart b/packages/flutter_solidart/lib/src/core/signal.dart index 40f08b07..ea908a6f 100644 --- a/packages/flutter_solidart/lib/src/core/signal.dart +++ b/packages/flutter_solidart/lib/src/core/signal.dart @@ -2,14 +2,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_solidart/src/core/value_listenable_signal_mixin.dart'; import 'package:solidart/solidart.dart' as core; -/// A Solidart [core.ReadonlySignal] that is also a Flutter [ValueListenable]. -abstract interface class ReadonlySignal - implements core.ReadonlySignal, ValueListenable {} - /// A Solidart [core.Signal] that is also a Flutter [ValueListenable]. -class Signal extends core.Signal - with SignalValueListenableMixin - implements ReadonlySignal { +class Signal extends core.Signal with SignalValueListenableMixin { /// Creates a new [Signal] and exposes it as a [ValueListenable]. Signal( super.initialValue, { @@ -33,7 +27,7 @@ class Signal extends core.Signal /// A lazy [Signal] that is also a Flutter [ValueListenable]. class LazySignal extends core.LazySignal with SignalValueListenableMixin - implements Signal, ReadonlySignal { + implements Signal { /// Creates a lazy [Signal] and exposes it as a [ValueListenable]. LazySignal({ super.autoDispose, @@ -46,8 +40,7 @@ class LazySignal extends core.LazySignal /// A Solidart [core.ListSignal] that is also a Flutter [ValueListenable]. class ListSignal extends core.ListSignal - with SignalValueListenableMixin> - implements ReadonlySignal> { + with SignalValueListenableMixin> { /// Creates a new [ListSignal] and exposes it as a [ValueListenable]. ListSignal( super.initialValue, { @@ -61,8 +54,7 @@ class ListSignal extends core.ListSignal /// A Solidart [core.SetSignal] that is also a Flutter [ValueListenable]. class SetSignal extends core.SetSignal - with SignalValueListenableMixin> - implements ReadonlySignal> { + with SignalValueListenableMixin> { /// Creates a new [SetSignal] and exposes it as a [ValueListenable]. SetSignal( super.initialValue, { @@ -76,8 +68,7 @@ class SetSignal extends core.SetSignal /// A Solidart [core.MapSignal] that is also a Flutter [ValueListenable]. class MapSignal extends core.MapSignal - with SignalValueListenableMixin> - implements ReadonlySignal> { + with SignalValueListenableMixin> { /// Creates a new [MapSignal] and exposes it as a [ValueListenable]. MapSignal( super.initialValue, { 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 index e0d2ce68..9167f172 100644 --- a/packages/flutter_solidart/lib/src/core/value_listenable_signal_mixin.dart +++ b/packages/flutter_solidart/lib/src/core/value_listenable_signal_mixin.dart @@ -1,19 +1,19 @@ import 'package:flutter/foundation.dart'; -import 'package:solidart/solidart.dart'; +import 'package:solidart/solidart.dart' as core; -/// Adds Flutter [ValueListenable] behavior to a Solidart [ReadonlySignal]. -mixin SignalValueListenableMixin on ReadonlySignal +/// Adds Flutter [ValueListenable] behavior to a Solidart [core.ReadonlySignal]. +mixin SignalValueListenableMixin on core.ReadonlySignal implements ValueListenable { final List _listeners = []; - Effect? _effect; + core.Effect? _effect; bool _skipped = false; bool _disposeAttached = false; void _ensureEffect() { if (_effect != null) return; _skipped = false; - _effect = Effect( + _effect = core.Effect( () { value; if (!_skipped) { diff --git a/packages/flutter_solidart/test/flutter_solidart_test.dart b/packages/flutter_solidart/test/flutter_solidart_test.dart index 8f95524c..4be463cb 100644 --- a/packages/flutter_solidart/test/flutter_solidart_test.dart +++ b/packages/flutter_solidart/test/flutter_solidart_test.dart @@ -32,7 +32,10 @@ class NumberContainer { } void main() { - test('Signals, Computed, Resources, and collections implement Listenable', () { + test( + 'Signals, Computed, Resources, and collections ' + 'implement Listenable', + () { final signal = Signal(0); final computed = Computed(() => signal.value * 2); final resource = Resource(() async => 1); @@ -53,7 +56,8 @@ void main() { listSignal.dispose(); setSignal.dispose(); mapSignal.dispose(); - }); + }, + ); testWidgets('(Provider) Not found signal throws an error', (tester) async { final counterProvider = Provider((_) => Signal(0)); From 7b329cf424ad36ef803b1f991ce6c4c2fb76ce1f Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:07:41 +0800 Subject: [PATCH 094/121] Restore observe extension --- packages/solidart/lib/solidart.dart | 3 +++ packages/solidart/lib/src/solidart.dart | 35 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/packages/solidart/lib/solidart.dart b/packages/solidart/lib/solidart.dart index 4a4fed2b..8c448e32 100644 --- a/packages/solidart/lib/solidart.dart +++ b/packages/solidart/lib/solidart.dart @@ -1,10 +1,13 @@ export 'src/solidart.dart' show Computed, + DisposeObservation, Effect, LazySignal, ListSignal, MapSignal, + ObserveCallback, + ObserveSignal, ReadonlySignal, Resource, ResourceError, diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index b338e046..00c4c41a 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -14,6 +14,12 @@ import 'package:solidart/deps/system.dart' as system; /// considered equivalent. typedef ValueComparator = bool Function(T? a, T? b); +/// Signature for callbacks fired when a signal changes. +typedef ObserveCallback = void Function(T? previousValue, T value); + +/// Disposer returned by [ObserveSignal.observe]. +typedef DisposeObservation = void Function(); + /// Lazily produces a value. typedef ValueGetter = T Function(); @@ -502,6 +508,35 @@ abstract interface class ReadonlySignal T? get untrackedPreviousValue; } +/// Observes [ReadonlySignal] changes with previous and current values. +extension ObserveSignal on ReadonlySignal { + /// Observe the signal and invoke [listener] whenever the value changes. + /// + /// When [fireImmediately] is `true`, the listener runs once on subscription. + /// Returns a disposer that stops the observation. + DisposeObservation observe( + ObserveCallback listener, { + bool fireImmediately = false, + }) { + var skipped = false; + final effect = Effect( + () { + value; + if (!fireImmediately && !skipped) { + skipped = true; + return; + } + untracked(() { + listener(untrackedPreviousValue, untrackedValue); + }); + }, + detach: true, + ); + + return effect.dispose; + } +} + /// {@template v3-signal} /// # Signals /// Signals are the cornerstone of reactivity in v3. They store values that From 65c5931a1b4af22f80b57b0a6613781019c76783 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:09:16 +0800 Subject: [PATCH 095/121] Drop v3 from devtools extension names --- packages/solidart/lib/src/solidart.dart | 2 +- packages/solidart_devtools_extension/lib/main.dart | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index 00c4c41a..b5e40fbd 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -242,7 +242,7 @@ void _notifyDevToolsAboutSignal( required _DevToolsEventType eventType, }) { if (!SolidartConfig.devToolsEnabled || !signal.trackInDevTools) return; - final eventName = 'ext.solidart.v3.signal.${eventType.name}'; + final eventName = 'ext.solidart.signal.${eventType.name}'; final value = _signalValue(signal); final previousValue = _signalPreviousValue(signal); final hasPreviousValue = _hasPreviousValue(signal); diff --git a/packages/solidart_devtools_extension/lib/main.dart b/packages/solidart_devtools_extension/lib/main.dart index d83be792..f4f70dbe 100644 --- a/packages/solidart_devtools_extension/lib/main.dart +++ b/packages/solidart_devtools_extension/lib/main.dart @@ -138,16 +138,16 @@ class _SignalsState extends State { sub = vmService.onExtensionEvent .where((e) { final kind = e.extensionKind; - return kind != null && kind.startsWith('ext.solidart.v3.signal'); + return kind != null && kind.startsWith('ext.solidart.signal'); }) .listen((event) { final data = event.extensionData?.data; if (data == null) return; final kind = event.extensionKind; switch (kind) { - case 'ext.solidart.v3.signal.created': - case 'ext.solidart.v3.signal.updated': - case 'ext.solidart.v3.signal.disposed': + case 'ext.solidart.signal.created': + case 'ext.solidart.signal.updated': + case 'ext.solidart.signal.disposed': final signalId = data['_id'].toString(); signals[signalId] = SignalData( name: data['name'] ?? data['_id'], From 147e310e025239f6103263a363ded557b42e64ca Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:10:40 +0800 Subject: [PATCH 096/121] Rename v3 templates --- packages/solidart/lib/src/solidart.dart | 52 ++++++++++++------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index b5e40fbd..9f919059 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -62,7 +62,7 @@ final class None extends Option { const None(); } -/// {@template v3-config} +/// {@template solidart.config} /// Global configuration for v3 reactive primitives. /// /// These flags provide defaults for newly created signals/effects/resources. @@ -119,7 +119,7 @@ final class SolidartConfig { static final observers = []; } -/// {@template v3-observer} +/// {@template solidart.observer} /// Observer for signal lifecycle events. /// /// Use this for logging or instrumentation without depending on DevTools: @@ -139,7 +139,7 @@ final class SolidartConfig { /// ``` /// {@endtemplate} abstract class SolidartObserver { - /// {@macro v3-observer} + /// {@macro solidart.observer} const SolidartObserver(); // coverage:ignore-line /// Called when a signal is created. @@ -537,7 +537,7 @@ extension ObserveSignal on ReadonlySignal { } } -/// {@template v3-signal} +/// {@template solidart.signal} /// # Signals /// Signals are the cornerstone of reactivity in v3. They store values that /// change over time, and any reactive computation that reads a signal will @@ -574,16 +574,16 @@ extension ObserveSignal on ReadonlySignal { /// have a value until it is first assigned, and reading it early throws /// [StateError]. /// {@endtemplate} -/// {@template v3-signal-equals} +/// {@template solidart.signal-equals} /// Updates are skipped when [equals] reports the new value is equivalent to /// the previous one. /// {@endtemplate} class Signal extends preset.SignalNode> with DisposableMixin implements ReadonlySignal { - /// {@macro v3-signal} + /// {@macro solidart.signal} /// - /// {@macro v3-signal-equals} + /// {@macro solidart.signal-equals} Signal( T initialValue, { bool? autoDispose, @@ -620,7 +620,7 @@ class Signal extends preset.SignalNode> _notifySignalCreation(this); } - /// {@macro v3-signal} + /// {@macro solidart.signal} /// /// This is a lazy signal: it has no value at construction time. /// Reading [value] before the first assignment throws [StateError]. @@ -660,7 +660,7 @@ class Signal extends preset.SignalNode> /// Sets the current value. /// - /// {@macro v3-signal-equals} + /// {@macro solidart.signal-equals} set value(T newValue) { assert(!isDisposed, 'Signal is disposed'); set(Some(newValue)); @@ -771,7 +771,7 @@ class LazySignal extends Signal { } } -/// {@template v3-list-signal} +/// {@template solidart.list-signal} /// A reactive wrapper around a [List] that copies on write. /// /// Mutations create a new list instance so that updates are observable: @@ -785,7 +785,7 @@ class LazySignal extends Signal { /// list API is supported. /// {@endtemplate} class ListSignal extends Signal> with ListMixin { - /// {@macro v3-list-signal} + /// {@macro solidart.list-signal} /// /// Creates a reactive list with the provided initial values. ListSignal( @@ -974,7 +974,7 @@ class ListSignal extends Signal> with ListMixin { 'previousValue: $untrackedPreviousValue)'; } -/// {@template v3-set-signal} +/// {@template solidart.set-signal} /// A reactive wrapper around a [Set] that copies on write. /// /// Mutations create a new set instance so that updates are observable: @@ -987,7 +987,7 @@ class ListSignal extends Signal> with ListMixin { /// Reads (like `length` or `contains`) establish dependencies. /// {@endtemplate} class SetSignal extends Signal> with SetMixin { - /// {@macro v3-set-signal} + /// {@macro solidart.set-signal} /// /// Creates a reactive set with the provided initial values. SetSignal( @@ -1103,7 +1103,7 @@ class SetSignal extends Signal> with SetMixin { 'previousValue: $untrackedPreviousValue)'; } -/// {@template v3-map-signal} +/// {@template solidart.map-signal} /// A reactive wrapper around a [Map] that copies on write. /// /// Mutations create a new map instance so that updates are observable: @@ -1116,7 +1116,7 @@ class SetSignal extends Signal> with SetMixin { /// Reads (like `[]`, `keys`, or `length`) establish dependencies. /// {@endtemplate} class MapSignal extends Signal> with MapMixin { - /// {@macro v3-map-signal} + /// {@macro solidart.map-signal} /// /// Creates a reactive map with the provided initial values. MapSignal( @@ -1295,7 +1295,7 @@ class MapSignal extends Signal> with MapMixin { 'previousValue: $untrackedPreviousValue)'; } -/// {@template v3-computed} +/// {@template solidart.computed} /// # Computed /// A computed signal derives its value from other signals. It is read-only /// and recalculates whenever any dependency changes. @@ -1315,7 +1315,7 @@ class MapSignal extends Signal> with MapMixin { class Computed extends preset.ComputedNode with DisposableMixin implements ReadonlySignal { - /// {@macro v3-computed} + /// {@macro solidart.computed} Computed( ValueGetter getter, { this.equals = identical, @@ -1418,7 +1418,7 @@ class Computed extends preset.ComputedNode } } -/// {@template v3-effect} +/// {@template solidart.effect} /// # Effect /// Effects run a side-effect whenever any signal they read changes. /// @@ -1440,7 +1440,7 @@ class Computed extends preset.ComputedNode class Effect extends preset.EffectNode with DisposableMixin implements Disposable, Configuration { - /// {@macro v3-effect} + /// {@macro solidart.effect} factory Effect( VoidCallback callback, { bool? autoDispose, @@ -1520,7 +1520,7 @@ class Effect extends preset.EffectNode } } -/// {@template v3-resource} +/// {@template solidart.resource} /// # Resource /// A resource is a signal designed for async data. It wraps the common states /// of asynchronous work: `ready`, `loading`, and `error`. @@ -1551,7 +1551,7 @@ class Effect extends preset.EffectNode /// to `loading`. /// {@endtemplate} class Resource extends Signal> { - /// {@macro v3-resource} + /// {@macro solidart.resource} /// /// Creates a resource backed by a future-producing [fetcher]. Resource( @@ -1581,7 +1581,7 @@ class Resource extends Signal> { } } - /// {@macro v3-resource} + /// {@macro solidart.resource} /// /// Creates a resource backed by a stream factory. /// @@ -1836,7 +1836,7 @@ class Resource extends Signal> { } } -/// {@template v3-resource-state} +/// {@template solidart.resource-state} /// Represents the state of a [Resource]. /// /// A resource is always in one of: @@ -1860,18 +1860,18 @@ sealed class ResourceState { /// Base constructor for resource states. const ResourceState(); // coverage:ignore-line - /// {@macro v3-resource-state} + /// {@macro solidart.resource-state} /// /// Creates a ready state with [data]. const factory ResourceState.ready(T data, {bool isRefreshing}) = ResourceReady; - /// {@macro v3-resource-state} + /// {@macro solidart.resource-state} /// /// Creates a loading state. const factory ResourceState.loading() = ResourceLoading; - /// {@macro v3-resource-state} + /// {@macro solidart.resource-state} /// /// Creates an error state. const factory ResourceState.error( From e1760cc6f0d2643c7269f2953f60799953ee5866 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:20:47 +0800 Subject: [PATCH 097/121] Add ObserveSignal tests --- .../solidart/test/observe_signal_test.dart | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 packages/solidart/test/observe_signal_test.dart diff --git a/packages/solidart/test/observe_signal_test.dart b/packages/solidart/test/observe_signal_test.dart new file mode 100644 index 00000000..230585b8 --- /dev/null +++ b/packages/solidart/test/observe_signal_test.dart @@ -0,0 +1,49 @@ +import 'package:solidart/solidart.dart'; +import 'package:test/test.dart'; + +void main() { + group('ObserveSignal', () { + test('skips initial run and reports changes', () { + final signal = Signal(0); + final calls = >[]; + + final dispose = signal.observe((previous, value) { + calls.add([previous, value]); + }); + + expect(calls, isEmpty); + + signal.value = 1; + expect(calls, equals(>[ + [0, 1], + ])); + + dispose(); + signal.value = 2; + expect(calls, equals(>[ + [0, 1], + ])); + + signal.dispose(); + }); + + test('fires immediately when requested', () { + final signal = Signal(5); + final calls = >[]; + + final dispose = signal.observe( + (previous, value) { + calls.add([previous, value]); + }, + fireImmediately: true, + ); + + expect(calls, equals(>[ + [null, 5], + ])); + + dispose(); + signal.dispose(); + }); + }); +} From 0ea6dd53218dca46f5007eefc9b18129a80f407b Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:25:28 +0800 Subject: [PATCH 098/121] Add listenable wrapper coverage tests --- .../value_listenable_signal_mixin_test.dart | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 packages/flutter_solidart/test/value_listenable_signal_mixin_test.dart diff --git a/packages/flutter_solidart/test/value_listenable_signal_mixin_test.dart b/packages/flutter_solidart/test/value_listenable_signal_mixin_test.dart new file mode 100644 index 00000000..12f0d8e2 --- /dev/null +++ b/packages/flutter_solidart/test/value_listenable_signal_mixin_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Signal listenable notifies listeners and stops after removal', () { + final signal = Signal(0); + var calls = 0; + + void listener() => calls++; + + signal.addListener(listener); + expect(calls, 0); + + signal.value = 1; + expect(calls, 1); + + signal.value = 2; + expect(calls, 2); + + signal.removeListener(listener); + signal.value = 3; + expect(calls, 2); + + signal.dispose(); + }); + + test('Signal listenable disposes effect on signal disposal', () { + final signal = Signal(0); + var calls = 0; + + void listener() => calls++; + + signal.addListener(listener); + signal.value = 1; + expect(calls, 1); + + signal.dispose(); + }); + + test('LazySignal wrapper is constructed', () { + final lazy = LazySignal(); + expect(lazy.isInitialized, isFalse); + + lazy.value = 10; + expect(lazy.value, 10); + + lazy.dispose(); + }); + + test('Resource.stream wrapper constructs and disposes', () { + final resource = Resource.stream( + () => Stream.value(1), + lazy: true, + ); + + expect(resource, isA()); + resource.dispose(); + }); +} From 8a78610b7004fa7b7eddb7131f3aff0372ab7a23 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:27:40 +0800 Subject: [PATCH 099/121] Refine listenable wrapper coverage test --- .../test/value_listenable_signal_mixin_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/flutter_solidart/test/value_listenable_signal_mixin_test.dart b/packages/flutter_solidart/test/value_listenable_signal_mixin_test.dart index 12f0d8e2..77e14d4e 100644 --- a/packages/flutter_solidart/test/value_listenable_signal_mixin_test.dart +++ b/packages/flutter_solidart/test/value_listenable_signal_mixin_test.dart @@ -51,7 +51,6 @@ void main() { test('Resource.stream wrapper constructs and disposes', () { final resource = Resource.stream( () => Stream.value(1), - lazy: true, ); expect(resource, isA()); From cd4ffa518c4f73450e1b8fa62b7b5d3891477c2b Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:43:03 +0800 Subject: [PATCH 100/121] Guard null devtools signal ids --- packages/solidart_devtools_extension/lib/main.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/solidart_devtools_extension/lib/main.dart b/packages/solidart_devtools_extension/lib/main.dart index f4f70dbe..1fd4f282 100644 --- a/packages/solidart_devtools_extension/lib/main.dart +++ b/packages/solidart_devtools_extension/lib/main.dart @@ -148,9 +148,13 @@ class _SignalsState extends State { case 'ext.solidart.signal.created': case 'ext.solidart.signal.updated': case 'ext.solidart.signal.disposed': - final signalId = data['_id'].toString(); + final id = data['_id']; + final signalId = + id == null + ? DateTime.now().microsecondsSinceEpoch.toString() + : id.toString(); signals[signalId] = SignalData( - name: data['name'] ?? data['_id'], + name: data['name'] ?? id ?? signalId, value: jsonDecode(data['value'] ?? 'null'), hasPreviousValue: data['hasPreviousValue'], previousValue: jsonDecode(data['previousValue'] ?? 'null'), From 2caea62be2edf19d5a64d201d4e636fc33a15592 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:31:02 +0800 Subject: [PATCH 101/121] CHANGELOG formatting and consolidate v3 dev entries --- packages/flutter_solidart/CHANGELOG.md | 33 +++++++++++++++---------- packages/solidart/CHANGELOG.md | 34 +++++++++----------------- 2 files changed, 32 insertions(+), 35 deletions(-) diff --git a/packages/flutter_solidart/CHANGELOG.md b/packages/flutter_solidart/CHANGELOG.md index 86492cc2..bf3dbac0 100644 --- a/packages/flutter_solidart/CHANGELOG.md +++ b/packages/flutter_solidart/CHANGELOG.md @@ -101,8 +101,8 @@ - **REFACTOR**: Update `alien_signals` dependency from `^0.2.1` to `^0.4.3` with significant performance improvements (thanks to @medz). - **REFACTOR**: Replace custom reactive node implementations with `alien.ReactiveNode` for better compatibility and performance (thanks to @medz). - **REFACTOR**: Simplify signal, computed and effect implementations by leveraging new `alien_signals` API (thanks to @medz). -- **PERFORMANCE**: Improve performance by removing redundant tracking operations in the reactive system (thanks to @medz). -- **FIX**: Add proper cleanup for disposed nodes to prevent memory leaks (thanks to @medz). +- **PERFORMANCE**: Improve performance by removing redundant tracking operations in the reactive system (thanks to @medz). +- **FIX**: Add proper cleanup for disposed nodes to prevent memory leaks (thanks to @medz). - **FIX**: Fix potential memory leaks in auto-dispose scenarios (thanks to @medz). - **FIX**: Clear queued flag when running effects in `ReactiveSystem` to ensure proper effect execution (thanks to @medz). - **CHORE**: Reorder dev_dependencies in pubspec.yaml for improved organization and readability (thanks to @medz). @@ -141,8 +141,8 @@ ### Changes from solidart -- *CHORE*: Remove deprecated `createSignal`, `createComputed`, `createEffect` and `createResource` helpers. -- *CHORE*: Remove `SignalOptions` and `ResourceOptions` classes. +- _CHORE_: Remove deprecated `createSignal`, `createComputed`, `createEffect` and `createResource` helpers. +- _CHORE_: Remove `SignalOptions` and `ResourceOptions` classes. ## 2.0.0-dev.1 @@ -211,7 +211,7 @@ Update solidart version ### Changes from solidart - **FEAT**: Add 3 new signals: `ListSignal`, `SetSignal` and `MapSignal`. Now you can easily be notified of every change of a list, set or map. - _Before_: + _Before_: ```dart final list = Signal([1, 2]); @@ -256,6 +256,7 @@ The core of the library has been rewritten in order to support automatic depende - The `Show` widget now takes a functions that returns a `bool`. You can easily convert any type to `bool`, for example: + ```dart final count = createSignal(0); @@ -268,11 +269,13 @@ The core of the library has been rewritten in order to support automatic depende ); } ``` + - Converting a `ValueNotifier` into a `Signal` now uses the `equals` comparator to keep the consistency. - Rename `resource` parameter of `ResourceWidgetBuilder` into `resourceState`. (thanks to @manuel-plavsic) - **FEAT** Allow multiple providers of the same type by specifying an `id`entifier. ### Provider declaration: + ```dart SolidProvider( create: () => const NumberProvider(1), @@ -285,6 +288,7 @@ The core of the library has been rewritten in order to support automatic depende ``` ### Access a specific provider + ```dart final numberProvider1 = context.get(1); final numberProvider2 = context.get(2); @@ -312,6 +316,7 @@ The core of the library has been rewritten in order to support automatic depende ], ), ``` + - **FEAT** You can access a specific `Signal` without specifing an `id`entifier, for example: ```dart // to get the signal @@ -386,11 +391,11 @@ The core of the library has been rewritten in order to support automatic depende - **CHORE**: Move `refreshing` from `ResourceWidgetBuilder` into the `ResourceState`. (thanks to @manuel-plavsic) - **FEAT**: Add `hasPreviousValue` getter to `ReadSignal`. (thanks to @manuel-plavsic) - **FEAT** Before, only the `fetcher` reacted to the `source`. -Now also the `stream` reacts to the `source` changes by subscribing again to the stream. -In addition, the `stream` parameter of the Resource has been changed from `Stream` into a `Stream Function()` in order to be able to listen to a new stream if it changed. + Now also the `stream` reacts to the `source` changes by subscribing again to the stream. + In addition, the `stream` parameter of the Resource has been changed from `Stream` into a `Stream Function()` in order to be able to listen to a new stream if it changed. - **FEAT**: Add the `select` method on the `Resource` class. -The `select` function allows filtering the `Resource`'s data by reading only the properties that you care about. -The advantage is that you keep handling the loading and error states. + The `select` function allows filtering the `Resource`'s data by reading only the properties that you care about. + The advantage is that you keep handling the loading and error states. - **FEAT**: Make the `Resource` to auto-resolve when accessing its `state`. - **CHORE**: The `refetch` method of a `Resource` has been renamed to `refresh`. - **FEAT**: You can decide whether to use `createSignal()` or directly the `Signal()` constructor, now the're equivalent. The same applies to all the other `create` functions. @@ -403,8 +408,8 @@ The advantage is that you keep handling the loading and error states. ### Changes from solidart - **FEAT**: Add the select method on the Resource class. -The select function allows filtering the Resource's data by reading only the properties that you care about. -The advantage is that you keep handling the loading and error states. + The select function allows filtering the Resource's data by reading only the properties that you care about. + The advantage is that you keep handling the loading and error states. - **FEAT**: Make the Resource to auto-resolve when accessing its state ## 1.0.0-dev902 @@ -428,6 +433,7 @@ The advantage is that you keep handling the loading and error states. - **FEAT** Allow multiple providers of the same type by specifying an `id`entifier. ### Provider declaration: + ```dart SolidProvider( create: () => const NumberProvider(1), @@ -440,6 +446,7 @@ The advantage is that you keep handling the loading and error states. ``` ### Access a specific provider + ```dart final numberProvider1 = context.get(1); final numberProvider2 = context.get(2); @@ -482,8 +489,8 @@ The advantage is that you keep handling the loading and error states. ### Changes from solidart - **FEAT** Before, only the `fetcher` reacted to the `source`. -Now also the `stream` reacts to the `source` changes by subscribing again to the stream. -In addition, the `stream` parameter of the Resource has been changed from `Stream` into a `Stream Function()` in order to be able to listen to a new stream if it changed + Now also the `stream` reacts to the `source` changes by subscribing again to the stream. + In addition, the `stream` parameter of the Resource has been changed from `Stream` into a `Stream Function()` in order to be able to listen to a new stream if it changed ## 1.0.0-dev6 diff --git a/packages/solidart/CHANGELOG.md b/packages/solidart/CHANGELOG.md index 2057779b..67eb2b0c 100644 --- a/packages/solidart/CHANGELOG.md +++ b/packages/solidart/CHANGELOG.md @@ -1,15 +1,5 @@ -## 3.0.0-dev.2 +## 3.0.0-dev.\* (Unreleased) -- **FEAT**: Add `untracked` and `batch` helpers for non-tracking reads and batched updates. -- **FEAT**: Add previous value tracking (`previousValue`, `untrackedPreviousValue`) and `trackPreviousValue` support. -- **FEAT**: Introduce `ReactiveList`, `ReactiveMap`, and `ReactiveSet` copy-on-write collections. -- **FEAT**: Expand `Resource` with fetcher/stream constructors, debounced source refreshes, and `previousState` access. -- **TEST**: Add v3 coverage for previous value, resources, untracked, and batch behavior. - -## 3.0.0-dev.1 - -- **BREAKING**: Remove all v2 implementation code (`src/core`, `src/extensions`, v2 utils). -- **BREAKING**: `package:solidart/solidart.dart` is the sole public entry and re-exports the v3 APIs. - **REFACTOR**: Rename `src/v3.dart` to `src/solidart.dart`. - **BREAKING**: Drop v2-only helpers like `until`, `Debouncer`, and v2 exceptions. @@ -86,8 +76,8 @@ - **REFACTOR**: Update `alien_signals` dependency from `^0.2.1` to `^0.4.3` with significant performance improvements (thanks to @medz). - **REFACTOR**: Replace custom reactive node implementations with `alien.ReactiveNode` for better compatibility and performance (thanks to @medz). - **REFACTOR**: Simplify signal, computed and effect implementations by leveraging new `alien_signals` API (thanks to @medz). -- **PERFORMANCE**: Improve performance by removing redundant tracking operations in the reactive system (thanks to @medz). -- **FIX**: Add proper cleanup for disposed nodes to prevent memory leaks (thanks to @medz). +- **PERFORMANCE**: Improve performance by removing redundant tracking operations in the reactive system (thanks to @medz). +- **FIX**: Add proper cleanup for disposed nodes to prevent memory leaks (thanks to @medz). - **FIX**: Fix potential memory leaks in auto-dispose scenarios (thanks to @medz). - **FIX**: Clear queued flag when running effects in `ReactiveSystem` to ensure proper effect execution (thanks to @medz). - **CHORE**: Reorder dev_dependencies in pubspec.yaml for improved organization and readability (thanks to @medz). @@ -181,7 +171,7 @@ ## 1.2.0 - **FEAT**: Add 3 new signals: `ListSignal`, `SetSignal` and `MapSignal`. Now you can easily be notified of every change of a list, set or map. - _Before_: + _Before_: ```dart final list = Signal([1, 2]); @@ -292,11 +282,11 @@ The core of the library has been rewritten in order to support automatic depende - **CHORE**: Move `refreshing` from `ResourceWidgetBuilder` into the `ResourceState`. (thanks to @manuel-plavsic) - **FEAT**: Add `hasPreviousValue` getter to `ReadSignal`. (thanks to @manuel-plavsic) - **FEAT** Before, only the `fetcher` reacted to the `source`. -Now also the `stream` reacts to the `source` changes by subscribing again to the stream. -In addition, the `stream` parameter of the Resource has been changed from `Stream` into a `Stream Function()` in order to be able to listen to a new stream if it changed. + Now also the `stream` reacts to the `source` changes by subscribing again to the stream. + In addition, the `stream` parameter of the Resource has been changed from `Stream` into a `Stream Function()` in order to be able to listen to a new stream if it changed. - **FEAT**: Add the `select` method on the `Resource` class. -The `select` function allows filtering the `Resource`'s data by reading only the properties that you care about. -The advantage is that you keep handling the loading and error states. + The `select` function allows filtering the `Resource`'s data by reading only the properties that you care about. + The advantage is that you keep handling the loading and error states. - **FEAT**: Make the `Resource` to auto-resolve when accessing its `state`. - **CHORE**: The `refetch` method of a `Resource` has been renamed to `refresh`. - **FEAT**: You can decide whether to use `createSignal()` or directly the `Signal()` constructor, now the're equivalent. The same applies to all the other `create` functions. @@ -304,8 +294,8 @@ The advantage is that you keep handling the loading and error states. ## 1.0.0-dev8 - **FEAT**: Add the select method on the Resource class. -The select function allows filtering the Resource's data by reading only the properties that you care about. -The advantage is that you keep handling the loading and error states. + The select function allows filtering the Resource's data by reading only the properties that you care about. + The advantage is that you keep handling the loading and error states. - **FEAT**: Make the Resource to auto-resolve when accessing its state ## 1.0.0-dev7 @@ -315,8 +305,8 @@ The advantage is that you keep handling the loading and error states. ## 1.0.0-dev6 - **FEAT** Before, only the `fetcher` reacted to the `source`. -Now also the `stream` reacts to the `source` changes by subscribing again to the stream. -In addition, the `stream` parameter of the Resource has been changed from `Stream` into a `Stream Function()` in order to be able to listen to a new stream if it changed + Now also the `stream` reacts to the `source` changes by subscribing again to the stream. + In addition, the `stream` parameter of the Resource has been changed from `Stream` into a `Stream Function()` in order to be able to listen to a new stream if it changed ## 1.0.0-dev5 From 6a13f237e18584ae19a87858d731ea710b561ca1 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:34:26 +0800 Subject: [PATCH 102/121] Revert docs-v2 to main --- docs-v2/astro.config.mjs | 9 -- .../docs/advanced/automatic_disposal.mdx | 128 ++++++++++++------ .../src/content/docs/advanced/list_signal.mdx | 34 +---- .../src/content/docs/advanced/map_signal.mdx | 26 ++-- .../src/content/docs/advanced/set_signal.mdx | 15 +- docs-v2/src/content/docs/flutter/show.mdx | 8 +- .../content/docs/flutter/signal_builder.mdx | 24 ++-- .../content/docs/flutter/solidart_hooks.mdx | 2 +- .../src/content/docs/learning/computed.mdx | 16 +-- docs-v2/src/content/docs/learning/effects.mdx | 35 ++--- .../src/content/docs/learning/resource.mdx | 14 -- docs-v2/src/content/docs/learning/signal.mdx | 75 +++++----- .../src/content/docs/learning/untracked.mdx | 4 +- 13 files changed, 178 insertions(+), 212 deletions(-) diff --git a/docs-v2/astro.config.mjs b/docs-v2/astro.config.mjs index 3bf098df..4cf5fee2 100644 --- a/docs-v2/astro.config.mjs +++ b/docs-v2/astro.config.mjs @@ -62,15 +62,6 @@ export default defineConfig({ label: 'Advanced', autogenerate: { directory: 'advanced' }, }, - { - label: 'Migration', - items: [ - { - label: 'v2 -> v3', - link: '/migration', - }, - ], - }, { label: 'Examples', link: '/examples', diff --git a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx index c6ca3724..a55092f5 100644 --- a/docs-v2/src/content/docs/advanced/automatic_disposal.mdx +++ b/docs-v2/src/content/docs/advanced/automatic_disposal.mdx @@ -6,64 +6,108 @@ sidebar: --- import { Aside } from '@astrojs/starlight/components'; - + -In v3, `SolidartConfig.autoDispose` defaults to `false`. That means signals, -computeds, resources, and effects are not automatically disposed unless you -opt in. +By default when you create a `Signal`, `Computed`, `Resource` or `Effect` the library will dispose them automatically. -Enable auto-dispose globally: +You can customize this behaviour for a specific trackable Object using the options, for example for a `Signal` you have to create it with `autoDispose` set to `false`. ```dart -SolidartConfig.autoDispose = true; +final counter = Signal(0, autoDispose: false); ``` -Or per instance: +If you want to disable it globally instead, use ```dart -final counter = Signal(0, autoDispose: true); +SolidartConfig.autoDispose = false; ``` -When auto-dispose is enabled: -- `Signal`, `Computed`, and `Resource` auto-dispose when they have no subscribers -- `Effect` auto-disposes only after ALL its tracked dependencies are disposed or disconnected +The automatic disposal happens automatically when there are no longer subscribers and listeners (for `Signal`, `Computed`, `Resource`) and when the __currently__ tracked dependencies are all disposed (for `Effect`). -**Best practice:** Even with auto-dispose enabled, manually dispose Effects in widgets -for explicit lifecycle control and easier debugging. +There is a single case that the automatic disposal won't cover: -## Disposing effects you create manually +```dart +final count = Signal(0); + +@override +void initState() { + super.initState(); + Effect(() { + print("The count is ${count.value}"); + }, + ); +} + +@override +void dispose() { + // nothing disposed manually here + super.dispose(); +} +``` + +In the example above the `count` signal will not be disposed because the `Effect` is a subscriber, and the `Effect` won't be disposed because the `count` that watches is not disposed. So they're going to be alive forever. +In order to fix this we need to dispose the `Effect` manually: + +```dart +final count = Signal(0); +late final DisposeEffect disposeEffect; + +@override +void initState() { + super.initState(); + disposeEffect = Effect(() { + print("The count is ${count.value}"); + }, + ); +} + +@override +void dispose() { + disposeEffect(); + super.dispose(); +} +``` + +In this case the `count` signal would be disposed because the subscriber is disposed and no longer watches it. +This would work also if you disposed only the `count` instead of the `Effect`. + +But I suggest to always dispose the `Effect` because it's always one, but if it is tracking multiple signals, all of them need to be disposed in order for the effect to dispose, for example: + +```dart +final count = Signal(0); +final name = Signal('Alex'); + +@override +void initState() { + super.initState(); + Effect(() { + print("The count is ${count.value} and the name is ${name.value}"); + }, + ); +} + +@override +void dispose() { + count.dispose(); + name.dispose(); + super.dispose(); +} +``` + +As you can see both the `count` and `name` signals needs to be disposed in order for the `Effect` to dispose. +This is the reason why I suggest to always dispose the `Effect`. -Effects can be created in widgets or in business-logic classes (for example, -`Notifier` classes with disco). Dispose them where they are owned. + -Effects created inside widgets should be disposed in `dispose()`: +In any case, don't worry to call `dispose()` yourself. It won't produce any error if it's already disposed. It just skips the operation. +In fact in the source code the operation is skipped if the object is already disposed: ```dart -class _ExampleState extends State { - late final Effect effect; - final count = Signal(0); - - @override - void initState() { - super.initState(); - effect = Effect(() { - print('count: ${count.value}'); - }); - } - - @override - void dispose() { - effect.dispose(); - count.dispose(); - super.dispose(); - } +@override +void dispose() { + // ignore if already disposed + if (_disposed) return; + ... } ``` -Calling `dispose()` is always safe; it no-ops if the object is already disposed. diff --git a/docs-v2/src/content/docs/advanced/list_signal.mdx b/docs-v2/src/content/docs/advanced/list_signal.mdx index 7054dda8..972fdb5a 100644 --- a/docs-v2/src/content/docs/advanced/list_signal.mdx +++ b/docs-v2/src/content/docs/advanced/list_signal.mdx @@ -2,42 +2,20 @@ title: List Signal description: Learn ListSignal in solidart sidebar: - order: 2 + order: 1 --- -import { Aside } from '@astrojs/starlight/components'; -A `ListSignal` is a reactive wrapper around a `List` that copies on write. +A `ListSignal` is a special signal that can be used to represent a list of values. When you modify any element on the list, the `ListSignal` will automatically notify its observers. ```dart final items = ListSignal([1, 2]); -Effect(() { - print('Items: ${items.value}'); +items.observe((previousValue, value) { + print("Items changed: $previousValue -> $value"); }); -// Remember to call effect.dispose() when you're done. -// See: /advanced/automatic-disposal - -items.add(3); // prints "Items: [1, 2, 3]" -items[0] = 10; // prints "Items: [10, 2, 3]" -``` - - diff --git a/docs-v2/src/content/docs/advanced/map_signal.mdx b/docs-v2/src/content/docs/advanced/map_signal.mdx index 54da919a..7fbcfa6b 100644 --- a/docs-v2/src/content/docs/advanced/map_signal.mdx +++ b/docs-v2/src/content/docs/advanced/map_signal.mdx @@ -1,31 +1,21 @@ --- -title: MapSignal +title: Map Signal description: Learn MapSignal in solidart sidebar: - order: 3 + order: 1 --- -A `MapSignal` is a reactive wrapper around a `Map` that uses copy-on-write. -Copy-on-write means each modification produces a new map instance, so observers -always see a consistent, immutable-like snapshot of the data. +A `MapSignal` is a special signal that can be used to represent a map of values. -When you modify any element on the map, the `MapSignal` will automatically -notify its observers. +When you modify any element on the map, the `MapSignal` will automatically notify its observers. ```dart -import 'package:solidart/solidart.dart'; - final items = MapSignal({'a': 1, 'b': 2}); -Effect(() { - print('Items: ${items.value}'); +items.observe((previousValue, value) { + print("Items changed: $previousValue -> $value"); }); -// Remember to call effect.dispose() when you're done. -// See: /advanced/automatic-disposal - -items['c'] = 3; // prints "Items: {a: 1, b: 2, c: 3}" -items.remove('a'); // prints "Items: {b: 2, c: 3}" +items.add({'c': 3}); // prints "Items changed: {'a': 1, 'b': 2} -> {'a': 1, 'b': 2, 'c': 3}" +items.remove('a'); // prints "Items changed: {'a': 1, 'b': 2, 'c': 3} -> {'b': 2, 'c': 3}" ``` - -For disposal guidelines, see the automatic disposal guide. diff --git a/docs-v2/src/content/docs/advanced/set_signal.mdx b/docs-v2/src/content/docs/advanced/set_signal.mdx index 4448103d..094dddf4 100644 --- a/docs-v2/src/content/docs/advanced/set_signal.mdx +++ b/docs-v2/src/content/docs/advanced/set_signal.mdx @@ -2,23 +2,20 @@ title: Set Signal description: Learn SetSignal in solidart sidebar: - order: 4 + order: 1 --- -A `SetSignal` is a reactive wrapper around a `Set` that copies on write. +A `SetSignal` is a special signal that can be used to represent a set of values. When you modify any element on the set, the `SetSignal` will automatically notify its observers. ```dart final items = SetSignal({1, 2}); -Effect(() { - print('Items: ${items.value}'); +items.observe((previousValue, value) { + print("Items changed: $previousValue -> $value"); }); -// Remember to call effect.dispose() when you're done. -// See: /advanced/automatic-disposal - -items.add(3); // prints "Items: {1, 2, 3}" -items.remove(1); // prints "Items: {2, 3}" +items.add(3); // prints "Items changed: [1, 2] -> [1, 2, 3]" +items.remove(1); // prints "Items changed: [1, 2, 3] -> [2, 3]" ``` diff --git a/docs-v2/src/content/docs/flutter/show.mdx b/docs-v2/src/content/docs/flutter/show.mdx index c7f4c6db..90809940 100644 --- a/docs-v2/src/content/docs/flutter/show.mdx +++ b/docs-v2/src/content/docs/flutter/show.mdx @@ -25,7 +25,7 @@ class _SampleState extends State { Widget build(BuildContext context) { return SignalBuilder( builder: (context, child) { - if (loggedIn.value) return const Text('Logged in'); + if (loggedIn()) return const Text('Logged in'); return const Text('Logged out'); }, ); @@ -59,9 +59,7 @@ class _SampleState extends State { The `Show` widget conditionally renders its `builder` or the `fallback` widget based on the `when` evaluation. The `fallback` widget builder is optional, by default nothing is rendered. -The `Show` widget takes a function that returns a `bool`. -Signals are callable, so you can pass them directly (for example, `when: loggedIn`) -or use `.value` if you prefer. +The `Show` widget takes a functions that returns a `bool`. You can easily convert any type to `bool`, for example: ```dart {9} {13-17} @@ -78,7 +76,7 @@ class _SampleState extends State { @override Widget build(BuildContext context) { return Show( - when: () => count.value > 5, + when: () => count() > 5, builder: (context) => const Text('Count is greater than 5'), fallback: (context) => const Text('Count is lower than 6'), ); diff --git a/docs-v2/src/content/docs/flutter/signal_builder.mdx b/docs-v2/src/content/docs/flutter/signal_builder.mdx index b12efa19..19b68248 100644 --- a/docs-v2/src/content/docs/flutter/signal_builder.mdx +++ b/docs-v2/src/content/docs/flutter/signal_builder.mdx @@ -4,16 +4,17 @@ description: Learn SignalBuilder in flutter_solidart sidebar: order: 1 --- + import { Aside } from '@astrojs/starlight/components'; -A magic widget builder that automatically rebuilds every time signals used inside its builder change. +A magic widget builder that automatically rebuilds everytime a signal used inside its builder changes. -Reacts to any number of signals, calling the `builder` each time. +Reacts to any number of *signals* calling the `builder` each time. The `builder` argument must not be null. The `child` is optional but is good practice to use if part of the widget -subtree does not depend on the value of the signals. +subtree does not depend on the value of the *signals*. ```dart {9} {13-17} class SampleCounter extends StatefulWidget { @@ -37,18 +38,9 @@ class _SampleCounterState extends State { } ``` -`SignalBuilder` tracks dependencies automatically. - +By default, if no __active__ signals are detected inside the builder method, an assertion error will be thrown in debug mode. +This is to help catch mistakes where you might have forgotten to use an __active__ signal inside the builder. -If you disable the assertion, a `SignalBuilder` without dependencies builds -once and does not subscribe to updates. +There may be cases where you want to disable this behavior, for example if you want it to build with the latest value of a disposed signal, in that case you can use `SolidartConfig.assertSignalBuilderWithoutDependencies = false` before `runApp`. + diff --git a/docs-v2/src/content/docs/flutter/solidart_hooks.mdx b/docs-v2/src/content/docs/flutter/solidart_hooks.mdx index b9e39195..6be97201 100644 --- a/docs-v2/src/content/docs/flutter/solidart_hooks.mdx +++ b/docs-v2/src/content/docs/flutter/solidart_hooks.mdx @@ -281,7 +281,7 @@ class Example extends HookWidget { ), ], ), - ), + ) floatingActionButton: FloatingActionButton( onPressed: () => userRoles['user${userRoles.value.length}'] = 'User${userRoles.value.length}', child: const Icon(Icons.add), diff --git a/docs-v2/src/content/docs/learning/computed.mdx b/docs-v2/src/content/docs/learning/computed.mdx index 21fd182a..0140895a 100644 --- a/docs-v2/src/content/docs/learning/computed.mdx +++ b/docs-v2/src/content/docs/learning/computed.mdx @@ -56,30 +56,30 @@ class User { } // create a user signal -final user = Signal(const User(name: 'name', age: 20)); +final user = Signal(const User(name: "name", age: 20)); // create a derived signal just for the age -final age = Computed(() => user.value.age); +final age = Computed(() => user().age); // adding an effect to print the age Effect(() { - print('age changed to ${age.value}'); + print('age changed from ${age.previousValue} into ${age.value}'); }); // just update the name, the effect above doesn't run because the age has not changed -user.value = user.untrackedValue.copyWith(name: 'new-name'); +user.updateValue((value) => value.copyWith(name: 'new-name')); // just update the age, the effect above prints -user.value = user.untrackedValue.copyWith(age: 21); +user.updateValue((value) => value.copyWith(age: 21)); ``` -A computed is not a `Signal` but a `ReadonlySignal`. -The difference with a normal `Signal` is that a `ReadonlySignal` doesn't have a value setter. +A derived signal is not of type `Signal` but is a `ReadSignal`. +The difference with a normal `Signal` is that a `ReadSignal` doesn't have a value setter, in other words it's a __read-only__ signal. With a `Computed` you can also transform the value type: ```dart final counter = Signal(0); // type: int -final isGreaterThan5 = Computed(() => counter.value > 5); // type: bool +final isGreaterThan5 = Computed(() => counter() > 5); // type: bool ``` `isGreaterThan5` will update only when the `counter` value becomes lower/greater than `5`. diff --git a/docs-v2/src/content/docs/learning/effects.mdx b/docs-v2/src/content/docs/learning/effects.mdx index 5a0074ca..90ea2182 100644 --- a/docs-v2/src/content/docs/learning/effects.mdx +++ b/docs-v2/src/content/docs/learning/effects.mdx @@ -13,47 +13,32 @@ The effect automatically subscribes to any signal and reruns when any of them ch So let's create an Effect that reruns whenever `counter` changes: ```dart -final counter = Signal(0); -final effect = Effect(() { - print('The count is now ${counter.value}'); +final disposeEffect = Effect(() { + print("The count is now ${counter.value}"); }); ``` -The effect runs immediately and prints `The count is now 0`. +The effect run immediately and prints `The count is now 0`; + Try incrementing the counter by 1: ```dart counter.value++; ``` -The effect prints `The count is now 1`. +The effect prints `The count is now 1`; -To stop the effect, call `dispose()`: +The `Effect` class returns a `Dispose` callback, invoke it to stop listening and clearing the effect. ```dart -final effect = Effect(() { - print('The count is now ${counter.value}'); +final disposeEffect = Effect(() { + print("The count is now ${counter.value}"); }); // Somewhere else, dispose the effect -effect.dispose(); +disposeEffect(); ``` - + - -## Lazy effects - -If you want to create an effect without running it immediately, use `Effect.manual` and call `run()` later. - -```dart -final counter = Signal(0); -final effect = Effect.manual(() { - print('Count: ${counter.value}'); -}); - -counter.value = 1; // no output yet - -effect.run(); // prints "Count: 1" and starts tracking -``` diff --git a/docs-v2/src/content/docs/learning/resource.mdx b/docs-v2/src/content/docs/learning/resource.mdx index b342266e..7e040582 100644 --- a/docs-v2/src/content/docs/learning/resource.mdx +++ b/docs-v2/src/content/docs/learning/resource.mdx @@ -38,10 +38,6 @@ SignalBuilder( ), ``` -`Resource.state` is the primary value. You can also access `previousState` and -`untrackedPreviousState` to read historical values. `previousState` updates -only after a tracked read of `state`. - A Resource can also be driven by a `Stream`. ```dart @@ -55,16 +51,6 @@ final resource = Resource.stream(() => stream); // the widget usage is the same as above, no changes needed ``` -## Lazy resources and resolve - -Resources are lazy by default. They start loading the first time you read -`state`, or when you call `resolve()` explicitly: - -```dart -final user = Resource(fetchUser, lazy: true); -await user.resolve(); // starts the fetch immediately -``` - You may ask yourself: why not just use a `Future` or a `Stream` directly? ## FutureBuilder is overcomplicated diff --git a/docs-v2/src/content/docs/learning/signal.mdx b/docs-v2/src/content/docs/learning/signal.mdx index 854da498..17b7e350 100644 --- a/docs-v2/src/content/docs/learning/signal.mdx +++ b/docs-v2/src/content/docs/learning/signal.mdx @@ -51,24 +51,39 @@ To change the value, you can use: // Set the value to 2 counter.value = 2; // Update the value based on the current value -counter.value *= 2; +counter.updateValue((value) => value * 2); ``` ## Make a read-only signal -If you want to create a signal that can only be read but not modified directly, you can use the `toReadonly` method. +If you want to create a signal that can only be read but not modified directly, you can use the `toReadSignal` method. ```dart final counter = Signal(0); -final readOnlyCounter = counter.toReadonly(); +final readOnlyCounter = counter.toReadSignal(); print(readOnlyCounter.value); // prints 0 // The code below is invalid and will cause a compile-time error because no setter is present readOnlyCounter.value = 2; ``` -## React to changes +## Observe the signal -Use an `Effect` to react to changes in one or more signals: +To react to changes in a Signal you can use the `observe` method: +```dart {2-4} +final counter = Signal(0); +counter.observe((previousValue, value) { + print("Counter changed from $previousValue to $value"); +}); +``` + +By default the observer is called for the next value change, but you can also call it immediately with the current value by passing `fireImmediately: true`: +```dart {3} +counter.observe((previousValue, value) { + print("Counter changed from $previousValue to $value"); +}, fireImmediately: true); +``` + +Or use an `Effect` if you need to react to multiple signals: ```dart {3-5} final counter = Signal(0); final doubleCounter = Signal(0); @@ -77,56 +92,46 @@ Effect(() { }); ``` -The effect runs immediately once, and then again when any tracked signal changes. +The effect will run immediately. ## Access the previous value -`previousValue` is updated only after a tracked read. You can also access the -untracked version via `untrackedPreviousValue`. - +A `Signal` contains the previous value in addition to the current value. You can access it using the `previousValue` property: ```dart final counter = Signal(0); -print(counter.previousValue); // null (not read yet) - -final _ = counter.value; // tracked read +print(counter.hasPreviousValue); // prints false (no previous value is present) +print(counter.previousValue); // prints null (no previous value is present) counter.value = 1; - -print(counter.previousValue); // 0 -print(counter.untrackedPreviousValue); // 0 +print(counter.hasPreviousValue); // prints true (previous value is present) +print(counter.previousValue); // prints 0 ``` -By default previous values are tracked. You can disable it per-signal: +As you can see the `previousValue` is `null` until the first time the value is changed. +You can check if a previous value is present using the `hasPreviousValue` property. + +By default the previous value is tracked. +If you want to disable it, you can pass `trackPreviousValue: false` to the `Signal` constructor: ```dart final counter = Signal(0, trackPreviousValue: false); ``` -or globally using `SolidartConfig`: +or globally using the `SolidartConfig`: ```dart SolidartConfig.trackPreviousValue = false; // do it before runApp() ``` -## Wait for a condition (replacement for until) - -`until` was removed in v3. Use a `Completer` plus an `Effect` instead: +## Await until a condition is met +You can use the `until` method to wait for a signal to meet a specific condition: ```dart -import 'dart:async'; - -final completer = Completer(); -late final Effect effect; - -effect = Effect(() { - final value = counter.value; - if (value >= 5 && !completer.isCompleted) { - completer.complete(value); - effect.dispose(); - } -}); - -await completer.future; +final counter = Signal(0); +await counter.until((value) => value >= 5, timeout: Duration(seconds: 10)); ``` +By default no timeout is set, so the `until` method will wait indefinitely until the condition is met. +This is useful when you want to wait for a signal to reach a certain value before proceeding with your code. + diff --git a/docs-v2/src/content/docs/learning/untracked.mdx b/docs-v2/src/content/docs/learning/untracked.mdx index 97684736..d62a88cc 100644 --- a/docs-v2/src/content/docs/learning/untracked.mdx +++ b/docs-v2/src/content/docs/learning/untracked.mdx @@ -8,7 +8,7 @@ sidebar: Execute a callback that will not be tracked by the reactive system. -This can be useful inside Effects to prevent a signal from being tracked. +This can be useful inside Effects or Observations to prevent a signal from being tracked. ## Example @@ -17,7 +17,7 @@ final count = Signal(0); final doubleCount = Signal(0); Effect(() { - final value = count.value; + final value = count(); untracked(() { doubleCount.value = value * 2; }); From 4c9c9fcab7f481deb3c7e4b7af2486d80b4cd2cb Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:58:40 +0800 Subject: [PATCH 103/121] Update CHANGELOG for v3.0.0-dev.0 release --- packages/solidart/CHANGELOG.md | 20 ++++++++++++++++---- packages/solidart/pubspec.yaml | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/solidart/CHANGELOG.md b/packages/solidart/CHANGELOG.md index 67eb2b0c..f1963b0f 100644 --- a/packages/solidart/CHANGELOG.md +++ b/packages/solidart/CHANGELOG.md @@ -1,7 +1,19 @@ -## 3.0.0-dev.\* (Unreleased) - -- **REFACTOR**: Rename `src/v3.dart` to `src/solidart.dart`. -- **BREAKING**: Drop v2-only helpers like `until`, `Debouncer`, and v2 exceptions. +## 3.0.0-dev.0 (Unreleased) + +- **BREAKING**: Replace the v2 public surface with the v3 API exported from `solidart.dart` and `advanced.dart`. +- **BREAKING**: `SolidartConfig.equals` is removed; update skipping now uses the per-instance `equals` comparator (defaults to `identical`). +- **BREAKING**: Default auto-disposal is now opt-in; `SolidartConfig.autoDispose` defaults to `false`. +- **BREAKING**: `Signal.toReadSignal()` renamed to `toReadonly()`, and `ResourceExtensions` renamed to `ResourceStateExtensions` (`on`/`maybeOn` removed). +- **BREAKING**: Effect API simplified by removing `autorun`, `delay`, and `onError`; use `Effect.manual()` and `run()` to control startup, and call `dispose()` (no callable effect). +- **BREAKING**: Named identifiers now live on `identifier`/`identifier.name`, and `SolidartObserver` receives `ReadonlySignal` instances. +- **REMOVED**: `SignalBase`, `ReadSignal`/`ReadableSignal`, `Signal.setValue`/`updateValue`, `Signal.hasValue`/`hasPreviousValue`, `Signal.listenerCount`, and `ToggleBoolSignal`. +- **REMOVED**: `until` extensions (`Signal.until`, `Computed.until`, `Resource.until`, `Resource.untilReady`) and `FutureOrThenExtension`. +- **REMOVED**: `Resource.update`, `Debouncer`/`DebounceOperation`, and Solidart exception types (`SolidartException`, `SolidartReactionException`, `SolidartCaughtException`). +- **ADDED**: `ReadonlySignal` and `ObserveSignal.observe` for any `ReadonlySignal` (signals, computeds, resources). +- **ADDED**: `LazySignal` type with `Signal.lazy` returning it and `isInitialized` support. +- **ADDED**: `Disposable`/`DisposableMixin`, `Identifier`, `Configuration`, `Option`/`Some`/`None`, and public `Resource.resolve()`. +- **REFACTOR**: Collection signals are reimplemented on the v3 core with copy-on-write updates and standard `ListMixin`/`SetMixin`/`MapMixin` APIs. +- **CHORE**: Upgrade `alien_signals` to `^2.1.1` and add `fake_async` for tests. ## 2.8.3 diff --git a/packages/solidart/pubspec.yaml b/packages/solidart/pubspec.yaml index 6fe538e3..66efac32 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.2 +version: 3.0.0-dev.0 repository: https://github.com/nank1ro/solidart documentation: https://solidart.mariuti.com topics: From ac936abaf57d1d8ebe3c25e365fb74488a683085 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 30 Jan 2026 23:01:33 +0800 Subject: [PATCH 104/121] Update CHANGELOG for v3.0.0-dev.0 breaking changes --- packages/solidart/CHANGELOG.md | 36 ++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/packages/solidart/CHANGELOG.md b/packages/solidart/CHANGELOG.md index f1963b0f..d1366e8e 100644 --- a/packages/solidart/CHANGELOG.md +++ b/packages/solidart/CHANGELOG.md @@ -1,17 +1,37 @@ ## 3.0.0-dev.0 (Unreleased) +### Signals + +- **BREAKING**: Signals now use the v3 surface from `solidart.dart`; read-only usage is through `ReadonlySignal`. +- **BREAKING**: `Signal.toReadSignal()` renamed to `toReadonly()`. +- **REMOVED**: `SignalBase`, `ReadSignal`/`ReadableSignal`, `Signal.setValue`/`updateValue`, `Signal.hasValue`/`hasPreviousValue`, `Signal.listenerCount`, and `ToggleBoolSignal`. +- **REMOVED**: `Signal.until` (and related `FutureOrThenExtension`). +- **ADDED**: `ReadonlySignal` and `ObserveSignal.observe` for any `ReadonlySignal` (signals, computeds, resources). +- **ADDED**: `LazySignal` type with `Signal.lazy` returning it and `isInitialized` support. + +### Computed + +- **BREAKING**: Computeds are part of the v3 surface and use the `ReadonlySignal` API. +- **REMOVED**: `Computed.until` (and related `FutureOrThenExtension`). + +### Effect + +- **BREAKING**: Effect API simplified by removing `autorun`, `delay`, and `onError`; use `Effect.manual()` and `run()` to control startup, and call `dispose()` (no callable effect). + +### Resource + +- **BREAKING**: `ResourceExtensions` renamed to `ResourceStateExtensions` (`on`/`maybeOn` removed). +- **REMOVED**: `Resource.update`, `Resource.until`, and `Resource.untilReady`. +- **ADDED**: public `Resource.resolve()`. + +### Core / Shared + - **BREAKING**: Replace the v2 public surface with the v3 API exported from `solidart.dart` and `advanced.dart`. - **BREAKING**: `SolidartConfig.equals` is removed; update skipping now uses the per-instance `equals` comparator (defaults to `identical`). - **BREAKING**: Default auto-disposal is now opt-in; `SolidartConfig.autoDispose` defaults to `false`. -- **BREAKING**: `Signal.toReadSignal()` renamed to `toReadonly()`, and `ResourceExtensions` renamed to `ResourceStateExtensions` (`on`/`maybeOn` removed). -- **BREAKING**: Effect API simplified by removing `autorun`, `delay`, and `onError`; use `Effect.manual()` and `run()` to control startup, and call `dispose()` (no callable effect). - **BREAKING**: Named identifiers now live on `identifier`/`identifier.name`, and `SolidartObserver` receives `ReadonlySignal` instances. -- **REMOVED**: `SignalBase`, `ReadSignal`/`ReadableSignal`, `Signal.setValue`/`updateValue`, `Signal.hasValue`/`hasPreviousValue`, `Signal.listenerCount`, and `ToggleBoolSignal`. -- **REMOVED**: `until` extensions (`Signal.until`, `Computed.until`, `Resource.until`, `Resource.untilReady`) and `FutureOrThenExtension`. -- **REMOVED**: `Resource.update`, `Debouncer`/`DebounceOperation`, and Solidart exception types (`SolidartException`, `SolidartReactionException`, `SolidartCaughtException`). -- **ADDED**: `ReadonlySignal` and `ObserveSignal.observe` for any `ReadonlySignal` (signals, computeds, resources). -- **ADDED**: `LazySignal` type with `Signal.lazy` returning it and `isInitialized` support. -- **ADDED**: `Disposable`/`DisposableMixin`, `Identifier`, `Configuration`, `Option`/`Some`/`None`, and public `Resource.resolve()`. +- **REMOVED**: `Debouncer`/`DebounceOperation` and Solidart exception types (`SolidartException`, `SolidartReactionException`, `SolidartCaughtException`). +- **ADDED**: `Disposable`/`DisposableMixin`, `Identifier`, `Configuration`, `Option`/`Some`/`None`. - **REFACTOR**: Collection signals are reimplemented on the v3 core with copy-on-write updates and standard `ListMixin`/`SetMixin`/`MapMixin` APIs. - **CHORE**: Upgrade `alien_signals` to `^2.1.1` and add `fake_async` for tests. From b491916bf9d1fad7704f2dcc276d36fde56212fb Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 30 Jan 2026 23:27:33 +0800 Subject: [PATCH 105/121] Align flutter_solidart dev versions --- examples/auth_flow/pubspec.yaml | 2 +- examples/counter/pubspec.yaml | 2 +- examples/github_search/pubspec.yaml | 2 +- examples/infinite_scroll/pubspec.yaml | 2 +- examples/todos/pubspec.yaml | 2 +- examples/toggle_theme/pubspec.yaml | 2 +- packages/flutter_solidart/CHANGELOG.md | 34 +++++++++++++------ .../flutter_solidart/example/pubspec.yaml | 2 +- packages/flutter_solidart/pubspec.yaml | 4 +-- packages/solidart_hooks/example/pubspec.yaml | 6 ++-- packages/solidart_hooks/pubspec.yaml | 2 +- packages/solidart_lint/pubspec.yaml | 4 +-- 12 files changed, 39 insertions(+), 25 deletions(-) diff --git a/examples/auth_flow/pubspec.yaml b/examples/auth_flow/pubspec.yaml index 0f735097..b0b9f574 100644 --- a/examples/auth_flow/pubspec.yaml +++ b/examples/auth_flow/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: flutter: sdk: flutter disco: ^1.0.3+1 - flutter_solidart: 3.0.0-dev.2 + flutter_solidart: 3.0.0-dev.0 go_router: ^17.0.0 localstorage: ^6.0.0 diff --git a/examples/counter/pubspec.yaml b/examples/counter/pubspec.yaml index abf82037..54419794 100644 --- a/examples/counter/pubspec.yaml +++ b/examples/counter/pubspec.yaml @@ -33,7 +33,7 @@ resolution: workspace dependencies: flutter: sdk: flutter - flutter_solidart: 3.0.0-dev.2 + flutter_solidart: 3.0.0-dev.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/examples/github_search/pubspec.yaml b/examples/github_search/pubspec.yaml index 32ca2707..7b7b2a1e 100644 --- a/examples/github_search/pubspec.yaml +++ b/examples/github_search/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: disco: ^1.0.0 flutter: sdk: flutter - flutter_solidart: 3.0.0-dev.2 + flutter_solidart: 3.0.0-dev.0 json_annotation: ^4.8.1 equatable: ^2.0.5 http: ^1.3.0 diff --git a/examples/infinite_scroll/pubspec.yaml b/examples/infinite_scroll/pubspec.yaml index e617d040..bc877fb8 100644 --- a/examples/infinite_scroll/pubspec.yaml +++ b/examples/infinite_scroll/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: disco: ^1.0.3+1 flutter: sdk: flutter - flutter_solidart: 3.0.0-dev.2 + flutter_solidart: 3.0.0-dev.0 http: ^1.6.0 dev_dependencies: diff --git a/examples/todos/pubspec.yaml b/examples/todos/pubspec.yaml index 634dcc9c..9e5d91cf 100644 --- a/examples/todos/pubspec.yaml +++ b/examples/todos/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: disco: ^1.0.0 flutter: sdk: flutter - flutter_solidart: 3.0.0-dev.2 + flutter_solidart: 3.0.0-dev.0 uuid: ^4.5.1 dev_dependencies: diff --git a/examples/toggle_theme/pubspec.yaml b/examples/toggle_theme/pubspec.yaml index c94f79b4..fd0a58b6 100644 --- a/examples/toggle_theme/pubspec.yaml +++ b/examples/toggle_theme/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: disco: ^1.0.0 flutter: sdk: flutter - flutter_solidart: 3.0.0-dev.2 + flutter_solidart: 3.0.0-dev.0 dev_dependencies: flutter_test: diff --git a/packages/flutter_solidart/CHANGELOG.md b/packages/flutter_solidart/CHANGELOG.md index bf3dbac0..fda672c8 100644 --- a/packages/flutter_solidart/CHANGELOG.md +++ b/packages/flutter_solidart/CHANGELOG.md @@ -1,16 +1,30 @@ -## 3.0.0-dev.2 +## 3.0.0-dev.0 (Unreleased) -- **CHORE**: Bump `solidart` dependency to `3.0.0-dev.2`. -- **DOCS**: Refresh examples for `LazySignal` and v3 dependency pins. +### Signals -## 3.0.0-dev.1 +- **BREAKING**: Flutter `Signal`, `Computed`, `Resource`, and collection wrappers now implement `ValueListenable` only (the `ValueNotifier` mixins are removed). +- **REMOVED**: `ValueNotifierSignalMixin` and `ValueListenableSignalMixin` exports. +- **BREAKING**: `Signal.toReadSignal()` is replaced by `toReadonly()` and `ReadableSignal` is removed in favor of `ReadonlySignal`. +- **REMOVED**: `Signal.updateValue` and `ToggleBoolSignal`. +- **ADDED**: `LazySignal` wrapper returned by `Signal.lazy`. -- **BREAKING**: Remove Flutter-specific core wrappers under `src/core/*`; `flutter_solidart` now re-exports `solidart/solidart.dart` directly. -- **BREAKING**: Replace `ListSignal`/`MapSignal`/`SetSignal` with `ReactiveList`/`ReactiveMap`/`ReactiveSet`. -- **BREAKING**: Replace `toReadSignal()` with `toReadonly()` and remove legacy `SignalBase`/`ReadableSignal` surfaces. -- **FEAT**: Add v3 conversion extensions: `ReadonlySignal.toValueNotifier()` and `ValueListenable.toSignal()`. -- **REFACTOR**: Rewrite `SignalBuilder` and `Show` to use v3 effects + dependency tracking. -- **DOCS**: Update README, examples, and tests to v3 syntax (`.value`, `isInitialized`, `Effect.dispose()`). +### Resource + +- **BREAKING**: Resource state helpers align with v3 (`ResourceStateExtensions` with `when`/`maybeWhen`; `on`/`maybeOn` removed). +- **ADDED**: `Resource.resolve()` is now public (via core). + +### Widgets + +- **REFACTOR**: `SignalBuilder` reworked for v3 dependency tracking. +- **REFACTOR**: `Show` simplified to a `StatelessWidget` backed by `SignalBuilder`. + +### Interop + +- **BREAKING**: `SignalBase.toValueNotifier()` becomes `ReadonlySignal.toValueNotifier()`, and `ValueNotifier.toSignal()` becomes `ValueListenable.toSignal()`. + +### Dependencies + +- **CHORE**: Bump `solidart` to `3.0.0-dev.0`. ## 2.7.2 diff --git a/packages/flutter_solidart/example/pubspec.yaml b/packages/flutter_solidart/example/pubspec.yaml index 6642341a..463a7bf3 100644 --- a/packages/flutter_solidart/example/pubspec.yaml +++ b/packages/flutter_solidart/example/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: flutter: sdk: flutter http: ^1.3.0 - flutter_solidart: 3.0.0-dev.2 + flutter_solidart: 3.0.0-dev.0 dev_dependencies: flutter_test: diff --git a/packages/flutter_solidart/pubspec.yaml b/packages/flutter_solidart/pubspec.yaml index a34ca74f..5a83bc50 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.2 +version: 3.0.0-dev.0 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.2 + solidart: 3.0.0-dev.0 dev_dependencies: disco: ^1.0.0 diff --git a/packages/solidart_hooks/example/pubspec.yaml b/packages/solidart_hooks/example/pubspec.yaml index a18b8f2a..8066be72 100644 --- a/packages/solidart_hooks/example/pubspec.yaml +++ b/packages/solidart_hooks/example/pubspec.yaml @@ -5,16 +5,16 @@ version: 1.0.0+1 environment: sdk: ^3.10.0 - + resolution: workspace dependencies: flutter: sdk: flutter flutter_hooks: ^0.21.3+1 - solidart: + solidart: 3.0.0-dev.0 solidart_hooks: - + dev_dependencies: flutter_test: sdk: flutter diff --git a/packages/solidart_hooks/pubspec.yaml b/packages/solidart_hooks/pubspec.yaml index 81794e12..f4d0dfdf 100644 --- a/packages/solidart_hooks/pubspec.yaml +++ b/packages/solidart_hooks/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: flutter: sdk: flutter flutter_hooks: ^0.21.3+1 - flutter_solidart: 3.0.0-dev.2 + flutter_solidart: 3.0.0-dev.0 dev_dependencies: flutter_test: diff --git a/packages/solidart_lint/pubspec.yaml b/packages/solidart_lint/pubspec.yaml index f89cf3e5..64254d60 100644 --- a/packages/solidart_lint/pubspec.yaml +++ b/packages/solidart_lint/pubspec.yaml @@ -27,5 +27,5 @@ dependencies: dev_dependencies: lints: ^6.0.0 test: ^1.25.2 - solidart: 3.0.0-dev.2 - flutter_solidart: 3.0.0-dev.2 + solidart: 3.0.0-dev.0 + flutter_solidart: 3.0.0-dev.0 From 2bd526d042c428b74db5278b86582d8967b15323 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 30 Jan 2026 23:28:39 +0800 Subject: [PATCH 106/121] format --- .../test/flutter_solidart_test.dart | 42 +++++++++---------- .../solidart/test/observe_signal_test.dart | 27 ++++++++---- .../solidart_devtools_extension/lib/main.dart | 7 ++-- 3 files changed, 42 insertions(+), 34 deletions(-) diff --git a/packages/flutter_solidart/test/flutter_solidart_test.dart b/packages/flutter_solidart/test/flutter_solidart_test.dart index 4be463cb..47b5b933 100644 --- a/packages/flutter_solidart/test/flutter_solidart_test.dart +++ b/packages/flutter_solidart/test/flutter_solidart_test.dart @@ -36,27 +36,27 @@ void main() { 'Signals, Computed, Resources, and collections ' 'implement Listenable', () { - final signal = Signal(0); - final computed = Computed(() => signal.value * 2); - final resource = Resource(() async => 1); - final listSignal = ListSignal([1, 2, 3]); - final setSignal = SetSignal({1, 2, 3}); - final mapSignal = MapSignal({'a': 1}); - - expect(signal, isA()); - expect(computed, isA()); - expect(resource, isA()); - expect(listSignal, isA()); - expect(setSignal, isA()); - expect(mapSignal, isA()); - - signal.dispose(); - computed.dispose(); - resource.dispose(); - listSignal.dispose(); - setSignal.dispose(); - mapSignal.dispose(); - }, + final signal = Signal(0); + final computed = Computed(() => signal.value * 2); + final resource = Resource(() async => 1); + final listSignal = ListSignal([1, 2, 3]); + final setSignal = SetSignal({1, 2, 3}); + final mapSignal = MapSignal({'a': 1}); + + expect(signal, isA()); + expect(computed, isA()); + expect(resource, isA()); + expect(listSignal, isA()); + expect(setSignal, isA()); + expect(mapSignal, isA()); + + signal.dispose(); + computed.dispose(); + resource.dispose(); + listSignal.dispose(); + setSignal.dispose(); + mapSignal.dispose(); + }, ); testWidgets('(Provider) Not found signal throws an error', (tester) async { diff --git a/packages/solidart/test/observe_signal_test.dart b/packages/solidart/test/observe_signal_test.dart index 230585b8..9a29f00b 100644 --- a/packages/solidart/test/observe_signal_test.dart +++ b/packages/solidart/test/observe_signal_test.dart @@ -14,15 +14,21 @@ void main() { expect(calls, isEmpty); signal.value = 1; - expect(calls, equals(>[ - [0, 1], - ])); + expect( + calls, + equals(>[ + [0, 1], + ]), + ); dispose(); signal.value = 2; - expect(calls, equals(>[ - [0, 1], - ])); + expect( + calls, + equals(>[ + [0, 1], + ]), + ); signal.dispose(); }); @@ -38,9 +44,12 @@ void main() { fireImmediately: true, ); - expect(calls, equals(>[ - [null, 5], - ])); + expect( + calls, + equals(>[ + [null, 5], + ]), + ); dispose(); signal.dispose(); diff --git a/packages/solidart_devtools_extension/lib/main.dart b/packages/solidart_devtools_extension/lib/main.dart index 1fd4f282..c6c034b4 100644 --- a/packages/solidart_devtools_extension/lib/main.dart +++ b/packages/solidart_devtools_extension/lib/main.dart @@ -149,10 +149,9 @@ class _SignalsState extends State { case 'ext.solidart.signal.updated': case 'ext.solidart.signal.disposed': final id = data['_id']; - final signalId = - id == null - ? DateTime.now().microsecondsSinceEpoch.toString() - : id.toString(); + final signalId = id == null + ? DateTime.now().microsecondsSinceEpoch.toString() + : id.toString(); signals[signalId] = SignalData( name: data['name'] ?? id ?? signalId, value: jsonDecode(data['value'] ?? 'null'), From 4149fe579acf68d8af1c5be6db64cf31c03ab6ee Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 30 Jan 2026 23:38:49 +0800 Subject: [PATCH 107/121] Remove unused cupertino_icons dependency --- examples/auth_flow/pubspec.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/auth_flow/pubspec.yaml b/examples/auth_flow/pubspec.yaml index b0b9f574..460b48ec 100644 --- a/examples/auth_flow/pubspec.yaml +++ b/examples/auth_flow/pubspec.yaml @@ -9,7 +9,6 @@ environment: resolution: workspace dependencies: - cupertino_icons: ^1.0.2 flutter: sdk: flutter disco: ^1.0.3+1 @@ -24,4 +23,3 @@ dev_dependencies: flutter: uses-material-design: true - From d6744ed2790493fbd8a0a368c95b04835c024fc8 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 30 Jan 2026 23:39:59 +0800 Subject: [PATCH 108/121] Remove cupertino_icons dependency from example apps --- examples/github_search/pubspec.yaml | 1 - examples/infinite_scroll/pubspec.yaml | 1 - examples/todos/pubspec.yaml | 1 - examples/toggle_theme/pubspec.yaml | 1 - 4 files changed, 4 deletions(-) diff --git a/examples/github_search/pubspec.yaml b/examples/github_search/pubspec.yaml index 7b7b2a1e..a5a8b7a8 100644 --- a/examples/github_search/pubspec.yaml +++ b/examples/github_search/pubspec.yaml @@ -30,7 +30,6 @@ resolution: workspace # the latest version available on pub.dev. To see which dependencies have newer # versions available, run `flutter pub outdated`. dependencies: - cupertino_icons: ^1.0.2 disco: ^1.0.0 flutter: sdk: flutter diff --git a/examples/infinite_scroll/pubspec.yaml b/examples/infinite_scroll/pubspec.yaml index bc877fb8..1223d4f0 100644 --- a/examples/infinite_scroll/pubspec.yaml +++ b/examples/infinite_scroll/pubspec.yaml @@ -9,7 +9,6 @@ environment: resolution: workspace dependencies: - cupertino_icons: ^1.0.2 disco: ^1.0.3+1 flutter: sdk: flutter diff --git a/examples/todos/pubspec.yaml b/examples/todos/pubspec.yaml index 9e5d91cf..c65d2cd3 100644 --- a/examples/todos/pubspec.yaml +++ b/examples/todos/pubspec.yaml @@ -9,7 +9,6 @@ environment: resolution: workspace dependencies: - cupertino_icons: ^1.0.2 disco: ^1.0.0 flutter: sdk: flutter diff --git a/examples/toggle_theme/pubspec.yaml b/examples/toggle_theme/pubspec.yaml index fd0a58b6..f0938d23 100644 --- a/examples/toggle_theme/pubspec.yaml +++ b/examples/toggle_theme/pubspec.yaml @@ -31,7 +31,6 @@ resolution: workspace # the latest version available on pub.dev. To see which dependencies have newer # versions available, run `flutter pub outdated`. dependencies: - cupertino_icons: ^1.0.2 disco: ^1.0.0 flutter: sdk: flutter From 7960a107d1c4054a7d2910679206f76d6f0b262a Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 30 Jan 2026 23:44:56 +0800 Subject: [PATCH 109/121] Update lazy counter example to use Signal.lazy factory - Replace deprecated LazySignal constructor with Signal.lazy factory - Use pattern matching for cleaner lazy signal state checks - Simplify conditional logic in button handlers --- .../example/lib/pages/lazy_counter.dart | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/flutter_solidart/example/lib/pages/lazy_counter.dart b/packages/flutter_solidart/example/lib/pages/lazy_counter.dart index 994c8f54..a80f1326 100644 --- a/packages/flutter_solidart/example/lib/pages/lazy_counter.dart +++ b/packages/flutter_solidart/example/lib/pages/lazy_counter.dart @@ -9,7 +9,7 @@ class LazyCounterPage extends StatefulWidget { } class _LazyCounterPageState extends State { - final counter = LazySignal(name: 'lazyCounter'); + final counter = Signal.lazy(name: 'lazyCounter'); @override Widget build(BuildContext context) { @@ -17,11 +17,11 @@ class _LazyCounterPageState extends State { appBar: AppBar(title: const Text('Lazy Counter')), body: Center( child: SignalBuilder( - builder: (_, _) { - return switch (counter.isInitialized) { - true => Text('Counter: ${counter.value}'), - false => const Text('Counter: not initialized'), - }; + builder: (_, _) => switch (counter) { + LazySignal(isInitialized: true) => Text( + 'Counter: ${counter.value}', + ), + _ => const Text('Counter: not initialized'), }, ), ), @@ -32,7 +32,12 @@ class _LazyCounterPageState extends State { heroTag: "subtract hero", child: const Icon(Icons.remove), onPressed: () { - counter.isInitialized ? counter.value -= 1 : counter.value = 0; + if (counter case LazySignal(isInitialized: false)) { + counter.value = 0; + return; + } + + counter.value -= 1; }, ), const SizedBox(width: 8), @@ -40,7 +45,12 @@ class _LazyCounterPageState extends State { heroTag: "add hero", child: const Icon(Icons.add), onPressed: () { - counter.isInitialized ? counter.value += 1 : counter.value = 0; + if (counter case LazySignal(isInitialized: false)) { + counter.value = 0; + return; + } + + counter.value += 1; }, ), ], From 792d30d45d04dfde7199e229ef60d1b7ddeba263 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 30 Jan 2026 23:56:13 +0800 Subject: [PATCH 110/121] Add isInitialized property to Signal base class --- packages/solidart/lib/src/solidart.dart | 9 ++++++--- packages/solidart_hooks/CHANGELOG.md | 8 +++++++- packages/solidart_hooks/pubspec.yaml | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index 9f919059..be46363d 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -620,6 +620,11 @@ class Signal extends preset.SignalNode> _notifySignalCreation(this); } + /// Whether the signal has been initialized. + /// + /// Regular signals are always initialized at construction time. + bool get isInitialized => true; + /// {@macro solidart.signal} /// /// This is a lazy signal: it has no value at construction time. @@ -744,9 +749,7 @@ class LazySignal extends Signal { trackInDevTools: trackInDevTools, ); - /// Whether the signal has been initialized. - /// - /// This becomes `true` after the first assignment. + @override bool get isInitialized => currentValue is Some; @override diff --git a/packages/solidart_hooks/CHANGELOG.md b/packages/solidart_hooks/CHANGELOG.md index b940a7f3..251fd7d0 100644 --- a/packages/solidart_hooks/CHANGELOG.md +++ b/packages/solidart_hooks/CHANGELOG.md @@ -1,4 +1,4 @@ -## 3.1.3 +## 3.2.0-dev.0 (Unreleased) - **CHORE**: Bump `flutter_solidart` dependency to `3.0.0-dev.2`. @@ -23,14 +23,18 @@ ## 3.0.0 - **BREAKING CHANGE**: `SignalHook` no longer calls `setState` to trigger a rebuild when the signal changes. Instead, you should use `SignalBuilder` to listen to signal changes and rebuild the UI accordingly. This change improves performance and reduces unnecessary rebuilds. You can also use `useListenable` if you want to trigger a rebuild on signal changes. + ### Migration Guide **Before (v2.x):** + ```dart final count = useSignal(0); return Text('Count: ${count.value}'); // Auto-rebuilds ``` + **After (v3.x):** + ```dart final count = useSignal(0); return SignalBuilder( @@ -39,11 +43,13 @@ ``` Or use `useListenable` for full widget rebuild: + ```dart final count = useSignal(0); useListenable(count); return Text('Count: ${count.value}'); ``` + This is inline with the behaviour of `useValueNotifier` from `flutter_hooks`. ## 2.0.0 diff --git a/packages/solidart_hooks/pubspec.yaml b/packages/solidart_hooks/pubspec.yaml index f4d0dfdf..b19e67a6 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: 3.1.3 +version: 3.2.0-dev.0 repository: https://github.com/nank1ro/solidart documentation: https://solidart.mariuti.com topics: From a639ce5d3d40b4c0e0a9ed4b19d858986f452b52 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 30 Jan 2026 23:59:47 +0800 Subject: [PATCH 111/121] Simplify lazy counter pattern matching --- .../example/lib/pages/lazy_counter.dart | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/flutter_solidart/example/lib/pages/lazy_counter.dart b/packages/flutter_solidart/example/lib/pages/lazy_counter.dart index a80f1326..0fe2447f 100644 --- a/packages/flutter_solidart/example/lib/pages/lazy_counter.dart +++ b/packages/flutter_solidart/example/lib/pages/lazy_counter.dart @@ -17,10 +17,8 @@ class _LazyCounterPageState extends State { appBar: AppBar(title: const Text('Lazy Counter')), body: Center( child: SignalBuilder( - builder: (_, _) => switch (counter) { - LazySignal(isInitialized: true) => Text( - 'Counter: ${counter.value}', - ), + builder: (_, _) => switch (counter.isInitialized) { + true => Text('Counter: ${counter.value}'), _ => const Text('Counter: not initialized'), }, ), @@ -32,7 +30,7 @@ class _LazyCounterPageState extends State { heroTag: "subtract hero", child: const Icon(Icons.remove), onPressed: () { - if (counter case LazySignal(isInitialized: false)) { + if (!counter.isInitialized) { counter.value = 0; return; } @@ -45,7 +43,7 @@ class _LazyCounterPageState extends State { heroTag: "add hero", child: const Icon(Icons.add), onPressed: () { - if (counter case LazySignal(isInitialized: false)) { + if (!counter.isInitialized) { counter.value = 0; return; } From a2f94fcf0f80dfebdee176dad12088f0d28d85fd Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:03:10 +0800 Subject: [PATCH 112/121] sort member --- packages/solidart/lib/src/solidart.dart | 2528 ++++++++++++----------- 1 file changed, 1265 insertions(+), 1263 deletions(-) diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index be46363d..691f24f4 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -8,148 +8,111 @@ import 'package:meta/meta.dart'; import 'package:solidart/deps/preset.dart' as preset; import 'package:solidart/deps/system.dart' as system; -/// Compares two values for equality. +/// Batches signal updates and flushes once at the end. /// -/// Return `true` when the update should be skipped because values are -/// considered equivalent. -typedef ValueComparator = bool Function(T? a, T? b); - -/// Signature for callbacks fired when a signal changes. -typedef ObserveCallback = void Function(T? previousValue, T value); - -/// Disposer returned by [ObserveSignal.observe]. -typedef DisposeObservation = void Function(); - -/// Lazily produces a value. -typedef ValueGetter = T Function(); - -/// A callback that returns no value. -typedef VoidCallback = ValueGetter; - -/// An optional value container. +/// Nested batches are supported; the final flush happens when the outermost +/// batch completes. /// -/// Use [Some] to represent presence and [None] to represent absence without -/// relying on `null`. -sealed class Option { - /// Base constructor for option values. - const Option(); - - /// Returns the contained value or throws if this is [None]. - T unwrap() => switch (this) { - Some(:final value) => value, - _ => throw StateError('Option is None'), - }; - - /// Returns the contained value or `null` if this is [None]. - T? safeUnwrap() => switch (this) { - Some(:final value) => value, - _ => null, - }; +/// ```dart +/// final a = Signal(1); +/// final b = Signal(2); +/// Effect(() => print('sum: ${a.value + b.value}')); +/// +/// batch(() { +/// a.value = 3; +/// b.value = 4; +/// }); +/// ``` +T batch(T Function() fn) { + preset.startBatch(); + try { + return fn(); + } finally { + preset.endBatch(); + } } -/// A present optional value. -final class Some extends Option { - /// Creates an option that wraps [value]. - const Some(this.value); - - /// The wrapped value. - final T value; +/// Runs [callback] without tracking dependencies. +/// +/// This is useful when you want to read or write signals inside an effect +/// without establishing a dependency. +/// +/// ```dart +/// final count = Signal(0); +/// Effect(() { +/// print(count.value); +/// untracked(() => count.value = count.value + 1); +/// }); +/// ``` +T untracked(T Function() callback) { + final prevSub = preset.setActiveSub(); + try { + return callback(); + } finally { + preset.setActiveSub(prevSub); + } } -/// An absent optional value. -final class None extends Option { - /// Creates an option with no value. - const None(); +Object? _computedValue(Computed signal) { + final current = signal.currentValue; + if (current != null || null is T) { + return current; + } + return null; } -/// {@template solidart.config} -/// Global configuration for v3 reactive primitives. -/// -/// These flags provide defaults for newly created signals/effects/resources. -/// You can override them per-instance via constructor parameters. -/// {@endtemplate} -final class SolidartConfig { - const SolidartConfig._(); // coverage:ignore-line - - /// Whether nodes auto-dispose when they lose all subscribers. - /// - /// When enabled, signals/computeds/effects may dispose themselves once - /// nothing depends on them. - static bool autoDispose = false; - - /// Whether nested effects detach from parent subscriptions. - /// - /// When `true`, inner effects do not become dependencies of their parent - /// effect unless explicitly linked. - static bool detachEffects = false; - - /// Whether to track previous values by default. - /// - /// Previous values are captured only after a signal has been read at least - /// once. - static bool trackPreviousValue = true; - - /// Whether to keep values while refreshing resources. - /// - /// When `true`, a refresh marks the state as `isRefreshing` instead of - /// replacing it with `loading`. - static bool useRefreshing = true; - - /// Whether DevTools tracking is enabled. - /// - /// Signals only emit DevTools events when both this flag and - /// `trackInDevTools` are `true`. - static bool devToolsEnabled = false; - - /// Whether to assert that SignalBuilder has at least one dependency during - /// its build. Defaults to true. - /// - /// If you set this to false, you must ensure that the SignalBuilder has at - /// least one dependency, otherwise it won't rebuild when the signals change. - /// - /// The ability to disable this assertion is provided for advanced use cases - /// where you might have a SignalBuilder that builds something based on - /// disposed signals where you might be interested in their latest values. - static bool assertSignalBuilderWithoutDependencies = true; +// coverage:ignore-end - /// Registered observers for signal lifecycle events. - /// - /// Observers are notified only when `trackInDevTools` is enabled for the - /// signal instance. - static final observers = []; +// coverage:ignore-start +bool _hasPreviousValue(ReadonlySignal signal) { + if (!signal.trackPreviousValue) return false; + if (signal is Signal) { + return signal._previousValue is Some; + } + if (signal is Computed) { + return signal._previousValue is Some; + } + return false; } -/// {@template solidart.observer} -/// Observer for signal lifecycle events. -/// -/// Use this for logging or instrumentation without depending on DevTools: -/// ```dart -/// class Logger extends SolidartObserver { -/// @override -/// void didCreateSignal(ReadonlySignal signal) { -/// print('created: ${signal.identifier.value}'); -/// } -/// @override -/// void didUpdateSignal(ReadonlySignal signal) {} -/// @override -/// void didDisposeSignal(ReadonlySignal signal) {} -/// } -/// -/// SolidartConfig.observers.add(Logger()); -/// ``` -/// {@endtemplate} -abstract class SolidartObserver { - /// {@macro solidart.observer} - const SolidartObserver(); // coverage:ignore-line +// coverage:ignore-start +int _listenerCount(system.ReactiveNode node) { + var count = 0; + var link = node.subs; + while (link != null) { + count++; + link = link.nextSub; + } + return count; +} - /// Called when a signal is created. - void didCreateSignal(ReadonlySignal signal); +// coverage:ignore-end - /// Called when a signal updates. - void didUpdateSignal(ReadonlySignal signal); +void _notifyDevToolsAboutSignal( + ReadonlySignal signal, { + required _DevToolsEventType eventType, +}) { + if (!SolidartConfig.devToolsEnabled || !signal.trackInDevTools) return; + final eventName = 'ext.solidart.signal.${eventType.name}'; + final value = _signalValue(signal); + final previousValue = _signalPreviousValue(signal); + final hasPreviousValue = _hasPreviousValue(signal); - /// Called when a signal is disposed. - void didDisposeSignal(ReadonlySignal signal); + dev.postEvent(eventName, { + '_id': signal.identifier.value.toString(), + 'name': signal.identifier.name, + 'value': _toJson(value), + 'previousValue': _toJson(previousValue), + 'hasPreviousValue': hasPreviousValue, + 'type': _signalType(signal), + 'valueType': value.runtimeType.toString(), + if (hasPreviousValue) + 'previousValueType': previousValue.runtimeType.toString(), + 'disposed': signal.isDisposed, + 'autoDispose': signal.autoDispose, + 'listenerCount': _listenerCount(signal), + 'lastUpdate': DateTime.now().toIso8601String(), + }); } void _notifySignalCreation(ReadonlySignal signal) { @@ -161,48 +124,78 @@ void _notifySignalCreation(ReadonlySignal signal) { _notifyDevToolsAboutSignal(signal, eventType: _DevToolsEventType.created); } -void _notifySignalUpdate(ReadonlySignal signal) { +void _notifySignalDisposal(ReadonlySignal signal) { if (signal.trackInDevTools && SolidartConfig.observers.isNotEmpty) { for (final observer in SolidartConfig.observers) { - observer.didUpdateSignal(signal); + observer.didDisposeSignal(signal); } } - _notifyDevToolsAboutSignal(signal, eventType: _DevToolsEventType.updated); + _notifyDevToolsAboutSignal(signal, eventType: _DevToolsEventType.disposed); } -void _notifySignalDisposal(ReadonlySignal signal) { +void _notifySignalUpdate(ReadonlySignal signal) { if (signal.trackInDevTools && SolidartConfig.observers.isNotEmpty) { for (final observer in SolidartConfig.observers) { - observer.didDisposeSignal(signal); + observer.didUpdateSignal(signal); } } - _notifyDevToolsAboutSignal(signal, eventType: _DevToolsEventType.disposed); + _notifyDevToolsAboutSignal(signal, eventType: _DevToolsEventType.updated); } -enum _DevToolsEventType { - created, - updated, - disposed, +Object? _resourceValue(ResourceState? state) { + if (state == null) return null; + return state.maybeWhen(orElse: () => null, ready: (value) => value); } -// coverage:ignore-start -dynamic _toJson(Object? obj, [int depth = 0, Set? visited]) { - const maxDepth = 20; - if (depth > maxDepth) return ''; - try { - return jsonEncode(obj); - } catch (_) { - if (obj is List) { - final visitedSet = visited ?? Set.identity(); - if (!visitedSet.add(obj)) return ''; - try { - return obj - .map((e) => _toJson(e, depth + 1, visitedSet)) - .toList() - .toString(); - } finally { - visitedSet.remove(obj); - } +Object? _signalPreviousValue(ReadonlySignal signal) { + if (signal is Resource) { + return _resourceValue(signal.untrackedPreviousState); + } + return signal.untrackedPreviousValue; +} + +String _signalType(ReadonlySignal signal) => switch (signal) { + Resource() => 'Resource', + ListSignal() => 'ListSignal', + MapSignal() => 'MapSignal', + SetSignal() => 'SetSignal', + LazySignal() => 'LazySignal', + Signal() => 'Signal', + Computed() => 'Computed', + _ => 'ReadonlySignal', +}; + +Object? _signalValue(ReadonlySignal signal) { + if (signal is Resource) { + return _resourceValue(signal.untrackedState); + } + if (signal is LazySignal && !signal.isInitialized) { + return null; + } + if (signal is Computed) { + return _computedValue(signal); + } + return signal.untrackedValue; +} + +// coverage:ignore-start +dynamic _toJson(Object? obj, [int depth = 0, Set? visited]) { + const maxDepth = 20; + if (depth > maxDepth) return ''; + try { + return jsonEncode(obj); + } catch (_) { + if (obj is List) { + final visitedSet = visited ?? Set.identity(); + if (!visitedSet.add(obj)) return ''; + try { + return obj + .map((e) => _toJson(e, depth + 1, visitedSet)) + .toList() + .toString(); + } finally { + visitedSet.remove(obj); + } } if (obj is Set) { final visitedSet = visited ?? Set.identity(); @@ -235,183 +228,168 @@ dynamic _toJson(Object? obj, [int depth = 0, Set? visited]) { return jsonEncode(obj.toString()); } } -// coverage:ignore-end - -void _notifyDevToolsAboutSignal( - ReadonlySignal signal, { - required _DevToolsEventType eventType, -}) { - if (!SolidartConfig.devToolsEnabled || !signal.trackInDevTools) return; - final eventName = 'ext.solidart.signal.${eventType.name}'; - final value = _signalValue(signal); - final previousValue = _signalPreviousValue(signal); - final hasPreviousValue = _hasPreviousValue(signal); - - dev.postEvent(eventName, { - '_id': signal.identifier.value.toString(), - 'name': signal.identifier.name, - 'value': _toJson(value), - 'previousValue': _toJson(previousValue), - 'hasPreviousValue': hasPreviousValue, - 'type': _signalType(signal), - 'valueType': value.runtimeType.toString(), - if (hasPreviousValue) - 'previousValueType': previousValue.runtimeType.toString(), - 'disposed': signal.isDisposed, - 'autoDispose': signal.autoDispose, - 'listenerCount': _listenerCount(signal), - 'lastUpdate': DateTime.now().toIso8601String(), - }); -} - -String _signalType(ReadonlySignal signal) => switch (signal) { - Resource() => 'Resource', - ListSignal() => 'ListSignal', - MapSignal() => 'MapSignal', - SetSignal() => 'SetSignal', - LazySignal() => 'LazySignal', - Signal() => 'Signal', - Computed() => 'Computed', - _ => 'ReadonlySignal', -}; - -// coverage:ignore-start -int _listenerCount(system.ReactiveNode node) { - var count = 0; - var link = node.subs; - while (link != null) { - count++; - link = link.nextSub; - } - return count; -} -// coverage:ignore-end -// coverage:ignore-start -bool _hasPreviousValue(ReadonlySignal signal) { - if (!signal.trackPreviousValue) return false; - if (signal is Signal) { - return signal._previousValue is Some; - } - if (signal is Computed) { - return signal._previousValue is Some; - } - return false; -} +/// Disposer returned by [ObserveSignal.observe]. +typedef DisposeObservation = void Function(); -Object? _signalValue(ReadonlySignal signal) { - if (signal is Resource) { - return _resourceValue(signal.untrackedState); - } - if (signal is LazySignal && !signal.isInitialized) { - return null; - } - if (signal is Computed) { - return _computedValue(signal); - } - return signal.untrackedValue; -} +/// Signature for callbacks fired when a signal changes. +typedef ObserveCallback = void Function(T? previousValue, T value); -Object? _signalPreviousValue(ReadonlySignal signal) { - if (signal is Resource) { - return _resourceValue(signal.untrackedPreviousState); - } - return signal.untrackedPreviousValue; -} +/// Compares two values for equality. +/// +/// Return `true` when the update should be skipped because values are +/// considered equivalent. +typedef ValueComparator = bool Function(T? a, T? b); -Object? _resourceValue(ResourceState? state) { - if (state == null) return null; - return state.maybeWhen(orElse: () => null, ready: (value) => value); -} +/// Lazily produces a value. +typedef ValueGetter = T Function(); -Object? _computedValue(Computed signal) { - final current = signal.currentValue; - if (current != null || null is T) { - return current; - } - return null; -} -// coverage:ignore-end +/// A callback that returns no value. +typedef VoidCallback = ValueGetter; -/// Runs [callback] without tracking dependencies. -/// -/// This is useful when you want to read or write signals inside an effect -/// without establishing a dependency. +/// {@template solidart.computed} +/// # Computed +/// A computed signal derives its value from other signals. It is read-only +/// and recalculates whenever any dependency changes. /// +/// Use `Computed` to derive state or combine multiple signals: /// ```dart -/// final count = Signal(0); -/// Effect(() { -/// print(count.value); -/// untracked(() => count.value = count.value + 1); -/// }); +/// final firstName = Signal('Josh'); +/// final lastName = Signal('Brown'); +/// final fullName = Computed(() => '${firstName.value} ${lastName.value}'); /// ``` -T untracked(T Function() callback) { - final prevSub = preset.setActiveSub(); - try { - return callback(); - } finally { - preset.setActiveSub(prevSub); - } -} - -/// Batches signal updates and flushes once at the end. -/// -/// Nested batches are supported; the final flush happens when the outermost -/// batch completes. /// -/// ```dart -/// final a = Signal(1); -/// final b = Signal(2); -/// Effect(() => print('sum: ${a.value + b.value}')); +/// Computeds only notify when the derived value changes. You can customize +/// equality via [equals] to skip updates for equivalent values. /// -/// batch(() { -/// a.value = 3; -/// b.value = 4; -/// }); -/// ``` -T batch(T Function() fn) { - preset.startBatch(); - try { - return fn(); - } finally { - preset.endBatch(); +/// Like signals, computeds can track previous values once they have been read. +/// {@endtemplate} +class Computed extends preset.ComputedNode + with DisposableMixin + implements ReadonlySignal { + /// {@macro solidart.computed} + Computed( + ValueGetter getter, { + this.equals = identical, + bool? autoDispose, + String? name, + bool? trackPreviousValue, + bool? trackInDevTools, + }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, + trackPreviousValue = + trackPreviousValue ?? SolidartConfig.trackPreviousValue, + trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, + identifier = ._(name), + super(flags: system.ReactiveFlags.none, getter: (_) => getter()) { + _notifySignalCreation(this); } -} -/// A unique identifier with an optional name. -/// -/// Used by DevTools and diagnostics to track instances. -class Identifier { - Identifier._(this.name) : value = _counter++; - static int _counter = 0; + @override + final bool autoDispose; - /// Optional human-readable name. - final String? name; + @override + final Identifier identifier; - /// Unique numeric identifier. - final int value; -} + @override + final ValueComparator equals; -/// Base configuration shared by reactive primitives. -abstract interface class Configuration { - /// Identifier for the instance. - Identifier get identifier; + @override + final bool trackPreviousValue; - /// Whether the instance auto-disposes. - bool get autoDispose; -} + @override + final bool trackInDevTools; -/// Disposable behavior for reactive primitives. -abstract class Disposable { - /// Whether this instance has been disposed. - bool get isDisposed; + Option _previousValue = const None(); - /// Registers a callback to run on dispose. - void onDispose(VoidCallback callback); + @override + T? get previousValue { + if (!trackPreviousValue) return null; + value; + return _previousValue.safeUnwrap(); + } + + @override + T? get untrackedPreviousValue { + if (!trackPreviousValue) return null; + return _previousValue.safeUnwrap(); + } + + @override + T get untrackedValue { + if (currentValue != null || null is T) { + return currentValue as T; + } + return untracked(() => value); + } + + @override + T get value { + assert(!isDisposed, 'Computed is disposed'); + return get(); + } + + @override + T call() => value; + + @override + bool didUpdate() { + preset.cycle++; + depsTail = null; + flags = system.ReactiveFlags.mutable | system.ReactiveFlags.recursedCheck; + + final prevSub = preset.setActiveSub(this); + try { + final previousValue = currentValue; + final pendingValue = getter(previousValue); + if (equals(previousValue, pendingValue)) { + return false; + } + + if (trackPreviousValue && (previousValue is T)) { + _previousValue = Some(previousValue); + } + + currentValue = pendingValue; + _notifySignalUpdate(this); + return true; + } finally { + preset.activeSub = prevSub; + flags &= ~system.ReactiveFlags.recursedCheck; + preset.purgeDeps(this); + } + } + + @override + void dispose() { + if (isDisposed) return; + Disposable.unlinkDeps(this); + Disposable.unlinkSubs(this); + preset.stop(this); + super.dispose(); + _notifySignalDisposal(this); + } +} + +/// Base configuration shared by reactive primitives. +abstract interface class Configuration { + /// Whether the instance auto-disposes. + bool get autoDispose; + + /// Identifier for the instance. + Identifier get identifier; +} + +/// Disposable behavior for reactive primitives. +abstract class Disposable { + /// Whether this instance has been disposed. + bool get isDisposed; /// Disposes the instance. void dispose(); + /// Registers a callback to run on dispose. + void onDispose(VoidCallback callback); + /// Whether the node can be auto-disposed. static bool canAutoDispose(system.ReactiveNode node) => switch (node) { Disposable(:final isDisposed) && Configuration(:final autoDispose) => @@ -460,182 +438,105 @@ abstract class Disposable { } } -/// Common configuration for signals. -abstract interface class SignalConfiguration implements Configuration { - /// Comparator used to skip equal updates. - /// - /// When it returns `true`, the new value is treated as equal and the update - /// is skipped. - ValueComparator get equals; - - /// Whether to track previous values. - /// - /// Previous values are captured on successful updates after a tracked read. - bool get trackPreviousValue; - - /// Whether to report to DevTools. - bool get trackInDevTools; -} - -/// Read-only reactive value. -/// -/// Reading [value] establishes a dependency; [untrackedValue] does not. -/// This interface is implemented by [Signal], [Computed], and [Resource]. -/// -/// ```dart -/// final count = Signal(0); -/// ReadonlySignal readonly = count.toReadonly(); -/// ``` -// TODO(nank1ro): Maybe rename to `ReadSignal`? medz: I still recommend `ReadonlySignal` because it is semantically clearer., https://github.com/nank1ro/solidart/pull/166#issuecomment-3623175977 -abstract interface class ReadonlySignal - implements system.ReactiveNode, Disposable, SignalConfiguration { - /// Returns the current value and tracks dependencies. - T get value; - - /// Returns [value]. This allows using a signal as a callable. - T call(); - - /// Returns the current value without tracking. - T get untrackedValue; - - /// Returns the previous value (tracked read). - /// - /// This may return `null` if tracking is disabled or the signal has not been - /// read since the last update. - T? get previousValue; +/// Default [Disposable] implementation using cleanup callbacks. +mixin DisposableMixin implements Disposable { + @internal + /// Registered cleanup callbacks invoked on dispose. + late final cleanups = []; - /// Returns the previous value without tracking. - T? get untrackedPreviousValue; -} + @override + bool isDisposed = false; -/// Observes [ReadonlySignal] changes with previous and current values. -extension ObserveSignal on ReadonlySignal { - /// Observe the signal and invoke [listener] whenever the value changes. - /// - /// When [fireImmediately] is `true`, the listener runs once on subscription. - /// Returns a disposer that stops the observation. - DisposeObservation observe( - ObserveCallback listener, { - bool fireImmediately = false, - }) { - var skipped = false; - final effect = Effect( - () { - value; - if (!fireImmediately && !skipped) { - skipped = true; - return; - } - untracked(() { - listener(untrackedPreviousValue, untrackedValue); - }); - }, - detach: true, - ); + @mustCallSuper + @override + void dispose() { + if (isDisposed) return; + isDisposed = true; + try { + for (final callback in cleanups) { + callback(); + } + } finally { + cleanups.clear(); + } + } - return effect.dispose; + @mustCallSuper + @override + void onDispose(VoidCallback callback) { + cleanups.add(callback); } } +// coverage:ignore-end -/// {@template solidart.signal} -/// # Signals -/// Signals are the cornerstone of reactivity in v3. They store values that -/// change over time, and any reactive computation that reads a signal will -/// automatically update when the signal changes. +/// {@template solidart.effect} +/// # Effect +/// Effects run a side-effect whenever any signal they read changes. /// -/// Create a signal with an initial value: /// ```dart /// final counter = Signal(0); +/// Effect(() { +/// print('count: ${counter.value}'); +/// }); /// ``` /// -/// Read the current value: -/// ```dart -/// counter.value; // 0 -/// ``` -/// -/// Update the value: -/// ```dart -/// counter.value++; -/// // or -/// counter.value = 10; -/// ``` +/// Effects run once immediately when created. If you need a lazy effect, +/// create it with [Effect.manual] and call [run] yourself. /// -/// Signals support previous value tracking. When enabled, `previousValue` -/// updates only after the signal has been read at least once: -/// ```dart -/// final count = Signal(0); -/// count.value = 1; -/// count.previousValue; // null (not read yet) -/// count.value; // establishes tracking -/// count.previousValue; // 0 -/// ``` +/// Nested effects can either attach to their parent (default) or detach by +/// passing `detach: true` or by enabling [SolidartConfig.detachEffects]. /// -/// Signals can be created lazily using [Signal.lazy]. A lazy signal does not -/// have a value until it is first assigned, and reading it early throws -/// [StateError]. -/// {@endtemplate} -/// {@template solidart.signal-equals} -/// Updates are skipped when [equals] reports the new value is equivalent to -/// the previous one. +/// Call [dispose] to stop the effect and release dependencies. /// {@endtemplate} -class Signal extends preset.SignalNode> +class Effect extends preset.EffectNode with DisposableMixin - implements ReadonlySignal { - /// {@macro solidart.signal} - /// - /// {@macro solidart.signal-equals} - Signal( - T initialValue, { + implements Disposable, Configuration { + /// {@macro solidart.effect} + factory Effect( + VoidCallback callback, { bool? autoDispose, String? name, - ValueComparator equals = identical, - bool? trackPreviousValue, - bool? trackInDevTools, - }) : this._internal( - Some(initialValue), - autoDispose: autoDispose, - name: name, - equals: equals, - trackPreviousValue: trackPreviousValue, - trackInDevTools: trackInDevTools, - ); + bool? detach, + }) => .manual( + callback, + autoDispose: autoDispose, + name: name, + detach: detach, + )..run(); - Signal._internal( - Option initialValue, { - this.equals = identical, - String? name, + /// Creates an effect without running it. + /// + /// Use this when you need to *delay* the first run or decide *when* the + /// effect should start tracking dependencies. Common cases: + /// - you must create several signals first and only then start the effect + /// - you want to control the first run in tests + /// - you need conditional startup (e.g. after async setup) + /// + /// The effect will not track anything until you call [run]: + /// ```dart + /// final count = Signal(0); + /// final effect = Effect.manual(() { + /// print('count: ${count.value}'); + /// }); + /// + /// count.value = 1; // no output yet + /// effect.run(); // prints "count: 1" and starts tracking + /// ``` + /// + /// If you want the effect to run immediately, use the [Effect] factory. + Effect.manual( + VoidCallback callback, { bool? autoDispose, - bool? trackPreviousValue, - bool? trackInDevTools, + String? name, + bool? detach, }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, - trackPreviousValue = - trackPreviousValue ?? SolidartConfig.trackPreviousValue, - trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, identifier = ._(name), + detach = detach ?? SolidartConfig.detachEffects, super( - flags: system.ReactiveFlags.mutable, - currentValue: initialValue, - pendingValue: initialValue, - ) { - _notifySignalCreation(this); - } - - /// Whether the signal has been initialized. - /// - /// Regular signals are always initialized at construction time. - bool get isInitialized => true; - - /// {@macro solidart.signal} - /// - /// This is a lazy signal: it has no value at construction time. - /// Reading [value] before the first assignment throws [StateError]. - factory Signal.lazy({ - String? name, - bool? autoDispose, - ValueComparator equals, - bool? trackPreviousValue, - bool? trackInDevTools, - }) = LazySignal; + fn: callback, + flags: + system.ReactiveFlags.watching | system.ReactiveFlags.recursedCheck, + ); @override final bool autoDispose; @@ -643,85 +544,47 @@ class Signal extends preset.SignalNode> @override final Identifier identifier; - @override - final ValueComparator equals; - - @override - final bool trackPreviousValue; - - @override - final bool trackInDevTools; - - Option _previousValue = const None(); - - @override - T get value { - assert(!isDisposed, 'Signal is disposed'); - return super.get().unwrap(); - } - - @override - T call() => value; - - /// Sets the current value. - /// - /// {@macro solidart.signal-equals} - set value(T newValue) { - assert(!isDisposed, 'Signal is disposed'); - set(Some(newValue)); - } - - @override - T get untrackedValue => super.currentValue.unwrap(); - - @override - T? get previousValue { - if (!trackPreviousValue) return null; - value; - return _previousValue.safeUnwrap(); - } - - @override - T? get untrackedPreviousValue { - if (!trackPreviousValue) return null; - return _previousValue.safeUnwrap(); - } - - // TODO(nank1ro): See ReadonlySignal TODO, If `ReadonlySignal` rename - // to `ReadSignal`, the `.toReadonly` method should be rename? - /// Returns a read-only view of this signal. - ReadonlySignal toReadonly() => this; + /// Whether this effect detaches from parent subscriptions. + final bool detach; @override void dispose() { if (isDisposed) return; - Disposable.unlinkSubs(this); + Disposable.unlinkDeps(this); preset.stop(this); super.dispose(); - _notifySignalDisposal(this); } - @override - bool didUpdate() { - flags = system.ReactiveFlags.mutable; - final current = currentValue; - final pending = pendingValue; - if (current is Some && - pending is Some && - equals(pending.value, current.value)) { - return false; + /// Runs the effect and tracks dependencies. + void run() { + final prevSub = preset.setActiveSub(this); + if (!detach && prevSub != null) { + preset.link(this, prevSub, 0); } - if (trackPreviousValue && current is Some) { - _previousValue = current; + try { + fn(); + } finally { + preset.activeSub = prevSub; + flags &= ~system.ReactiveFlags.recursedCheck; } - - currentValue = pending; - _notifySignalUpdate(this); - return true; } } +/// A unique identifier with an optional name. +/// +/// Used by DevTools and diagnostics to track instances. +class Identifier { + Identifier._(this.name) : value = _counter++; + static int _counter = 0; + + /// Optional human-readable name. + final String? name; + + /// Unique numeric identifier. + final int value; +} + /// A signal that starts uninitialized until first set. /// /// This is the concrete type behind [Signal.lazy]. Reading [value] before the @@ -807,17 +670,6 @@ class ListSignal extends Signal> with ListMixin { trackInDevTools: trackInDevTools, ); - List _copy() => List.of(untrackedValue); - - bool _listEquals(List a, List b) { - if (identical(a, b)) return true; - if (a.length != b.length) return false; - for (var i = 0; i < a.length; i++) { - if (a[i] != b[i]) return false; - } - return true; - } - @override int get length => value.length; @@ -853,6 +705,23 @@ class ListSignal extends Signal> with ListMixin { value = next; } + @override + List cast() => ListSignal(untrackedValue.cast()); + + @override + void clear() { + if (untrackedValue.isEmpty) return; + value = []; + } + + @override + void fillRange(int start, int end, [E? fill]) { + if (end <= start) return; + final next = _copy()..fillRange(start, end, fill); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + @override void insert(int index, E element) { final next = _copy()..insert(index, element); @@ -902,44 +771,38 @@ class ListSignal extends Signal> with ListMixin { } @override - void replaceRange(int start, int end, Iterable newContents) { - final next = _copy()..replaceRange(start, end, newContents); - if (_listEquals(untrackedValue, next)) return; + void removeWhere(bool Function(E element) test) { + final current = untrackedValue; + final next = List.of(current)..removeWhere(test); + if (next.length == current.length) return; value = next; } @override - void setAll(int index, Iterable iterable) { - final next = _copy()..setAll(index, iterable); + void replaceRange(int start, int end, Iterable newContents) { + final next = _copy()..replaceRange(start, end, newContents); if (_listEquals(untrackedValue, next)) return; value = next; } @override - void setRange(int start, int end, Iterable iterable, [int skipCount = 0]) { - final next = _copy()..setRange(start, end, iterable, skipCount); - if (_listEquals(untrackedValue, next)) return; + void retainWhere(bool Function(E element) test) { + final current = untrackedValue; + final next = List.of(current)..retainWhere(test); + if (next.length == current.length) return; value = next; } @override - void fillRange(int start, int end, [E? fill]) { - if (end <= start) return; - final next = _copy()..fillRange(start, end, fill); + void setAll(int index, Iterable iterable) { + final next = _copy()..setAll(index, iterable); if (_listEquals(untrackedValue, next)) return; value = next; } @override - void clear() { - if (untrackedValue.isEmpty) return; - value = []; - } - - @override - void sort([int Function(E a, E b)? compare]) { - if (untrackedValue.length < 2) return; - final next = _copy()..sort(compare); + void setRange(int start, int end, Iterable iterable, [int skipCount = 0]) { + final next = _copy()..setRange(start, end, iterable, skipCount); if (_listEquals(untrackedValue, next)) return; value = next; } @@ -953,163 +816,34 @@ class ListSignal extends Signal> with ListMixin { } @override - void removeWhere(bool Function(E element) test) { - final current = untrackedValue; - final next = List.of(current)..removeWhere(test); - if (next.length == current.length) return; - value = next; - } - - @override - void retainWhere(bool Function(E element) test) { - final current = untrackedValue; - final next = List.of(current)..retainWhere(test); - if (next.length == current.length) return; + void sort([int Function(E a, E b)? compare]) { + if (untrackedValue.length < 2) return; + final next = _copy()..sort(compare); + if (_listEquals(untrackedValue, next)) return; value = next; } - @override - List cast() => ListSignal(untrackedValue.cast()); - @override String toString() => 'ListSignal<$E>(value: $untrackedValue, ' 'previousValue: $untrackedPreviousValue)'; + + List _copy() => List.of(untrackedValue); + + bool _listEquals(List a, List b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } } -/// {@template solidart.set-signal} -/// A reactive wrapper around a [Set] that copies on write. +/// {@template solidart.map-signal} +/// A reactive wrapper around a [Map] that copies on write. /// -/// Mutations create a new set instance so that updates are observable: -/// ```dart -/// final set = SetSignal({1}); -/// Effect(() => print(set.length)); -/// set.add(2); // triggers effect -/// ``` -/// -/// Reads (like `length` or `contains`) establish dependencies. -/// {@endtemplate} -class SetSignal extends Signal> with SetMixin { - /// {@macro solidart.set-signal} - /// - /// Creates a reactive set with the provided initial values. - SetSignal( - Iterable initialValue, { - bool? autoDispose, - String? name, - ValueComparator> equals = identical, - bool? trackPreviousValue, - bool? trackInDevTools, - }) : super( - Set.of(initialValue), - autoDispose: autoDispose, - name: name, - equals: equals, - trackPreviousValue: trackPreviousValue, - trackInDevTools: trackInDevTools, - ); - - Set _copy() => Set.of(untrackedValue); - - @override - int get length => value.length; - - @override - Iterator get iterator => value.iterator; - - @override - bool contains(Object? element) { - value; - return untrackedValue.contains(element); - } - - @override - E? lookup(Object? element) { - value; - return untrackedValue.lookup(element); - } - - @override - bool add(E value) { - final current = untrackedValue; - if (current.contains(value)) return false; - final next = Set.of(current)..add(value); - this.value = next; - return true; - } - - @override - void addAll(Iterable elements) { - if (elements.isEmpty) return; - final next = _copy()..addAll(elements); - if (next.length == untrackedValue.length) return; - value = next; - } - - @override - bool remove(Object? value) { - final current = untrackedValue; - if (!current.contains(value)) return false; - final next = Set.of(current)..remove(value); - this.value = next; - return true; - } - - @override - void removeAll(Iterable elements) { - if (elements.isEmpty) return; - final current = untrackedValue; - final next = Set.of(current)..removeAll(elements); - if (next.length == current.length) return; - value = next; - } - - @override - void retainAll(Iterable elements) { - final current = untrackedValue; - final next = Set.of(current)..retainAll(elements); - if (next.length == current.length) return; - value = next; - } - - @override - void removeWhere(bool Function(E element) test) { - final current = untrackedValue; - final next = Set.of(current)..removeWhere(test); - if (next.length == current.length) return; - value = next; - } - - @override - void retainWhere(bool Function(E element) test) { - final current = untrackedValue; - final next = Set.of(current)..retainWhere(test); - if (next.length == current.length) return; - value = next; - } - - @override - void clear() { - if (untrackedValue.isEmpty) return; - value = {}; - } - - @override - Set toSet() => Set.of(untrackedValue); - - @override - Set cast() => SetSignal(untrackedValue.cast()); - - @override - String toString() => - 'SetSignal<$E>(value: $untrackedValue, ' - 'previousValue: $untrackedPreviousValue)'; -} - -/// {@template solidart.map-signal} -/// A reactive wrapper around a [Map] that copies on write. -/// -/// Mutations create a new map instance so that updates are observable: +/// Mutations create a new map instance so that updates are observable: /// ```dart /// final map = MapSignal({'a': 1}); /// Effect(() => print(map['a'])); @@ -1138,72 +872,63 @@ class MapSignal extends Signal> with MapMixin { trackInDevTools: trackInDevTools, ); - Map _copy() => Map.of(untrackedValue); - - bool _mapEquals(Map a, Map b) { - if (identical(a, b)) return true; - if (a.length != b.length) return false; - for (final entry in a.entries) { - if (!b.containsKey(entry.key)) return false; - if (b[entry.key] != entry.value) return false; - } - return true; + @override + bool get isEmpty { + value; + return untrackedValue.isEmpty; } @override - V? operator [](Object? key) { + bool get isNotEmpty { value; - return untrackedValue[key]; + return untrackedValue.isNotEmpty; } @override - void operator []=(K key, V value) { - final current = untrackedValue; - final existing = current[key]; - if (current.containsKey(key) && existing == value) return; - final next = _copy(); - next[key] = value; - this.value = next; + Iterable get keys { + value; + return untrackedValue.keys; } @override - void clear() { - if (untrackedValue.isEmpty) return; - value = {}; + int get length { + value; + return untrackedValue.length; } @override - Iterable get keys { + V? operator [](Object? key) { value; - return untrackedValue.keys; + return untrackedValue[key]; } @override - V? remove(Object? key) { + void operator []=(K key, V value) { final current = untrackedValue; - if (!current.containsKey(key)) return null; + final existing = current[key]; + if (current.containsKey(key) && existing == value) return; final next = _copy(); - final removed = next.remove(key); - value = next; - return removed; + next[key] = value; + this.value = next; } @override - int get length { - value; - return untrackedValue.length; + void addAll(Map other) { + if (other.isEmpty) return; + final current = untrackedValue; + final next = _copy()..addAll(other); + if (_mapEquals(next, current)) return; + value = next; } @override - bool get isEmpty { - value; - return untrackedValue.isEmpty; - } + Map cast() => + MapSignal(untrackedValue.cast()); @override - bool get isNotEmpty { - value; - return untrackedValue.isNotEmpty; + void clear() { + if (untrackedValue.isEmpty) return; + value = {}; } @override @@ -1218,15 +943,6 @@ class MapSignal extends Signal> with MapMixin { return untrackedValue.containsValue(value); } - @override - void addAll(Map other) { - if (other.isEmpty) return; - final current = untrackedValue; - final next = _copy()..addAll(other); - if (_mapEquals(next, current)) return; - value = next; - } - @override V putIfAbsent(K key, V Function() ifAbsent) { final current = untrackedValue; @@ -1240,6 +956,30 @@ class MapSignal extends Signal> with MapMixin { return value; } + @override + V? remove(Object? key) { + final current = untrackedValue; + if (!current.containsKey(key)) return null; + final next = _copy(); + final removed = next.remove(key); + value = next; + return removed; + } + + @override + void removeWhere(bool Function(K key, V value) test) { + final current = untrackedValue; + if (current.isEmpty) return; + final next = _copy()..removeWhere(test); + if (next.length == current.length) return; + value = next; + } + + @override + String toString() => + 'MapSignal<$K, $V>(value: $untrackedValue, ' + 'previousValue: $untrackedPreviousValue)'; + @override V update( K key, @@ -1279,248 +1019,75 @@ class MapSignal extends Signal> with MapMixin { value = next; } - @override - void removeWhere(bool Function(K key, V value) test) { - final current = untrackedValue; - if (current.isEmpty) return; - final next = _copy()..removeWhere(test); - if (next.length == current.length) return; - value = next; - } + Map _copy() => Map.of(untrackedValue); - @override - Map cast() => - MapSignal(untrackedValue.cast()); + bool _mapEquals(Map a, Map b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (final entry in a.entries) { + if (!b.containsKey(entry.key)) return false; + if (b[entry.key] != entry.value) return false; + } + return true; + } +} - @override - String toString() => - 'MapSignal<$K, $V>(value: $untrackedValue, ' - 'previousValue: $untrackedPreviousValue)'; +/// An absent optional value. +final class None extends Option { + /// Creates an option with no value. + const None(); } -/// {@template solidart.computed} -/// # Computed -/// A computed signal derives its value from other signals. It is read-only -/// and recalculates whenever any dependency changes. -/// -/// Use `Computed` to derive state or combine multiple signals: -/// ```dart -/// final firstName = Signal('Josh'); -/// final lastName = Signal('Brown'); -/// final fullName = Computed(() => '${firstName.value} ${lastName.value}'); -/// ``` -/// -/// Computeds only notify when the derived value changes. You can customize -/// equality via [equals] to skip updates for equivalent values. +/// An optional value container. /// -/// Like signals, computeds can track previous values once they have been read. -/// {@endtemplate} -class Computed extends preset.ComputedNode - with DisposableMixin - implements ReadonlySignal { - /// {@macro solidart.computed} - Computed( - ValueGetter getter, { - this.equals = identical, - bool? autoDispose, - String? name, - bool? trackPreviousValue, - bool? trackInDevTools, - }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, - trackPreviousValue = - trackPreviousValue ?? SolidartConfig.trackPreviousValue, - trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, - identifier = ._(name), - super(flags: system.ReactiveFlags.none, getter: (_) => getter()) { - _notifySignalCreation(this); - } - - @override - final bool autoDispose; - - @override - final Identifier identifier; - - @override - final ValueComparator equals; - - @override - final bool trackPreviousValue; - - @override - final bool trackInDevTools; - - Option _previousValue = const None(); - - @override - T get value { - assert(!isDisposed, 'Computed is disposed'); - return get(); - } - - @override - T call() => value; - - @override - T get untrackedValue { - if (currentValue != null || null is T) { - return currentValue as T; - } - return untracked(() => value); - } - - @override - T? get previousValue { - if (!trackPreviousValue) return null; - value; - return _previousValue.safeUnwrap(); - } - - @override - T? get untrackedPreviousValue { - if (!trackPreviousValue) return null; - return _previousValue.safeUnwrap(); - } - - @override - void dispose() { - if (isDisposed) return; - Disposable.unlinkDeps(this); - Disposable.unlinkSubs(this); - preset.stop(this); - super.dispose(); - _notifySignalDisposal(this); - } - - @override - bool didUpdate() { - preset.cycle++; - depsTail = null; - flags = system.ReactiveFlags.mutable | system.ReactiveFlags.recursedCheck; - - final prevSub = preset.setActiveSub(this); - try { - final previousValue = currentValue; - final pendingValue = getter(previousValue); - if (equals(previousValue, pendingValue)) { - return false; - } +/// Use [Some] to represent presence and [None] to represent absence without +/// relying on `null`. +sealed class Option { + /// Base constructor for option values. + const Option(); - if (trackPreviousValue && (previousValue is T)) { - _previousValue = Some(previousValue); - } + /// Returns the contained value or `null` if this is [None]. + T? safeUnwrap() => switch (this) { + Some(:final value) => value, + _ => null, + }; - currentValue = pendingValue; - _notifySignalUpdate(this); - return true; - } finally { - preset.activeSub = prevSub; - flags &= ~system.ReactiveFlags.recursedCheck; - preset.purgeDeps(this); - } - } + /// Returns the contained value or throws if this is [None]. + T unwrap() => switch (this) { + Some(:final value) => value, + _ => throw StateError('Option is None'), + }; } -/// {@template solidart.effect} -/// # Effect -/// Effects run a side-effect whenever any signal they read changes. +/// Read-only reactive value. +/// +/// Reading [value] establishes a dependency; [untrackedValue] does not. +/// This interface is implemented by [Signal], [Computed], and [Resource]. /// /// ```dart -/// final counter = Signal(0); -/// Effect(() { -/// print('count: ${counter.value}'); -/// }); +/// final count = Signal(0); +/// ReadonlySignal readonly = count.toReadonly(); /// ``` -/// -/// Effects run once immediately when created. If you need a lazy effect, -/// create it with [Effect.manual] and call [run] yourself. -/// -/// Nested effects can either attach to their parent (default) or detach by -/// passing `detach: true` or by enabling [SolidartConfig.detachEffects]. -/// -/// Call [dispose] to stop the effect and release dependencies. -/// {@endtemplate} -class Effect extends preset.EffectNode - with DisposableMixin - implements Disposable, Configuration { - /// {@macro solidart.effect} - factory Effect( - VoidCallback callback, { - bool? autoDispose, - String? name, - bool? detach, - }) => .manual( - callback, - autoDispose: autoDispose, - name: name, - detach: detach, - )..run(); - - /// Creates an effect without running it. - /// - /// Use this when you need to *delay* the first run or decide *when* the - /// effect should start tracking dependencies. Common cases: - /// - you must create several signals first and only then start the effect - /// - you want to control the first run in tests - /// - you need conditional startup (e.g. after async setup) - /// - /// The effect will not track anything until you call [run]: - /// ```dart - /// final count = Signal(0); - /// final effect = Effect.manual(() { - /// print('count: ${count.value}'); - /// }); - /// - /// count.value = 1; // no output yet - /// effect.run(); // prints "count: 1" and starts tracking - /// ``` +// TODO(nank1ro): Maybe rename to `ReadSignal`? medz: I still recommend `ReadonlySignal` because it is semantically clearer., https://github.com/nank1ro/solidart/pull/166#issuecomment-3623175977 +abstract interface class ReadonlySignal + implements system.ReactiveNode, Disposable, SignalConfiguration { + /// Returns the previous value (tracked read). /// - /// If you want the effect to run immediately, use the [Effect] factory. - Effect.manual( - VoidCallback callback, { - bool? autoDispose, - String? name, - bool? detach, - }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, - identifier = ._(name), - detach = detach ?? SolidartConfig.detachEffects, - super( - fn: callback, - flags: - system.ReactiveFlags.watching | system.ReactiveFlags.recursedCheck, - ); - - @override - final bool autoDispose; - - @override - final Identifier identifier; + /// This may return `null` if tracking is disabled or the signal has not been + /// read since the last update. + T? get previousValue; - /// Whether this effect detaches from parent subscriptions. - final bool detach; + /// Returns the previous value without tracking. + T? get untrackedPreviousValue; - /// Runs the effect and tracks dependencies. - void run() { - final prevSub = preset.setActiveSub(this); - if (!detach && prevSub != null) { - preset.link(this, prevSub, 0); - } + /// Returns the current value without tracking. + T get untrackedValue; - try { - fn(); - } finally { - preset.activeSub = prevSub; - flags &= ~system.ReactiveFlags.recursedCheck; - } - } + /// Returns the current value and tracks dependencies. + T get value; - @override - void dispose() { - if (isDisposed) return; - Disposable.unlinkDeps(this); - preset.stop(this); - super.dispose(); - } + /// Returns [value]. This allows using a signal as a callable. + T call(); } /// {@template solidart.resource} @@ -1662,15 +1229,6 @@ class Resource extends Signal> { StreamSubscription? _streamSubscription; Timer? _debounceTimer; - /// Returns the current state, resolving lazily if needed. - ResourceState get state { - _resolveIfNeeded(); - return value; - } - - /// Sets the current state. - set state(ResourceState next) => value = next; - /// Returns the previous state (tracked read), or `null`. /// /// Previous state is available only after a tracked read. @@ -1680,26 +1238,30 @@ class Resource extends Signal> { return previousValue; } - /// Returns the current state without tracking. - ResourceState get untrackedState => untrackedValue; + /// Returns the current state, resolving lazily if needed. + ResourceState get state { + _resolveIfNeeded(); + return value; + } + + /// Sets the current state. + set state(ResourceState next) => value = next; /// Returns the previous state without tracking. ResourceState? get untrackedPreviousState => untrackedPreviousValue; - /// Resolves the resource if it has not been resolved yet. - /// - /// Multiple calls are coalesced into a single in-flight resolve. - Future resolve() async { - if (isDisposed) return; - if (_resolveFuture != null) return _resolveFuture!; - if (_resolved) return; - - _resolved = true; - _resolveFuture = _doResolve().whenComplete(() { - _resolveFuture = null; - }); + /// Returns the current state without tracking. + ResourceState get untrackedState => untrackedValue; - return _resolveFuture!; + @override + void dispose() { + _debounceTimer?.cancel(); + _debounceTimer = null; + _sourceEffect?.dispose(); + _sourceEffect = null; + _streamSubscription?.cancel(); + _streamSubscription = null; + super.dispose(); } /// Re-fetches or re-subscribes to the resource. @@ -1722,21 +1284,20 @@ class Resource extends Signal> { } } - @override - void dispose() { - _debounceTimer?.cancel(); - _debounceTimer = null; - _sourceEffect?.dispose(); - _sourceEffect = null; - _streamSubscription?.cancel(); - _streamSubscription = null; - super.dispose(); - } + /// Resolves the resource if it has not been resolved yet. + /// + /// Multiple calls are coalesced into a single in-flight resolve. + Future resolve() async { + if (isDisposed) return; + if (_resolveFuture != null) return _resolveFuture!; + if (_resolved) return; - void _resolveIfNeeded() { - if (!_resolved) { - unawaited(resolve()); - } + _resolved = true; + _resolveFuture = _doResolve().whenComplete(() { + _resolveFuture = null; + }); + + return _resolveFuture!; } Future _doResolve() async { @@ -1753,29 +1314,6 @@ class Resource extends Signal> { } } - void _setupSourceEffect() { - var skipped = false; - _sourceEffect = Effect( - () { - source!.value; - if (!skipped) { - skipped = true; - return; - } - if (debounceDelay != null) { - _debounceTimer?.cancel(); - _debounceTimer = Timer(debounceDelay!, () { - if (isDisposed) return; - untracked(refresh); - }); - } else { - untracked(refresh); - } - }, - autoDispose: false, - ); - } - Future _fetch() async { final requestId = ++_version; try { @@ -1788,13 +1326,31 @@ class Resource extends Signal> { } } + bool _isStale(int requestId) => requestId != _version || isDisposed; + + void _listenStream() { + final requestId = ++_version; + _streamSubscription = stream!().listen( + (data) { + if (_isStale(requestId)) return; + state = ResourceState.ready(data); + }, + onError: (Object error, StackTrace stackTrace) { + if (_isStale(requestId)) return; + state = ResourceState.error(error, stackTrace: stackTrace); + }, + ); + } + Future _refetch() async { _transition(); return _fetch(); } - void _subscribe() { - _listenStream(); + void _resolveIfNeeded() { + if (!_resolved) { + unawaited(resolve()); + } } void _resubscribe() { @@ -1804,21 +1360,32 @@ class Resource extends Signal> { _listenStream(); } - void _listenStream() { - final requestId = ++_version; - _streamSubscription = stream!().listen( - (data) { - if (_isStale(requestId)) return; - state = ResourceState.ready(data); - }, - onError: (Object error, StackTrace stackTrace) { - if (_isStale(requestId)) return; - state = ResourceState.error(error, stackTrace: stackTrace); + void _setupSourceEffect() { + var skipped = false; + _sourceEffect = Effect( + () { + source!.value; + if (!skipped) { + skipped = true; + return; + } + if (debounceDelay != null) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(debounceDelay!, () { + if (isDisposed) return; + untracked(refresh); + }); + } else { + untracked(refresh); + } }, + autoDispose: false, ); } - bool _isStale(int requestId) => requestId != _version || isDisposed; + void _subscribe() { + _listenStream(); + } void _transition() { if (!useRefreshing) { @@ -1839,105 +1406,64 @@ class Resource extends Signal> { } } -/// {@template solidart.resource-state} -/// Represents the state of a [Resource]. -/// -/// A resource is always in one of: -/// - `ready(data)` when a value is available -/// - `loading()` while work is in progress -/// - `error(error)` when a failure occurs -/// -/// Use [ResourceStateExtensions] helpers to map or pattern-match: -/// ```dart -/// final state = resource.state; -/// final label = state.when( -/// ready: (data) => 'ready: $data', -/// error: (err, _) => 'error: $err', -/// loading: () => 'loading', -/// ); -/// ``` -/// {@endtemplate} -@sealed +/// Error state containing an error and optional stack trace. @immutable -sealed class ResourceState { - /// Base constructor for resource states. - const ResourceState(); // coverage:ignore-line - - /// {@macro solidart.resource-state} - /// - /// Creates a ready state with [data]. - const factory ResourceState.ready(T data, {bool isRefreshing}) = - ResourceReady; - - /// {@macro solidart.resource-state} - /// - /// Creates a loading state. - const factory ResourceState.loading() = ResourceLoading; - - /// {@macro solidart.resource-state} - /// +class ResourceError implements ResourceState { /// Creates an error state. - const factory ResourceState.error( - Object error, { - StackTrace? stackTrace, - bool isRefreshing, - }) = ResourceError; - - /// Maps each concrete state to a value. - R map({ - required R Function(ResourceReady ready) ready, - required R Function(ResourceError error) error, - required R Function(ResourceLoading loading) loading, + const ResourceError( + this.error, { + this.stackTrace, + this.isRefreshing = false, }); -} -/// Ready state containing data. -@immutable -class ResourceReady implements ResourceState { - /// Creates a ready state with [value]. - const ResourceReady(this.value, {this.isRefreshing = false}); + /// The error object. + final Object error; - /// The resource value. - final T value; + /// Optional stack trace. + final StackTrace? stackTrace; /// Whether the resource is refreshing. final bool isRefreshing; @override - R map({ - required R Function(ResourceReady ready) ready, - required R Function(ResourceError error) error, - required R Function(ResourceLoading loading) loading, - }) { - return ready(this); + int get hashCode => Object.hash(runtimeType, error, stackTrace, isRefreshing); + + @override + bool operator ==(Object other) { + return runtimeType == other.runtimeType && + other is ResourceError && + other.error == error && + other.stackTrace == stackTrace && + other.isRefreshing == isRefreshing; } /// Returns a copy with updated fields. - ResourceReady copyWith({ - T? value, + ResourceError copyWith({ + Object? error, + StackTrace? stackTrace, bool? isRefreshing, }) { - return ResourceReady( - value ?? this.value, + return ResourceError( + error ?? this.error, + stackTrace: stackTrace ?? this.stackTrace, isRefreshing: isRefreshing ?? this.isRefreshing, ); } @override - String toString() { - return 'ResourceReady<$T>(value: $value, refreshing: $isRefreshing)'; + R map({ + required R Function(ResourceReady ready) ready, + required R Function(ResourceError error) error, + required R Function(ResourceLoading loading) loading, + }) { + return error(this); } @override - bool operator ==(Object other) { - return runtimeType == other.runtimeType && - other is ResourceReady && - other.value == value && - other.isRefreshing == isRefreshing; + String toString() { + return 'ResourceError<$T>(error: $error, stackTrace: $stackTrace, ' + 'refreshing: $isRefreshing)'; } - - @override - int get hashCode => Object.hash(runtimeType, value, isRefreshing); } /// Loading state. @@ -1946,6 +1472,12 @@ class ResourceLoading implements ResourceState { /// Creates a loading state. const ResourceLoading(); + @override + int get hashCode => runtimeType.hashCode; + + @override + bool operator ==(Object other) => runtimeType == other.runtimeType; + @override R map({ required R Function(ResourceReady ready) ready, @@ -1955,74 +1487,574 @@ class ResourceLoading implements ResourceState { return loading(this); } - @override - String toString() => 'ResourceLoading<$T>()'; + @override + String toString() => 'ResourceLoading<$T>()'; +} + +/// Ready state containing data. +@immutable +class ResourceReady implements ResourceState { + /// Creates a ready state with [value]. + const ResourceReady(this.value, {this.isRefreshing = false}); + + /// The resource value. + final T value; + + /// Whether the resource is refreshing. + final bool isRefreshing; + + @override + int get hashCode => Object.hash(runtimeType, value, isRefreshing); + + @override + bool operator ==(Object other) { + return runtimeType == other.runtimeType && + other is ResourceReady && + other.value == value && + other.isRefreshing == isRefreshing; + } + + /// Returns a copy with updated fields. + ResourceReady copyWith({ + T? value, + bool? isRefreshing, + }) { + return ResourceReady( + value ?? this.value, + isRefreshing: isRefreshing ?? this.isRefreshing, + ); + } + + @override + R map({ + required R Function(ResourceReady ready) ready, + required R Function(ResourceError error) error, + required R Function(ResourceLoading loading) loading, + }) { + return ready(this); + } + + @override + String toString() { + return 'ResourceReady<$T>(value: $value, refreshing: $isRefreshing)'; + } +} + +/// {@template solidart.resource-state} +/// Represents the state of a [Resource]. +/// +/// A resource is always in one of: +/// - `ready(data)` when a value is available +/// - `loading()` while work is in progress +/// - `error(error)` when a failure occurs +/// +/// Use [ResourceStateExtensions] helpers to map or pattern-match: +/// ```dart +/// final state = resource.state; +/// final label = state.when( +/// ready: (data) => 'ready: $data', +/// error: (err, _) => 'error: $err', +/// loading: () => 'loading', +/// ); +/// ``` +/// {@endtemplate} +@sealed +@immutable +sealed class ResourceState { + /// Base constructor for resource states. + const ResourceState(); // coverage:ignore-line + + /// {@macro solidart.resource-state} + /// + /// Creates an error state. + const factory ResourceState.error( + Object error, { + StackTrace? stackTrace, + bool isRefreshing, + }) = ResourceError; + + /// {@macro solidart.resource-state} + /// + /// Creates a loading state. + const factory ResourceState.loading() = ResourceLoading; + + /// {@macro solidart.resource-state} + /// + /// Creates a ready state with [data]. + const factory ResourceState.ready(T data, {bool isRefreshing}) = + ResourceReady; + + /// Maps each concrete state to a value. + R map({ + required R Function(ResourceReady ready) ready, + required R Function(ResourceError error) error, + required R Function(ResourceLoading loading) loading, + }); +} + +/// {@template solidart.set-signal} +/// A reactive wrapper around a [Set] that copies on write. +/// +/// Mutations create a new set instance so that updates are observable: +/// ```dart +/// final set = SetSignal({1}); +/// Effect(() => print(set.length)); +/// set.add(2); // triggers effect +/// ``` +/// +/// Reads (like `length` or `contains`) establish dependencies. +/// {@endtemplate} +class SetSignal extends Signal> with SetMixin { + /// {@macro solidart.set-signal} + /// + /// Creates a reactive set with the provided initial values. + SetSignal( + Iterable initialValue, { + bool? autoDispose, + String? name, + ValueComparator> equals = identical, + bool? trackPreviousValue, + bool? trackInDevTools, + }) : super( + Set.of(initialValue), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: trackPreviousValue, + trackInDevTools: trackInDevTools, + ); + + @override + Iterator get iterator => value.iterator; + + @override + int get length => value.length; + + @override + bool add(E value) { + final current = untrackedValue; + if (current.contains(value)) return false; + final next = Set.of(current)..add(value); + this.value = next; + return true; + } + + @override + void addAll(Iterable elements) { + if (elements.isEmpty) return; + final next = _copy()..addAll(elements); + if (next.length == untrackedValue.length) return; + value = next; + } + + @override + Set cast() => SetSignal(untrackedValue.cast()); + + @override + void clear() { + if (untrackedValue.isEmpty) return; + value = {}; + } + + @override + bool contains(Object? element) { + value; + return untrackedValue.contains(element); + } + + @override + E? lookup(Object? element) { + value; + return untrackedValue.lookup(element); + } + + @override + bool remove(Object? value) { + final current = untrackedValue; + if (!current.contains(value)) return false; + final next = Set.of(current)..remove(value); + this.value = next; + return true; + } + + @override + void removeAll(Iterable elements) { + if (elements.isEmpty) return; + final current = untrackedValue; + final next = Set.of(current)..removeAll(elements); + if (next.length == current.length) return; + value = next; + } + + @override + void removeWhere(bool Function(E element) test) { + final current = untrackedValue; + final next = Set.of(current)..removeWhere(test); + if (next.length == current.length) return; + value = next; + } + + @override + void retainAll(Iterable elements) { + final current = untrackedValue; + final next = Set.of(current)..retainAll(elements); + if (next.length == current.length) return; + value = next; + } + + @override + void retainWhere(bool Function(E element) test) { + final current = untrackedValue; + final next = Set.of(current)..retainWhere(test); + if (next.length == current.length) return; + value = next; + } + + @override + Set toSet() => Set.of(untrackedValue); + + @override + String toString() => + 'SetSignal<$E>(value: $untrackedValue, ' + 'previousValue: $untrackedPreviousValue)'; + + Set _copy() => Set.of(untrackedValue); +} + +/// {@template solidart.signal} +/// # Signals +/// Signals are the cornerstone of reactivity in v3. They store values that +/// change over time, and any reactive computation that reads a signal will +/// automatically update when the signal changes. +/// +/// Create a signal with an initial value: +/// ```dart +/// final counter = Signal(0); +/// ``` +/// +/// Read the current value: +/// ```dart +/// counter.value; // 0 +/// ``` +/// +/// Update the value: +/// ```dart +/// counter.value++; +/// // or +/// counter.value = 10; +/// ``` +/// +/// Signals support previous value tracking. When enabled, `previousValue` +/// updates only after the signal has been read at least once: +/// ```dart +/// final count = Signal(0); +/// count.value = 1; +/// count.previousValue; // null (not read yet) +/// count.value; // establishes tracking +/// count.previousValue; // 0 +/// ``` +/// +/// Signals can be created lazily using [Signal.lazy]. A lazy signal does not +/// have a value until it is first assigned, and reading it early throws +/// [StateError]. +/// {@endtemplate} +/// {@template solidart.signal-equals} +/// Updates are skipped when [equals] reports the new value is equivalent to +/// the previous one. +/// {@endtemplate} +class Signal extends preset.SignalNode> + with DisposableMixin + implements ReadonlySignal { + /// {@macro solidart.signal} + /// + /// {@macro solidart.signal-equals} + Signal( + T initialValue, { + bool? autoDispose, + String? name, + ValueComparator equals = identical, + bool? trackPreviousValue, + bool? trackInDevTools, + }) : this._internal( + Some(initialValue), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: trackPreviousValue, + trackInDevTools: trackInDevTools, + ); + + /// {@macro solidart.signal} + /// + /// This is a lazy signal: it has no value at construction time. + /// Reading [value] before the first assignment throws [StateError]. + factory Signal.lazy({ + String? name, + bool? autoDispose, + ValueComparator equals, + bool? trackPreviousValue, + bool? trackInDevTools, + }) = LazySignal; + + Signal._internal( + Option initialValue, { + this.equals = identical, + String? name, + bool? autoDispose, + bool? trackPreviousValue, + bool? trackInDevTools, + }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, + trackPreviousValue = + trackPreviousValue ?? SolidartConfig.trackPreviousValue, + trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, + identifier = ._(name), + super( + flags: system.ReactiveFlags.mutable, + currentValue: initialValue, + pendingValue: initialValue, + ) { + _notifySignalCreation(this); + } + + @override + final bool autoDispose; + + @override + final Identifier identifier; + + @override + final ValueComparator equals; + + @override + final bool trackPreviousValue; + + @override + final bool trackInDevTools; + + Option _previousValue = const None(); + + /// Whether the signal has been initialized. + /// + /// Regular signals are always initialized at construction time. + bool get isInitialized => true; + + @override + T? get previousValue { + if (!trackPreviousValue) return null; + value; + return _previousValue.safeUnwrap(); + } + + @override + T? get untrackedPreviousValue { + if (!trackPreviousValue) return null; + return _previousValue.safeUnwrap(); + } + + @override + T get untrackedValue => super.currentValue.unwrap(); + + @override + T get value { + assert(!isDisposed, 'Signal is disposed'); + return super.get().unwrap(); + } + + /// Sets the current value. + /// + /// {@macro solidart.signal-equals} + set value(T newValue) { + assert(!isDisposed, 'Signal is disposed'); + set(Some(newValue)); + } + + @override + T call() => value; + + // TODO(nank1ro): See ReadonlySignal TODO, If `ReadonlySignal` rename + // to `ReadSignal`, the `.toReadonly` method should be rename? + @override + bool didUpdate() { + flags = system.ReactiveFlags.mutable; + final current = currentValue; + final pending = pendingValue; + if (current is Some && + pending is Some && + equals(pending.value, current.value)) { + return false; + } + + if (trackPreviousValue && current is Some) { + _previousValue = current; + } + + currentValue = pending; + _notifySignalUpdate(this); + return true; + } + + @override + void dispose() { + if (isDisposed) return; + Disposable.unlinkSubs(this); + preset.stop(this); + super.dispose(); + _notifySignalDisposal(this); + } + + /// Returns a read-only view of this signal. + ReadonlySignal toReadonly() => this; +} + +/// Common configuration for signals. +abstract interface class SignalConfiguration implements Configuration { + /// Comparator used to skip equal updates. + /// + /// When it returns `true`, the new value is treated as equal and the update + /// is skipped. + ValueComparator get equals; + + /// Whether to report to DevTools. + bool get trackInDevTools; + + /// Whether to track previous values. + /// + /// Previous values are captured on successful updates after a tracked read. + bool get trackPreviousValue; +} + +/// {@template solidart.config} +/// Global configuration for v3 reactive primitives. +/// +/// These flags provide defaults for newly created signals/effects/resources. +/// You can override them per-instance via constructor parameters. +/// {@endtemplate} +final class SolidartConfig { + const SolidartConfig._(); // coverage:ignore-line + + /// Whether nodes auto-dispose when they lose all subscribers. + /// + /// When enabled, signals/computeds/effects may dispose themselves once + /// nothing depends on them. + static bool autoDispose = false; + + /// Whether nested effects detach from parent subscriptions. + /// + /// When `true`, inner effects do not become dependencies of their parent + /// effect unless explicitly linked. + static bool detachEffects = false; + + /// Whether to track previous values by default. + /// + /// Previous values are captured only after a signal has been read at least + /// once. + static bool trackPreviousValue = true; + + /// Whether to keep values while refreshing resources. + /// + /// When `true`, a refresh marks the state as `isRefreshing` instead of + /// replacing it with `loading`. + static bool useRefreshing = true; + + /// Whether DevTools tracking is enabled. + /// + /// Signals only emit DevTools events when both this flag and + /// `trackInDevTools` are `true`. + static bool devToolsEnabled = false; + + /// Whether to assert that SignalBuilder has at least one dependency during + /// its build. Defaults to true. + /// + /// If you set this to false, you must ensure that the SignalBuilder has at + /// least one dependency, otherwise it won't rebuild when the signals change. + /// + /// The ability to disable this assertion is provided for advanced use cases + /// where you might have a SignalBuilder that builds something based on + /// disposed signals where you might be interested in their latest values. + static bool assertSignalBuilderWithoutDependencies = true; + + /// Registered observers for signal lifecycle events. + /// + /// Observers are notified only when `trackInDevTools` is enabled for the + /// signal instance. + static final observers = []; +} - @override - bool operator ==(Object other) => runtimeType == other.runtimeType; +/// {@template solidart.observer} +/// Observer for signal lifecycle events. +/// +/// Use this for logging or instrumentation without depending on DevTools: +/// ```dart +/// class Logger extends SolidartObserver { +/// @override +/// void didCreateSignal(ReadonlySignal signal) { +/// print('created: ${signal.identifier.value}'); +/// } +/// @override +/// void didUpdateSignal(ReadonlySignal signal) {} +/// @override +/// void didDisposeSignal(ReadonlySignal signal) {} +/// } +/// +/// SolidartConfig.observers.add(Logger()); +/// ``` +/// {@endtemplate} +abstract class SolidartObserver { + /// {@macro solidart.observer} + const SolidartObserver(); // coverage:ignore-line - @override - int get hashCode => runtimeType.hashCode; -} + /// Called when a signal is created. + void didCreateSignal(ReadonlySignal signal); -/// Error state containing an error and optional stack trace. -@immutable -class ResourceError implements ResourceState { - /// Creates an error state. - const ResourceError( - this.error, { - this.stackTrace, - this.isRefreshing = false, - }); + /// Called when a signal is disposed. + void didDisposeSignal(ReadonlySignal signal); - /// The error object. - final Object error; + /// Called when a signal updates. + void didUpdateSignal(ReadonlySignal signal); +} - /// Optional stack trace. - final StackTrace? stackTrace; +/// A present optional value. +final class Some extends Option { + /// Creates an option that wraps [value]. + const Some(this.value); - /// Whether the resource is refreshing. - final bool isRefreshing; + /// The wrapped value. + final T value; +} - @override - R map({ - required R Function(ResourceReady ready) ready, - required R Function(ResourceError error) error, - required R Function(ResourceLoading loading) loading, - }) { - return error(this); - } +enum _DevToolsEventType { + created, + updated, + disposed, +} - /// Returns a copy with updated fields. - ResourceError copyWith({ - Object? error, - StackTrace? stackTrace, - bool? isRefreshing, +/// Observes [ReadonlySignal] changes with previous and current values. +extension ObserveSignal on ReadonlySignal { + /// Observe the signal and invoke [listener] whenever the value changes. + /// + /// When [fireImmediately] is `true`, the listener runs once on subscription. + /// Returns a disposer that stops the observation. + DisposeObservation observe( + ObserveCallback listener, { + bool fireImmediately = false, }) { - return ResourceError( - error ?? this.error, - stackTrace: stackTrace ?? this.stackTrace, - isRefreshing: isRefreshing ?? this.isRefreshing, + var skipped = false; + final effect = Effect( + () { + value; + if (!fireImmediately && !skipped) { + skipped = true; + return; + } + untracked(() { + listener(untrackedPreviousValue, untrackedValue); + }); + }, + detach: true, ); - } - - @override - String toString() { - return 'ResourceError<$T>(error: $error, stackTrace: $stackTrace, ' - 'refreshing: $isRefreshing)'; - } - @override - bool operator ==(Object other) { - return runtimeType == other.runtimeType && - other is ResourceError && - other.error == error && - other.stackTrace == stackTrace && - other.isRefreshing == isRefreshing; + return effect.dispose; } - - @override - int get hashCode => Object.hash(runtimeType, error, stackTrace, isRefreshing); } /// Convenience accessors for [ResourceState]. @@ -2031,12 +2063,33 @@ class ResourceError implements ResourceState { /// (`asReady`, `asError`), and pattern matching helpers (`when`, `maybeWhen`, /// `maybeMap`). extension ResourceStateExtensions on ResourceState { - /// Whether this state is loading. - bool get isLoading => this is ResourceLoading; + /// Casts to [ResourceError] if possible. + ResourceError? get asError => map( + error: (e) => e, + ready: (_) => null, + loading: (_) => null, + ); + + /// Casts to [ResourceReady] if possible. + ResourceReady? get asReady => map( + ready: (r) => r, + error: (_) => null, + loading: (_) => null, + ); + + /// Returns the error for error state. + Object? get error => map( + error: (r) => r.error, + ready: (_) => null, + loading: (_) => null, + ); /// Whether this state is an error. bool get hasError => this is ResourceError; + /// Whether this state is loading. + bool get isLoading => this is ResourceLoading; + /// Whether this state is ready. bool get isReady => this is ResourceReady; @@ -2047,20 +2100,6 @@ extension ResourceStateExtensions on ResourceState { ResourceLoading() => false, }; - /// Casts to [ResourceReady] if possible. - ResourceReady? get asReady => map( - ready: (r) => r, - error: (_) => null, - loading: (_) => null, - ); - - /// Casts to [ResourceError] if possible. - ResourceError? get asError => map( - error: (e) => e, - ready: (_) => null, - loading: (_) => null, - ); - /// Returns the value for ready state, throws for error state. T? get value => map( ready: (r) => r.value, @@ -2069,99 +2108,62 @@ extension ResourceStateExtensions on ResourceState { loading: (_) => null, ); - /// Returns the error for error state. - Object? get error => map( - error: (r) => r.error, - ready: (_) => null, - loading: (_) => null, - ); - - /// Executes callbacks for each state. - R when({ - required R Function(T data) ready, - required R Function(Object error, StackTrace? stackTrace) error, - required R Function() loading, - }) { - return map( - ready: (r) => ready(r.value), - error: (e) => error(e.error, e.stackTrace), - loading: (_) => loading(), - ); - } - /// Executes callbacks for available handlers, otherwise [orElse]. - R maybeWhen({ + R maybeMap({ required R Function() orElse, - R Function(T data)? ready, - R Function(Object error, StackTrace? stackTrace)? error, - R Function()? loading, + R Function(ResourceReady ready)? ready, + R Function(ResourceError error)? error, + R Function(ResourceLoading loading)? loading, }) { return map( ready: (r) { - if (ready != null) return ready(r.value); + if (ready != null) return ready(r); return orElse(); }, error: (e) { - if (error != null) return error(e.error, e.stackTrace); + if (error != null) return error(e); return orElse(); }, loading: (l) { - if (loading != null) return loading(); + if (loading != null) return loading(l); return orElse(); }, ); } /// Executes callbacks for available handlers, otherwise [orElse]. - R maybeMap({ + R maybeWhen({ required R Function() orElse, - R Function(ResourceReady ready)? ready, - R Function(ResourceError error)? error, - R Function(ResourceLoading loading)? loading, + R Function(T data)? ready, + R Function(Object error, StackTrace? stackTrace)? error, + R Function()? loading, }) { return map( ready: (r) { - if (ready != null) return ready(r); + if (ready != null) return ready(r.value); return orElse(); }, error: (e) { - if (error != null) return error(e); + if (error != null) return error(e.error, e.stackTrace); return orElse(); }, loading: (l) { - if (loading != null) return loading(l); + if (loading != null) return loading(); return orElse(); }, ); } -} - -/// Default [Disposable] implementation using cleanup callbacks. -mixin DisposableMixin implements Disposable { - @internal - /// Registered cleanup callbacks invoked on dispose. - late final cleanups = []; - - @override - bool isDisposed = false; - - @mustCallSuper - @override - void onDispose(VoidCallback callback) { - cleanups.add(callback); - } - @mustCallSuper - @override - void dispose() { - if (isDisposed) return; - isDisposed = true; - try { - for (final callback in cleanups) { - callback(); - } - } finally { - cleanups.clear(); - } + /// Executes callbacks for each state. + R when({ + required R Function(T data) ready, + required R Function(Object error, StackTrace? stackTrace) error, + required R Function() loading, + }) { + return map( + ready: (r) => ready(r.value), + error: (e) => error(e.error, e.stackTrace), + loading: (_) => loading(), + ); } } From 4ae9d1cbf43a1582380e3491b96cc694c347e254 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:09:01 +0800 Subject: [PATCH 113/121] Restore until helpers in v3 --- packages/solidart/CHANGELOG.md | 9 ++-- packages/solidart/lib/solidart.dart | 1 + packages/solidart/lib/src/solidart.dart | 59 +++++++++++++++++++++++++ packages/solidart/test/until_test.dart | 48 ++++++++++++++++++++ 4 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 packages/solidart/test/until_test.dart diff --git a/packages/solidart/CHANGELOG.md b/packages/solidart/CHANGELOG.md index d1366e8e..64832dc1 100644 --- a/packages/solidart/CHANGELOG.md +++ b/packages/solidart/CHANGELOG.md @@ -5,14 +5,13 @@ - **BREAKING**: Signals now use the v3 surface from `solidart.dart`; read-only usage is through `ReadonlySignal`. - **BREAKING**: `Signal.toReadSignal()` renamed to `toReadonly()`. - **REMOVED**: `SignalBase`, `ReadSignal`/`ReadableSignal`, `Signal.setValue`/`updateValue`, `Signal.hasValue`/`hasPreviousValue`, `Signal.listenerCount`, and `ToggleBoolSignal`. -- **REMOVED**: `Signal.until` (and related `FutureOrThenExtension`). - **ADDED**: `ReadonlySignal` and `ObserveSignal.observe` for any `ReadonlySignal` (signals, computeds, resources). +- **ADDED**: `ReadonlySignal.until` for awaiting signal conditions (signals, computeds, resources). - **ADDED**: `LazySignal` type with `Signal.lazy` returning it and `isInitialized` support. ### Computed - **BREAKING**: Computeds are part of the v3 surface and use the `ReadonlySignal` API. -- **REMOVED**: `Computed.until` (and related `FutureOrThenExtension`). ### Effect @@ -21,8 +20,8 @@ ### Resource - **BREAKING**: `ResourceExtensions` renamed to `ResourceStateExtensions` (`on`/`maybeOn` removed). -- **REMOVED**: `Resource.update`, `Resource.until`, and `Resource.untilReady`. -- **ADDED**: public `Resource.resolve()`. +- **REMOVED**: `Resource.update`. +- **ADDED**: public `Resource.resolve()` and `Resource.untilReady()`. ### Core / Shared @@ -30,7 +29,7 @@ - **BREAKING**: `SolidartConfig.equals` is removed; update skipping now uses the per-instance `equals` comparator (defaults to `identical`). - **BREAKING**: Default auto-disposal is now opt-in; `SolidartConfig.autoDispose` defaults to `false`. - **BREAKING**: Named identifiers now live on `identifier`/`identifier.name`, and `SolidartObserver` receives `ReadonlySignal` instances. -- **REMOVED**: `Debouncer`/`DebounceOperation` and Solidart exception types (`SolidartException`, `SolidartReactionException`, `SolidartCaughtException`). +- **REMOVED**: `Debouncer`/`DebounceOperation`, `FutureOrThenExtension`, and Solidart exception types (`SolidartException`, `SolidartReactionException`, `SolidartCaughtException`). - **ADDED**: `Disposable`/`DisposableMixin`, `Identifier`, `Configuration`, `Option`/`Some`/`None`. - **REFACTOR**: Collection signals are reimplemented on the v3 core with copy-on-write updates and standard `ListMixin`/`SetMixin`/`MapMixin` APIs. - **CHORE**: Upgrade `alien_signals` to `^2.1.1` and add `fake_async` for tests. diff --git a/packages/solidart/lib/solidart.dart b/packages/solidart/lib/solidart.dart index 8c448e32..62284eb0 100644 --- a/packages/solidart/lib/solidart.dart +++ b/packages/solidart/lib/solidart.dart @@ -19,6 +19,7 @@ export 'src/solidart.dart' Signal, SolidartConfig, SolidartObserver, + UntilSignal, ValueComparator, batch, untracked; diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index 691f24f4..9502eee2 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -1284,6 +1284,14 @@ class Resource extends Signal> { } } + /// Returns a future that completes with the value when the resource is ready. + /// + /// If the resource is already ready, it completes immediately. + Future untilReady() async { + final state = await Future.value(until((value) => value.isReady)); + return state.asReady!.value; + } + /// Resolves the resource if it has not been resolved yet. /// /// Multiple calls are coalesced into a single in-flight resolve. @@ -2057,6 +2065,57 @@ extension ObserveSignal on ReadonlySignal { } } +/// Waits until a signal satisfies [condition]. +extension UntilSignal on ReadonlySignal { + /// Returns a future that completes when [condition] becomes true. + /// + /// If [condition] is already true, this returns the current value + /// immediately. + /// + /// When [timeout] is provided, the returned future completes with a + /// [TimeoutException] if the condition is not met in time. + FutureOr until( + bool Function(T value) condition, { + Duration? timeout, + }) { + if (condition(value)) return value; + + final completer = Completer(); + Timer? timer; + late final Effect effect; + + void dispose() { + effect.dispose(); + timer?.cancel(); + timer = null; + } + + effect = Effect( + () { + final current = value; + if (!condition(current)) return; + dispose(); + if (!completer.isCompleted) { + completer.complete(current); + } + }, + autoDispose: false, + ); + + onDispose(dispose); + + if (timeout != null) { + timer = Timer(timeout, () { + if (completer.isCompleted) return; + dispose(); + completer.completeError(TimeoutException(null, timeout)); + }); + } + + return completer.future; + } +} + /// Convenience accessors for [ResourceState]. /// /// Includes common flags (`isLoading`, `isReady`, `hasError`), casting helpers diff --git a/packages/solidart/test/until_test.dart b/packages/solidart/test/until_test.dart new file mode 100644 index 00000000..71b6df1e --- /dev/null +++ b/packages/solidart/test/until_test.dart @@ -0,0 +1,48 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:solidart/solidart.dart'; +import 'package:test/test.dart'; + +void main() { + group('until', () { + test('returns current value when condition already true', () async { + final signal = Signal(0); + + final result = signal.until((value) => value == 0); + + expect(await Future.value(result), 0); + }); + + test('completes when condition becomes true', () async { + final signal = Signal(0); + + final future = Future.value(signal.until((value) => value == 2)); + signal.value = 2; + + expect(await future, 2); + }); + + test('completes with TimeoutException when timed out', () { + fakeAsync((async) { + final signal = Signal(0); + + final future = Future.value( + signal.until( + (value) => value == 1, + timeout: const Duration(seconds: 1), + ), + ); + + var completed = false; + expectLater(future, throwsA(isA())) + .whenComplete(() => completed = true); + + async.elapse(const Duration(seconds: 1)); + async.flushMicrotasks(); + + expect(completed, isTrue); + }); + }); + }); +} From 9cfcca6a396c1ba2a221a5fbe00135ee22a2d2b0 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 31 Jan 2026 01:35:30 +0800 Subject: [PATCH 114/121] Split solidart.dart into part files --- packages/solidart/lib/src/core/batch.dart | 25 + packages/solidart/lib/src/core/computed.dart | 124 + packages/solidart/lib/src/core/config.dart | 58 + .../solidart/lib/src/core/configuration.dart | 10 + packages/solidart/lib/src/core/devtools.dart | 183 ++ .../solidart/lib/src/core/disposable.dart | 91 + packages/solidart/lib/src/core/effect.dart | 103 + .../solidart/lib/src/core/identifier.dart | 15 + .../solidart/lib/src/core/lazy_signal.dart | 53 + .../solidart/lib/src/core/list_signal.dart | 204 ++ .../solidart/lib/src/core/map_signal.dart | 193 ++ packages/solidart/lib/src/core/observer.dart | 34 + packages/solidart/lib/src/core/option.dart | 37 + .../lib/src/core/readonly_signal.dart | 32 + .../solidart/lib/src/core/set_signal.dart | 130 + packages/solidart/lib/src/core/signal.dart | 186 ++ .../lib/src/core/signal_configuration.dart | 18 + packages/solidart/lib/src/core/typedefs.dart | 19 + packages/solidart/lib/src/core/untracked.dart | 22 + .../lib/src/extensions/observe_signal.dart | 30 + .../solidart/lib/src/extensions/until.dart | 52 + .../solidart/lib/src/resources/resource.dart | 325 +++ .../lib/src/resources/resource_state.dart | 187 ++ .../resources/resource_state_extensions.dart | 112 + packages/solidart/lib/src/solidart.dart | 2242 +---------------- packages/solidart/test/until_test.dart | 13 +- 26 files changed, 2275 insertions(+), 2223 deletions(-) create mode 100644 packages/solidart/lib/src/core/batch.dart create mode 100644 packages/solidart/lib/src/core/computed.dart create mode 100644 packages/solidart/lib/src/core/config.dart create mode 100644 packages/solidart/lib/src/core/configuration.dart create mode 100644 packages/solidart/lib/src/core/devtools.dart create mode 100644 packages/solidart/lib/src/core/disposable.dart create mode 100644 packages/solidart/lib/src/core/effect.dart create mode 100644 packages/solidart/lib/src/core/identifier.dart create mode 100644 packages/solidart/lib/src/core/lazy_signal.dart create mode 100644 packages/solidart/lib/src/core/list_signal.dart create mode 100644 packages/solidart/lib/src/core/map_signal.dart create mode 100644 packages/solidart/lib/src/core/observer.dart create mode 100644 packages/solidart/lib/src/core/option.dart create mode 100644 packages/solidart/lib/src/core/readonly_signal.dart create mode 100644 packages/solidart/lib/src/core/set_signal.dart create mode 100644 packages/solidart/lib/src/core/signal.dart create mode 100644 packages/solidart/lib/src/core/signal_configuration.dart create mode 100644 packages/solidart/lib/src/core/typedefs.dart create mode 100644 packages/solidart/lib/src/core/untracked.dart create mode 100644 packages/solidart/lib/src/extensions/observe_signal.dart create mode 100644 packages/solidart/lib/src/extensions/until.dart create mode 100644 packages/solidart/lib/src/resources/resource.dart create mode 100644 packages/solidart/lib/src/resources/resource_state.dart create mode 100644 packages/solidart/lib/src/resources/resource_state_extensions.dart diff --git a/packages/solidart/lib/src/core/batch.dart b/packages/solidart/lib/src/core/batch.dart new file mode 100644 index 00000000..bc2aa7ed --- /dev/null +++ b/packages/solidart/lib/src/core/batch.dart @@ -0,0 +1,25 @@ +part of '../solidart.dart'; + +/// Batches signal updates and flushes once at the end. +/// +/// Nested batches are supported; the final flush happens when the outermost +/// batch completes. +/// +/// ```dart +/// final a = Signal(1); +/// final b = Signal(2); +/// Effect(() => print('sum: ${a.value + b.value}')); +/// +/// batch(() { +/// a.value = 3; +/// b.value = 4; +/// }); +/// ``` +T batch(T Function() fn) { + preset.startBatch(); + try { + return fn(); + } finally { + preset.endBatch(); + } +} diff --git a/packages/solidart/lib/src/core/computed.dart b/packages/solidart/lib/src/core/computed.dart new file mode 100644 index 00000000..a852801f --- /dev/null +++ b/packages/solidart/lib/src/core/computed.dart @@ -0,0 +1,124 @@ +part of '../solidart.dart'; + +/// {@template solidart.computed} +/// # Computed +/// A computed signal derives its value from other signals. It is read-only +/// and recalculates whenever any dependency changes. +/// +/// Use `Computed` to derive state or combine multiple signals: +/// ```dart +/// final firstName = Signal('Josh'); +/// final lastName = Signal('Brown'); +/// final fullName = Computed(() => '${firstName.value} ${lastName.value}'); +/// ``` +/// +/// Computeds only notify when the derived value changes. You can customize +/// equality via [equals] to skip updates for equivalent values. +/// +/// Like signals, computeds can track previous values once they have been read. +/// {@endtemplate} +class Computed extends preset.ComputedNode + with DisposableMixin + implements ReadonlySignal { + /// {@macro solidart.computed} + Computed( + ValueGetter getter, { + this.equals = identical, + bool? autoDispose, + String? name, + bool? trackPreviousValue, + bool? trackInDevTools, + }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, + trackPreviousValue = + trackPreviousValue ?? SolidartConfig.trackPreviousValue, + trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, + identifier = ._(name), + super(flags: system.ReactiveFlags.none, getter: (_) => getter()) { + _notifySignalCreation(this); + } + + @override + final bool autoDispose; + + @override + final Identifier identifier; + + @override + final ValueComparator equals; + + @override + final bool trackPreviousValue; + + @override + final bool trackInDevTools; + + Option _previousValue = const None(); + + @override + T? get previousValue { + if (!trackPreviousValue) return null; + value; + return _previousValue.safeUnwrap(); + } + + @override + T? get untrackedPreviousValue { + if (!trackPreviousValue) return null; + return _previousValue.safeUnwrap(); + } + + @override + T get untrackedValue { + if (currentValue != null || null is T) { + return currentValue as T; + } + return untracked(() => value); + } + + @override + T get value { + assert(!isDisposed, 'Computed is disposed'); + return get(); + } + + @override + T call() => value; + + @override + bool didUpdate() { + preset.cycle++; + depsTail = null; + flags = system.ReactiveFlags.mutable | system.ReactiveFlags.recursedCheck; + + final prevSub = preset.setActiveSub(this); + try { + final previousValue = currentValue; + final pendingValue = getter(previousValue); + if (equals(previousValue, pendingValue)) { + return false; + } + + if (trackPreviousValue && (previousValue is T)) { + _previousValue = Some(previousValue); + } + + currentValue = pendingValue; + _notifySignalUpdate(this); + return true; + } finally { + preset.activeSub = prevSub; + flags &= ~system.ReactiveFlags.recursedCheck; + preset.purgeDeps(this); + } + } + + @override + void dispose() { + if (isDisposed) return; + Disposable.unlinkDeps(this); + Disposable.unlinkSubs(this); + preset.stop(this); + super.dispose(); + _notifySignalDisposal(this); + } +} diff --git a/packages/solidart/lib/src/core/config.dart b/packages/solidart/lib/src/core/config.dart new file mode 100644 index 00000000..d93aaa46 --- /dev/null +++ b/packages/solidart/lib/src/core/config.dart @@ -0,0 +1,58 @@ +part of '../solidart.dart'; + +/// {@template solidart.config} +/// Global configuration for v3 reactive primitives. +/// +/// These flags provide defaults for newly created signals/effects/resources. +/// You can override them per-instance via constructor parameters. +/// {@endtemplate} +final class SolidartConfig { + const SolidartConfig._(); // coverage:ignore-line + + /// Whether nodes auto-dispose when they lose all subscribers. + /// + /// When enabled, signals/computeds/effects may dispose themselves once + /// nothing depends on them. + static bool autoDispose = false; + + /// Whether nested effects detach from parent subscriptions. + /// + /// When `true`, inner effects do not become dependencies of their parent + /// effect unless explicitly linked. + static bool detachEffects = false; + + /// Whether to track previous values by default. + /// + /// Previous values are captured only after a signal has been read at least + /// once. + static bool trackPreviousValue = true; + + /// Whether to keep values while refreshing resources. + /// + /// When `true`, a refresh marks the state as `isRefreshing` instead of + /// replacing it with `loading`. + static bool useRefreshing = true; + + /// Whether DevTools tracking is enabled. + /// + /// Signals only emit DevTools events when both this flag and + /// `trackInDevTools` are `true`. + static bool devToolsEnabled = false; + + /// Whether to assert that SignalBuilder has at least one dependency during + /// its build. Defaults to true. + /// + /// If you set this to false, you must ensure that the SignalBuilder has at + /// least one dependency, otherwise it won't rebuild when the signals change. + /// + /// The ability to disable this assertion is provided for advanced use cases + /// where you might have a SignalBuilder that builds something based on + /// disposed signals where you might be interested in their latest values. + static bool assertSignalBuilderWithoutDependencies = true; + + /// Registered observers for signal lifecycle events. + /// + /// Observers are notified only when `trackInDevTools` is enabled for the + /// signal instance. + static final observers = []; +} diff --git a/packages/solidart/lib/src/core/configuration.dart b/packages/solidart/lib/src/core/configuration.dart new file mode 100644 index 00000000..a649b585 --- /dev/null +++ b/packages/solidart/lib/src/core/configuration.dart @@ -0,0 +1,10 @@ +part of '../solidart.dart'; + +/// Base configuration shared by reactive primitives. +abstract interface class Configuration { + /// Whether the instance auto-disposes. + bool get autoDispose; + + /// Identifier for the instance. + Identifier get identifier; +} diff --git a/packages/solidart/lib/src/core/devtools.dart b/packages/solidart/lib/src/core/devtools.dart new file mode 100644 index 00000000..09d09e04 --- /dev/null +++ b/packages/solidart/lib/src/core/devtools.dart @@ -0,0 +1,183 @@ +part of '../solidart.dart'; + +// coverage:ignore-start +Object? _computedValue(Computed signal) { + final current = signal.currentValue; + if (current != null || null is T) { + return current; + } + return null; +} +// coverage:ignore-end + +// coverage:ignore-start +bool _hasPreviousValue(ReadonlySignal signal) { + if (!signal.trackPreviousValue) return false; + if (signal is Signal) { + return signal._previousValue is Some; + } + if (signal is Computed) { + return signal._previousValue is Some; + } + return false; +} +// coverage:ignore-end + +// coverage:ignore-start +int _listenerCount(system.ReactiveNode node) { + var count = 0; + var link = node.subs; + while (link != null) { + count++; + link = link.nextSub; + } + return count; +} +// coverage:ignore-end + +void _notifyDevToolsAboutSignal( + ReadonlySignal signal, { + required _DevToolsEventType eventType, +}) { + if (!SolidartConfig.devToolsEnabled || !signal.trackInDevTools) return; + final eventName = 'ext.solidart.signal.${eventType.name}'; + final value = _signalValue(signal); + final previousValue = _signalPreviousValue(signal); + final hasPreviousValue = _hasPreviousValue(signal); + + dev.postEvent(eventName, { + '_id': signal.identifier.value.toString(), + 'name': signal.identifier.name, + 'value': _toJson(value), + 'previousValue': _toJson(previousValue), + 'hasPreviousValue': hasPreviousValue, + 'type': _signalType(signal), + 'valueType': value.runtimeType.toString(), + if (hasPreviousValue) + 'previousValueType': previousValue.runtimeType.toString(), + 'disposed': signal.isDisposed, + 'autoDispose': signal.autoDispose, + 'listenerCount': _listenerCount(signal), + 'lastUpdate': DateTime.now().toIso8601String(), + }); +} + +void _notifySignalCreation(ReadonlySignal signal) { + if (signal.trackInDevTools && SolidartConfig.observers.isNotEmpty) { + for (final observer in SolidartConfig.observers) { + observer.didCreateSignal(signal); + } + } + _notifyDevToolsAboutSignal(signal, eventType: _DevToolsEventType.created); +} + +void _notifySignalDisposal(ReadonlySignal signal) { + if (signal.trackInDevTools && SolidartConfig.observers.isNotEmpty) { + for (final observer in SolidartConfig.observers) { + observer.didDisposeSignal(signal); + } + } + _notifyDevToolsAboutSignal(signal, eventType: _DevToolsEventType.disposed); +} + +void _notifySignalUpdate(ReadonlySignal signal) { + if (signal.trackInDevTools && SolidartConfig.observers.isNotEmpty) { + for (final observer in SolidartConfig.observers) { + observer.didUpdateSignal(signal); + } + } + _notifyDevToolsAboutSignal(signal, eventType: _DevToolsEventType.updated); +} + +Object? _resourceValue(ResourceState? state) { + if (state == null) return null; + return state.maybeWhen(orElse: () => null, ready: (value) => value); +} + +Object? _signalPreviousValue(ReadonlySignal signal) { + if (signal is Resource) { + return _resourceValue(signal.untrackedPreviousState); + } + return signal.untrackedPreviousValue; +} + +String _signalType(ReadonlySignal signal) => switch (signal) { + Resource() => 'Resource', + ListSignal() => 'ListSignal', + MapSignal() => 'MapSignal', + SetSignal() => 'SetSignal', + LazySignal() => 'LazySignal', + Signal() => 'Signal', + Computed() => 'Computed', + _ => 'ReadonlySignal', +}; + +Object? _signalValue(ReadonlySignal signal) { + if (signal is Resource) { + return _resourceValue(signal.untrackedState); + } + if (signal is LazySignal && !signal.isInitialized) { + return null; + } + if (signal is Computed) { + return _computedValue(signal); + } + return signal.untrackedValue; +} + +// coverage:ignore-start +dynamic _toJson(Object? obj, [int depth = 0, Set? visited]) { + const maxDepth = 20; + if (depth > maxDepth) return ''; + try { + return jsonEncode(obj); + } catch (_) { + if (obj is List) { + final visitedSet = visited ?? Set.identity(); + if (!visitedSet.add(obj)) return ''; + try { + return obj + .map((e) => _toJson(e, depth + 1, visitedSet)) + .toList() + .toString(); + } finally { + visitedSet.remove(obj); + } + } + if (obj is Set) { + final visitedSet = visited ?? Set.identity(); + if (!visitedSet.add(obj)) return ''; + try { + return obj + .map((e) => _toJson(e, depth + 1, visitedSet)) + .toList() + .toString(); + } finally { + visitedSet.remove(obj); + } + } + if (obj is Map) { + final visitedSet = visited ?? Set.identity(); + if (!visitedSet.add(obj)) return ''; + try { + return obj + .map( + (key, value) => MapEntry( + _toJson(key, depth + 1, visitedSet), + _toJson(value, depth + 1, visitedSet), + ), + ) + .toString(); + } finally { + visitedSet.remove(obj); + } + } + return jsonEncode(obj.toString()); + } +} + +enum _DevToolsEventType { + created, + updated, + disposed, +} diff --git a/packages/solidart/lib/src/core/disposable.dart b/packages/solidart/lib/src/core/disposable.dart new file mode 100644 index 00000000..3a0a3c82 --- /dev/null +++ b/packages/solidart/lib/src/core/disposable.dart @@ -0,0 +1,91 @@ +part of '../solidart.dart'; + +/// Disposable behavior for reactive primitives. +abstract class Disposable { + /// Whether this instance has been disposed. + bool get isDisposed; + + /// Disposes the instance. + void dispose(); + + /// Registers a callback to run on dispose. + void onDispose(VoidCallback callback); + + /// Whether the node can be auto-disposed. + static bool canAutoDispose(system.ReactiveNode node) => switch (node) { + Disposable(:final isDisposed) && Configuration(:final autoDispose) => + !isDisposed && autoDispose, + _ => false, + }; + + /// Unlinks dependencies from a node. + /// + /// This is used to break reactive links during disposal. + static void unlinkDeps(system.ReactiveNode node) { + var link = node.deps; + while (link != null) { + final next = link.nextDep; + final dep = link.dep; + final isLastSub = + identical(dep.subs, link) && + link.prevSub == null && + link.nextSub == null; + if (canAutoDispose(dep) && isLastSub) { + (dep as Disposable).dispose(); + } else { + preset.unlink(link, node); + if (canAutoDispose(dep) && dep.subs == null) { + (dep as Disposable).dispose(); + } + } + link = next; + } + } + + /// Unlinks subscribers from a node. + /// + /// This is used to break reactive links during disposal. + static void unlinkSubs(system.ReactiveNode node) { + var link = node.subs; + while (link != null) { + final next = link.nextSub; + final sub = link.sub; + preset.unlink(link, sub); + if (canAutoDispose(sub) && sub.deps == null) { + (sub as Disposable).dispose(); + } + link = next; + } + } +} + +/// Default [Disposable] implementation using cleanup callbacks. +mixin DisposableMixin implements Disposable { + @internal + /// Registered cleanup callbacks invoked on dispose. + late final cleanups = []; + + @override + bool isDisposed = false; + + @mustCallSuper + @override + void dispose() { + if (isDisposed) return; + isDisposed = true; + try { + for (final callback in cleanups) { + callback(); + } + } finally { + cleanups.clear(); + } + } + + @mustCallSuper + @override + void onDispose(VoidCallback callback) { + cleanups.add(callback); + } +} +// coverage:ignore-end diff --git a/packages/solidart/lib/src/core/effect.dart b/packages/solidart/lib/src/core/effect.dart new file mode 100644 index 00000000..84b2e7fc --- /dev/null +++ b/packages/solidart/lib/src/core/effect.dart @@ -0,0 +1,103 @@ +part of '../solidart.dart'; + +/// {@template solidart.effect} +/// # Effect +/// Effects run a side-effect whenever any signal they read changes. +/// +/// ```dart +/// final counter = Signal(0); +/// Effect(() { +/// print('count: ${counter.value}'); +/// }); +/// ``` +/// +/// Effects run once immediately when created. If you need a lazy effect, +/// create it with [Effect.manual] and call [run] yourself. +/// +/// Nested effects can either attach to their parent (default) or detach by +/// passing `detach: true` or by enabling [SolidartConfig.detachEffects]. +/// +/// Call [dispose] to stop the effect and release dependencies. +/// {@endtemplate} +class Effect extends preset.EffectNode + with DisposableMixin + implements Disposable, Configuration { + /// {@macro solidart.effect} + factory Effect( + VoidCallback callback, { + bool? autoDispose, + String? name, + bool? detach, + }) => .manual( + callback, + autoDispose: autoDispose, + name: name, + detach: detach, + )..run(); + + /// Creates an effect without running it. + /// + /// Use this when you need to *delay* the first run or decide *when* the + /// effect should start tracking dependencies. Common cases: + /// - you must create several signals first and only then start the effect + /// - you want to control the first run in tests + /// - you need conditional startup (e.g. after async setup) + /// + /// The effect will not track anything until you call [run]: + /// ```dart + /// final count = Signal(0); + /// final effect = Effect.manual(() { + /// print('count: ${count.value}'); + /// }); + /// + /// count.value = 1; // no output yet + /// effect.run(); // prints "count: 1" and starts tracking + /// ``` + /// + /// If you want the effect to run immediately, use the [Effect] factory. + Effect.manual( + VoidCallback callback, { + bool? autoDispose, + String? name, + bool? detach, + }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, + identifier = ._(name), + detach = detach ?? SolidartConfig.detachEffects, + super( + fn: callback, + flags: + system.ReactiveFlags.watching | system.ReactiveFlags.recursedCheck, + ); + + @override + final bool autoDispose; + + @override + final Identifier identifier; + + /// Whether this effect detaches from parent subscriptions. + final bool detach; + + @override + void dispose() { + if (isDisposed) return; + Disposable.unlinkDeps(this); + preset.stop(this); + super.dispose(); + } + + /// Runs the effect and tracks dependencies. + void run() { + final prevSub = preset.setActiveSub(this); + if (!detach && prevSub != null) { + preset.link(this, prevSub, 0); + } + + try { + fn(); + } finally { + preset.activeSub = prevSub; + flags &= ~system.ReactiveFlags.recursedCheck; + } + } +} diff --git a/packages/solidart/lib/src/core/identifier.dart b/packages/solidart/lib/src/core/identifier.dart new file mode 100644 index 00000000..cc730f2b --- /dev/null +++ b/packages/solidart/lib/src/core/identifier.dart @@ -0,0 +1,15 @@ +part of '../solidart.dart'; + +/// A unique identifier with an optional name. +/// +/// Used by DevTools and diagnostics to track instances. +class Identifier { + Identifier._(this.name) : value = _counter++; + static int _counter = 0; + + /// Optional human-readable name. + final String? name; + + /// Unique numeric identifier. + final int value; +} diff --git a/packages/solidart/lib/src/core/lazy_signal.dart b/packages/solidart/lib/src/core/lazy_signal.dart new file mode 100644 index 00000000..0855ff5f --- /dev/null +++ b/packages/solidart/lib/src/core/lazy_signal.dart @@ -0,0 +1,53 @@ +part of '../solidart.dart'; + +/// A signal that starts uninitialized until first set. +/// +/// This is the concrete type behind [Signal.lazy]. Reading [value] before the +/// first assignment throws [StateError]. +/// +/// ```dart +/// final lazy = Signal.lazy(); +/// lazy.value = 1; +/// print(lazy.value); // 1 +/// ``` +class LazySignal extends Signal { + /// Creates a lazy signal. + LazySignal({ + String? name, + bool? autoDispose, + ValueComparator equals = identical, + bool? trackPreviousValue, + bool? trackInDevTools, + }) : super._internal( + const None(), + name: name, + autoDispose: autoDispose, + equals: equals, + trackPreviousValue: trackPreviousValue, + trackInDevTools: trackInDevTools, + ); + + @override + bool get isInitialized => currentValue is Some; + + @override + T get value { + if (isInitialized || pendingValue is Some) { + return super.value; + } + throw StateError( + 'LazySignal is not initialized, please call `.value = ` first.', + ); + } + + @override + bool didUpdate() { + if (!isInitialized) { + flags = system.ReactiveFlags.mutable; + currentValue = pendingValue; + return true; + } + + return super.didUpdate(); + } +} diff --git a/packages/solidart/lib/src/core/list_signal.dart b/packages/solidart/lib/src/core/list_signal.dart new file mode 100644 index 00000000..8f0a0e3f --- /dev/null +++ b/packages/solidart/lib/src/core/list_signal.dart @@ -0,0 +1,204 @@ +part of '../solidart.dart'; + +/// {@template solidart.list-signal} +/// A reactive wrapper around a [List] that copies on write. +/// +/// Mutations create a new list instance so that updates are observable: +/// ```dart +/// final list = ListSignal([1, 2]); +/// Effect(() => print(list.length)); +/// list.add(3); // triggers effect +/// ``` +/// +/// Reads (like `length` or index access) establish dependencies; the usual +/// list API is supported. +/// {@endtemplate} +class ListSignal extends Signal> with ListMixin { + /// {@macro solidart.list-signal} + /// + /// Creates a reactive list with the provided initial values. + ListSignal( + Iterable initialValue, { + bool? autoDispose, + String? name, + ValueComparator> equals = identical, + bool? trackPreviousValue, + bool? trackInDevTools, + }) : super( + List.of(initialValue), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: trackPreviousValue, + trackInDevTools: trackInDevTools, + ); + + @override + int get length => value.length; + + @override + set length(int newLength) { + final current = untrackedValue; + if (current.length == newLength) return; + value = List.of(current)..length = newLength; + } + + @override + E operator [](int index) => value[index]; + + @override + void operator []=(int index, E element) { + final current = untrackedValue; + if (current[index] == element) return; + final next = List.of(current); + next[index] = element; + value = next; + } + + @override + void add(E element) { + final next = _copy()..add(element); + value = next; + } + + @override + void addAll(Iterable iterable) { + if (iterable.isEmpty) return; + final next = _copy()..addAll(iterable); + value = next; + } + + @override + List cast() => ListSignal(untrackedValue.cast()); + + @override + void clear() { + if (untrackedValue.isEmpty) return; + value = []; + } + + @override + void fillRange(int start, int end, [E? fill]) { + if (end <= start) return; + final next = _copy()..fillRange(start, end, fill); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + + @override + void insert(int index, E element) { + final next = _copy()..insert(index, element); + value = next; + } + + @override + void insertAll(int index, Iterable iterable) { + if (iterable.isEmpty) return; + final next = _copy()..insertAll(index, iterable); + value = next; + } + + @override + bool remove(Object? element) { + final current = untrackedValue; + final index = current.indexWhere((value) => value == element); + if (index == -1) return false; + final next = List.of(current)..removeAt(index); + value = next; + return true; + } + + @override + E removeAt(int index) { + final current = untrackedValue; + final removed = current[index]; + final next = List.of(current)..removeAt(index); + value = next; + return removed; + } + + @override + E removeLast() { + final current = untrackedValue; + final removed = current.last; + final next = List.of(current)..removeLast(); + value = next; + return removed; + } + + @override + void removeRange(int start, int end) { + if (end <= start) return; + final next = _copy()..removeRange(start, end); + value = next; + } + + @override + void removeWhere(bool Function(E element) test) { + final current = untrackedValue; + final next = List.of(current)..removeWhere(test); + if (next.length == current.length) return; + value = next; + } + + @override + void replaceRange(int start, int end, Iterable newContents) { + final next = _copy()..replaceRange(start, end, newContents); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + + @override + void retainWhere(bool Function(E element) test) { + final current = untrackedValue; + final next = List.of(current)..retainWhere(test); + if (next.length == current.length) return; + value = next; + } + + @override + void setAll(int index, Iterable iterable) { + final next = _copy()..setAll(index, iterable); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + + @override + void setRange(int start, int end, Iterable iterable, [int skipCount = 0]) { + final next = _copy()..setRange(start, end, iterable, skipCount); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + + @override + void shuffle([Random? random]) { + if (untrackedValue.length < 2) return; + final next = _copy()..shuffle(random); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + + @override + void sort([int Function(E a, E b)? compare]) { + if (untrackedValue.length < 2) return; + final next = _copy()..sort(compare); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + + @override + String toString() => + 'ListSignal<$E>(value: $untrackedValue, ' + 'previousValue: $untrackedPreviousValue)'; + + List _copy() => List.of(untrackedValue); + + bool _listEquals(List a, List b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} diff --git a/packages/solidart/lib/src/core/map_signal.dart b/packages/solidart/lib/src/core/map_signal.dart new file mode 100644 index 00000000..dd16076b --- /dev/null +++ b/packages/solidart/lib/src/core/map_signal.dart @@ -0,0 +1,193 @@ +part of '../solidart.dart'; + +/// {@template solidart.map-signal} +/// A reactive wrapper around a [Map] that copies on write. +/// +/// Mutations create a new map instance so that updates are observable: +/// ```dart +/// final map = MapSignal({'a': 1}); +/// Effect(() => print(map['a'])); +/// map['a'] = 2; // triggers effect +/// ``` +/// +/// Reads (like `[]`, `keys`, or `length`) establish dependencies. +/// {@endtemplate} +class MapSignal extends Signal> with MapMixin { + /// {@macro solidart.map-signal} + /// + /// Creates a reactive map with the provided initial values. + MapSignal( + Map initialValue, { + bool? autoDispose, + String? name, + ValueComparator> equals = identical, + bool? trackPreviousValue, + bool? trackInDevTools, + }) : super( + Map.of(initialValue), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: trackPreviousValue, + trackInDevTools: trackInDevTools, + ); + + @override + bool get isEmpty { + value; + return untrackedValue.isEmpty; + } + + @override + bool get isNotEmpty { + value; + return untrackedValue.isNotEmpty; + } + + @override + Iterable get keys { + value; + return untrackedValue.keys; + } + + @override + int get length { + value; + return untrackedValue.length; + } + + @override + V? operator [](Object? key) { + value; + return untrackedValue[key]; + } + + @override + void operator []=(K key, V value) { + final current = untrackedValue; + final existing = current[key]; + if (current.containsKey(key) && existing == value) return; + final next = _copy(); + next[key] = value; + this.value = next; + } + + @override + void addAll(Map other) { + if (other.isEmpty) return; + final current = untrackedValue; + final next = _copy()..addAll(other); + if (_mapEquals(next, current)) return; + value = next; + } + + @override + Map cast() => + MapSignal(untrackedValue.cast()); + + @override + void clear() { + if (untrackedValue.isEmpty) return; + value = {}; + } + + @override + bool containsKey(Object? key) { + value; + return untrackedValue.containsKey(key); + } + + @override + bool containsValue(Object? value) { + this.value; + return untrackedValue.containsValue(value); + } + + @override + V putIfAbsent(K key, V Function() ifAbsent) { + final current = untrackedValue; + if (current.containsKey(key)) { + return current[key] as V; + } + final next = _copy(); + final value = ifAbsent(); + next[key] = value; + this.value = next; + return value; + } + + @override + V? remove(Object? key) { + final current = untrackedValue; + if (!current.containsKey(key)) return null; + final next = _copy(); + final removed = next.remove(key); + value = next; + return removed; + } + + @override + void removeWhere(bool Function(K key, V value) test) { + final current = untrackedValue; + if (current.isEmpty) return; + final next = _copy()..removeWhere(test); + if (next.length == current.length) return; + value = next; + } + + @override + String toString() => + 'MapSignal<$K, $V>(value: $untrackedValue, ' + 'previousValue: $untrackedPreviousValue)'; + + @override + V update( + K key, + V Function(V value) update, { + V Function()? ifAbsent, + }) { + final current = untrackedValue; + if (!current.containsKey(key)) { + if (ifAbsent == null) { + throw ArgumentError.value(key, 'key', 'Key not in map.'); + } + final next = _copy(); + final value = ifAbsent(); + next[key] = value; + this.value = next; + return value; + } + + final next = _copy(); + final value = update(next[key] as V); + next[key] = value; + this.value = next; + return value; + } + + @override + void updateAll(V Function(K key, V value) update) { + final current = untrackedValue; + if (current.isEmpty) return; + final next = _copy()..updateAll(update); + if (next.length == current.length && + next.keys.every((key) { + return current.containsKey(key) && current[key] == next[key]; + })) { + return; + } + value = next; + } + + Map _copy() => Map.of(untrackedValue); + + bool _mapEquals(Map a, Map b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (final entry in a.entries) { + if (!b.containsKey(entry.key)) return false; + if (b[entry.key] != entry.value) return false; + } + return true; + } +} diff --git a/packages/solidart/lib/src/core/observer.dart b/packages/solidart/lib/src/core/observer.dart new file mode 100644 index 00000000..c80272ea --- /dev/null +++ b/packages/solidart/lib/src/core/observer.dart @@ -0,0 +1,34 @@ +part of '../solidart.dart'; + +/// {@template solidart.observer} +/// Observer for signal lifecycle events. +/// +/// Use this for logging or instrumentation without depending on DevTools: +/// ```dart +/// class Logger extends SolidartObserver { +/// @override +/// void didCreateSignal(ReadonlySignal signal) { +/// print('created: ${signal.identifier.value}'); +/// } +/// @override +/// void didUpdateSignal(ReadonlySignal signal) {} +/// @override +/// void didDisposeSignal(ReadonlySignal signal) {} +/// } +/// +/// SolidartConfig.observers.add(Logger()); +/// ``` +/// {@endtemplate} +abstract class SolidartObserver { + /// {@macro solidart.observer} + const SolidartObserver(); // coverage:ignore-line + + /// Called when a signal is created. + void didCreateSignal(ReadonlySignal signal); + + /// Called when a signal is disposed. + void didDisposeSignal(ReadonlySignal signal); + + /// Called when a signal updates. + void didUpdateSignal(ReadonlySignal signal); +} diff --git a/packages/solidart/lib/src/core/option.dart b/packages/solidart/lib/src/core/option.dart new file mode 100644 index 00000000..2faafe3f --- /dev/null +++ b/packages/solidart/lib/src/core/option.dart @@ -0,0 +1,37 @@ +part of '../solidart.dart'; + +/// An absent optional value. +final class None extends Option { + /// Creates an option with no value. + const None(); +} + +/// An optional value container. +/// +/// Use [Some] to represent presence and [None] to represent absence without +/// relying on `null`. +sealed class Option { + /// Base constructor for option values. + const Option(); + + /// Returns the contained value or `null` if this is [None]. + T? safeUnwrap() => switch (this) { + Some(:final value) => value, + _ => null, + }; + + /// Returns the contained value or throws if this is [None]. + T unwrap() => switch (this) { + Some(:final value) => value, + _ => throw StateError('Option is None'), + }; +} + +/// A present optional value. +final class Some extends Option { + /// Creates an option that wraps [value]. + const Some(this.value); + + /// The wrapped value. + final T value; +} diff --git a/packages/solidart/lib/src/core/readonly_signal.dart b/packages/solidart/lib/src/core/readonly_signal.dart new file mode 100644 index 00000000..00d2fb4f --- /dev/null +++ b/packages/solidart/lib/src/core/readonly_signal.dart @@ -0,0 +1,32 @@ +part of '../solidart.dart'; + +/// Read-only reactive value. +/// +/// Reading [value] establishes a dependency; [untrackedValue] does not. +/// This interface is implemented by [Signal], [Computed], and [Resource]. +/// +/// ```dart +/// final count = Signal(0); +/// ReadonlySignal readonly = count.toReadonly(); +/// ``` +// TODO(nank1ro): Maybe rename to `ReadSignal`? medz: I still recommend `ReadonlySignal` because it is semantically clearer., https://github.com/nank1ro/solidart/pull/166#issuecomment-3623175977 +abstract interface class ReadonlySignal + implements system.ReactiveNode, Disposable, SignalConfiguration { + /// Returns the previous value (tracked read). + /// + /// This may return `null` if tracking is disabled or the signal has not been + /// read since the last update. + T? get previousValue; + + /// Returns the previous value without tracking. + T? get untrackedPreviousValue; + + /// Returns the current value without tracking. + T get untrackedValue; + + /// Returns the current value and tracks dependencies. + T get value; + + /// Returns [value]. This allows using a signal as a callable. + T call(); +} diff --git a/packages/solidart/lib/src/core/set_signal.dart b/packages/solidart/lib/src/core/set_signal.dart new file mode 100644 index 00000000..3046de84 --- /dev/null +++ b/packages/solidart/lib/src/core/set_signal.dart @@ -0,0 +1,130 @@ +part of '../solidart.dart'; + +/// {@template solidart.set-signal} +/// A reactive wrapper around a [Set] that copies on write. +/// +/// Mutations create a new set instance so that updates are observable: +/// ```dart +/// final set = SetSignal({1}); +/// Effect(() => print(set.length)); +/// set.add(2); // triggers effect +/// ``` +/// +/// Reads (like `length` or `contains`) establish dependencies. +/// {@endtemplate} +class SetSignal extends Signal> with SetMixin { + /// {@macro solidart.set-signal} + /// + /// Creates a reactive set with the provided initial values. + SetSignal( + Iterable initialValue, { + bool? autoDispose, + String? name, + ValueComparator> equals = identical, + bool? trackPreviousValue, + bool? trackInDevTools, + }) : super( + Set.of(initialValue), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: trackPreviousValue, + trackInDevTools: trackInDevTools, + ); + + @override + Iterator get iterator => value.iterator; + + @override + int get length => value.length; + + @override + bool add(E value) { + final current = untrackedValue; + if (current.contains(value)) return false; + final next = Set.of(current)..add(value); + this.value = next; + return true; + } + + @override + void addAll(Iterable elements) { + if (elements.isEmpty) return; + final next = _copy()..addAll(elements); + if (next.length == untrackedValue.length) return; + value = next; + } + + @override + Set cast() => SetSignal(untrackedValue.cast()); + + @override + void clear() { + if (untrackedValue.isEmpty) return; + value = {}; + } + + @override + bool contains(Object? element) { + value; + return untrackedValue.contains(element); + } + + @override + E? lookup(Object? element) { + value; + return untrackedValue.lookup(element); + } + + @override + bool remove(Object? value) { + final current = untrackedValue; + if (!current.contains(value)) return false; + final next = Set.of(current)..remove(value); + this.value = next; + return true; + } + + @override + void removeAll(Iterable elements) { + if (elements.isEmpty) return; + final current = untrackedValue; + final next = Set.of(current)..removeAll(elements); + if (next.length == current.length) return; + value = next; + } + + @override + void removeWhere(bool Function(E element) test) { + final current = untrackedValue; + final next = Set.of(current)..removeWhere(test); + if (next.length == current.length) return; + value = next; + } + + @override + void retainAll(Iterable elements) { + final current = untrackedValue; + final next = Set.of(current)..retainAll(elements); + if (next.length == current.length) return; + value = next; + } + + @override + void retainWhere(bool Function(E element) test) { + final current = untrackedValue; + final next = Set.of(current)..retainWhere(test); + if (next.length == current.length) return; + value = next; + } + + @override + Set toSet() => Set.of(untrackedValue); + + @override + String toString() => + 'SetSignal<$E>(value: $untrackedValue, ' + 'previousValue: $untrackedPreviousValue)'; + + Set _copy() => Set.of(untrackedValue); +} diff --git a/packages/solidart/lib/src/core/signal.dart b/packages/solidart/lib/src/core/signal.dart new file mode 100644 index 00000000..805a3300 --- /dev/null +++ b/packages/solidart/lib/src/core/signal.dart @@ -0,0 +1,186 @@ +part of '../solidart.dart'; + +/// {@template solidart.signal} +/// # Signals +/// Signals are the cornerstone of reactivity in v3. They store values that +/// change over time, and any reactive computation that reads a signal will +/// automatically update when the signal changes. +/// +/// Create a signal with an initial value: +/// ```dart +/// final counter = Signal(0); +/// ``` +/// +/// Read the current value: +/// ```dart +/// counter.value; // 0 +/// ``` +/// +/// Update the value: +/// ```dart +/// counter.value++; +/// // or +/// counter.value = 10; +/// ``` +/// +/// Signals support previous value tracking. When enabled, `previousValue` +/// updates only after the signal has been read at least once: +/// ```dart +/// final count = Signal(0); +/// count.value = 1; +/// count.previousValue; // null (not read yet) +/// count.value; // establishes tracking +/// count.previousValue; // 0 +/// ``` +/// +/// Signals can be created lazily using [Signal.lazy]. A lazy signal does not +/// have a value until it is first assigned, and reading it early throws +/// [StateError]. +/// {@endtemplate} +/// {@template solidart.signal-equals} +/// Updates are skipped when [equals] reports the new value is equivalent to +/// the previous one. +/// {@endtemplate} +class Signal extends preset.SignalNode> + with DisposableMixin + implements ReadonlySignal { + /// {@macro solidart.signal} + /// + /// {@macro solidart.signal-equals} + Signal( + T initialValue, { + bool? autoDispose, + String? name, + ValueComparator equals = identical, + bool? trackPreviousValue, + bool? trackInDevTools, + }) : this._internal( + Some(initialValue), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: trackPreviousValue, + trackInDevTools: trackInDevTools, + ); + + /// {@macro solidart.signal} + /// + /// This is a lazy signal: it has no value at construction time. + /// Reading [value] before the first assignment throws [StateError]. + factory Signal.lazy({ + String? name, + bool? autoDispose, + ValueComparator equals, + bool? trackPreviousValue, + bool? trackInDevTools, + }) = LazySignal; + + Signal._internal( + Option initialValue, { + this.equals = identical, + String? name, + bool? autoDispose, + bool? trackPreviousValue, + bool? trackInDevTools, + }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, + trackPreviousValue = + trackPreviousValue ?? SolidartConfig.trackPreviousValue, + trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, + identifier = ._(name), + super( + flags: system.ReactiveFlags.mutable, + currentValue: initialValue, + pendingValue: initialValue, + ) { + _notifySignalCreation(this); + } + + @override + final bool autoDispose; + + @override + final Identifier identifier; + + @override + final ValueComparator equals; + + @override + final bool trackPreviousValue; + + @override + final bool trackInDevTools; + + Option _previousValue = const None(); + + /// Whether the signal has been initialized. + /// + /// Regular signals are always initialized at construction time. + bool get isInitialized => true; + + @override + T? get previousValue { + if (!trackPreviousValue) return null; + value; + return _previousValue.safeUnwrap(); + } + + @override + T? get untrackedPreviousValue { + if (!trackPreviousValue) return null; + return _previousValue.safeUnwrap(); + } + + @override + T get untrackedValue => super.currentValue.unwrap(); + + @override + T get value { + assert(!isDisposed, 'Signal is disposed'); + return super.get().unwrap(); + } + + /// Sets the current value. + /// + /// {@macro solidart.signal-equals} + set value(T newValue) { + assert(!isDisposed, 'Signal is disposed'); + set(Some(newValue)); + } + + @override + T call() => value; + + // TODO(nank1ro): See ReadonlySignal TODO, If `ReadonlySignal` rename + // to `ReadSignal`, the `.toReadonly` method should be rename? + @override + bool didUpdate() { + flags = system.ReactiveFlags.mutable; + final current = currentValue; + final pending = pendingValue; + if (current is Some && + pending is Some && + equals(pending.value, current.value)) { + return false; + } + + if (trackPreviousValue && current is Some) { + _previousValue = current; + } + + currentValue = pending; + _notifySignalUpdate(this); + return true; + } + + @override + void dispose() { + if (isDisposed) return; + Disposable.unlinkSubs(this); + preset.stop(this); + super.dispose(); + _notifySignalDisposal(this); + } + + /// Returns a read-only view of this signal. + ReadonlySignal toReadonly() => this; +} diff --git a/packages/solidart/lib/src/core/signal_configuration.dart b/packages/solidart/lib/src/core/signal_configuration.dart new file mode 100644 index 00000000..c27e474c --- /dev/null +++ b/packages/solidart/lib/src/core/signal_configuration.dart @@ -0,0 +1,18 @@ +part of '../solidart.dart'; + +/// Common configuration for signals. +abstract interface class SignalConfiguration implements Configuration { + /// Comparator used to skip equal updates. + /// + /// When it returns `true`, the new value is treated as equal and the update + /// is skipped. + ValueComparator get equals; + + /// Whether to report to DevTools. + bool get trackInDevTools; + + /// Whether to track previous values. + /// + /// Previous values are captured on successful updates after a tracked read. + bool get trackPreviousValue; +} diff --git a/packages/solidart/lib/src/core/typedefs.dart b/packages/solidart/lib/src/core/typedefs.dart new file mode 100644 index 00000000..cef339d5 --- /dev/null +++ b/packages/solidart/lib/src/core/typedefs.dart @@ -0,0 +1,19 @@ +part of '../solidart.dart'; + +/// Disposer returned by [ObserveSignal.observe]. +typedef DisposeObservation = void Function(); + +/// Signature for callbacks fired when a signal changes. +typedef ObserveCallback = void Function(T? previousValue, T value); + +/// Compares two values for equality. +/// +/// Return `true` when the update should be skipped because values are +/// considered equivalent. +typedef ValueComparator = bool Function(T? a, T? b); + +/// Lazily produces a value. +typedef ValueGetter = T Function(); + +/// A callback that returns no value. +typedef VoidCallback = ValueGetter; diff --git a/packages/solidart/lib/src/core/untracked.dart b/packages/solidart/lib/src/core/untracked.dart new file mode 100644 index 00000000..5d2738f2 --- /dev/null +++ b/packages/solidart/lib/src/core/untracked.dart @@ -0,0 +1,22 @@ +part of '../solidart.dart'; + +/// Runs [callback] without tracking dependencies. +/// +/// This is useful when you want to read or write signals inside an effect +/// without establishing a dependency. +/// +/// ```dart +/// final count = Signal(0); +/// Effect(() { +/// print(count.value); +/// untracked(() => count.value = count.value + 1); +/// }); +/// ``` +T untracked(T Function() callback) { + final prevSub = preset.setActiveSub(); + try { + return callback(); + } finally { + preset.setActiveSub(prevSub); + } +} diff --git a/packages/solidart/lib/src/extensions/observe_signal.dart b/packages/solidart/lib/src/extensions/observe_signal.dart new file mode 100644 index 00000000..f5254ec0 --- /dev/null +++ b/packages/solidart/lib/src/extensions/observe_signal.dart @@ -0,0 +1,30 @@ +part of '../solidart.dart'; + +/// Observes [ReadonlySignal] changes with previous and current values. +extension ObserveSignal on ReadonlySignal { + /// Observe the signal and invoke [listener] whenever the value changes. + /// + /// When [fireImmediately] is `true`, the listener runs once on subscription. + /// Returns a disposer that stops the observation. + DisposeObservation observe( + ObserveCallback listener, { + bool fireImmediately = false, + }) { + var skipped = false; + final effect = Effect( + () { + value; + if (!fireImmediately && !skipped) { + skipped = true; + return; + } + untracked(() { + listener(untrackedPreviousValue, untrackedValue); + }); + }, + detach: true, + ); + + return effect.dispose; + } +} diff --git a/packages/solidart/lib/src/extensions/until.dart b/packages/solidart/lib/src/extensions/until.dart new file mode 100644 index 00000000..ce4b3814 --- /dev/null +++ b/packages/solidart/lib/src/extensions/until.dart @@ -0,0 +1,52 @@ +part of '../solidart.dart'; + +/// Waits until a signal satisfies a condition. +extension UntilSignal on ReadonlySignal { + /// Returns a future that completes when [condition] becomes true. + /// + /// If [condition] is already true, this returns the current value + /// immediately. + /// + /// When [timeout] is provided, the returned future completes with a + /// [TimeoutException] if the condition is not met in time. + FutureOr until( + bool Function(T value) condition, { + Duration? timeout, + }) { + if (condition(value)) return value; + + final completer = Completer(); + Timer? timer; + late final Effect effect; + + void dispose() { + effect.dispose(); + timer?.cancel(); + timer = null; + } + + effect = Effect( + () { + final current = value; + if (!condition(current)) return; + dispose(); + if (!completer.isCompleted) { + completer.complete(current); + } + }, + autoDispose: false, + ); + + onDispose(dispose); + + if (timeout != null) { + timer = Timer(timeout, () { + if (completer.isCompleted) return; + dispose(); + completer.completeError(TimeoutException(null, timeout)); + }); + } + + return completer.future; + } +} diff --git a/packages/solidart/lib/src/resources/resource.dart b/packages/solidart/lib/src/resources/resource.dart new file mode 100644 index 00000000..28249bac --- /dev/null +++ b/packages/solidart/lib/src/resources/resource.dart @@ -0,0 +1,325 @@ +part of '../solidart.dart'; + +/// {@template solidart.resource} +/// # Resource +/// A resource is a signal designed for async data. It wraps the common states +/// of asynchronous work: `ready`, `loading`, and `error`. +/// +/// Resources can be driven by: +/// - a `fetcher` that returns a `Future` +/// - a `stream` that yields values over time +/// - an optional `source` signal that triggers refreshes +/// +/// Example using a fetcher: +/// ```dart +/// final userId = Signal(1); +/// +/// Future fetchUser() async { +/// final id = userId.value; +/// return 'user:$id'; +/// } +/// +/// final user = Resource(fetchUser, source: userId); +/// ``` +/// +/// The current state is available via [state] and provides helpers like +/// `when`, `maybeWhen`, `asReady`, `asError`, `isLoading`, and `isRefreshing`. +/// +/// The [resolve] method starts the resource once. The [refresh] method forces +/// a new fetch or re-subscribes to the stream. When [useRefreshing] is true, +/// refresh updates the current state with `isRefreshing` instead of resetting +/// to `loading`. +/// {@endtemplate} +class Resource extends Signal> { + /// {@macro solidart.resource} + /// + /// Creates a resource backed by a future-producing [fetcher]. + Resource( + this.fetcher, { + this.source, + this.lazy = true, + bool? useRefreshing, + bool? trackPreviousState, + this.debounceDelay, + bool? autoDispose, + String? name, + bool? trackInDevTools, + ValueComparator> equals = identical, + }) : stream = null, + useRefreshing = useRefreshing ?? SolidartConfig.useRefreshing, + super( + ResourceState.loading(), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: + trackPreviousState ?? SolidartConfig.trackPreviousValue, + trackInDevTools: trackInDevTools, + ) { + if (!lazy) { + _resolveIfNeeded(); + } + } + + /// {@macro solidart.resource} + /// + /// Creates a resource backed by a stream factory. + /// + /// Use this when your data source is an ongoing stream (e.g. sockets, + /// Firestore snapshots, or SSE). The stream is subscribed on resolve and + /// re-subscribed when [refresh] is called or when [source] changes. + /// + /// ```dart + /// final ticks = Resource.stream( + /// () => Stream.periodic(const Duration(seconds: 1), (i) => i), + /// lazy: false, + /// ); + /// ``` + /// + /// When a refresh happens, the previous subscription is cancelled and + /// events from older subscriptions are ignored. + Resource.stream( + this.stream, { + this.source, + this.lazy = true, + bool? useRefreshing, + bool? trackPreviousState, + this.debounceDelay, + bool? autoDispose, + String? name, + bool? trackInDevTools, + ValueComparator> equals = identical, + }) : fetcher = null, + useRefreshing = useRefreshing ?? SolidartConfig.useRefreshing, + super( + ResourceState.loading(), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: + trackPreviousState ?? SolidartConfig.trackPreviousValue, + trackInDevTools: trackInDevTools, + ) { + if (!lazy) { + _resolveIfNeeded(); + } + } + + /// Optional source signal that triggers refreshes when it changes. + /// + /// When [source] updates, the resource refreshes. If [debounceDelay] is set, + /// multiple source changes are coalesced. + final ReadonlySignal? source; + + /// Fetches the resource value. + final Future Function()? fetcher; + + /// Provides a stream of resource values. + final Stream Function()? stream; + + /// Whether the resource is resolved lazily. + /// + /// When `true`, the resource resolves on first read or when [resolve] is + /// called explicitly. + final bool lazy; + + /// Whether to keep previous value while refreshing. + /// + /// When `true`, refresh updates the current state with `isRefreshing` rather + /// than replacing it with `loading`. + final bool useRefreshing; + + /// Optional debounce duration for source-triggered refreshes. + final Duration? debounceDelay; + + bool _resolved = false; + int _version = 0; + Future? _resolveFuture; + Effect? _sourceEffect; + StreamSubscription? _streamSubscription; + Timer? _debounceTimer; + + /// Returns the previous state (tracked read), or `null`. + /// + /// Previous state is available only after a tracked read. + ResourceState? get previousState { + _resolveIfNeeded(); + if (!_resolved) return null; + return previousValue; + } + + /// Returns the current state, resolving lazily if needed. + ResourceState get state { + _resolveIfNeeded(); + return value; + } + + /// Sets the current state. + set state(ResourceState next) => value = next; + + /// Returns the previous state without tracking. + ResourceState? get untrackedPreviousState => untrackedPreviousValue; + + /// Returns the current state without tracking. + ResourceState get untrackedState => untrackedValue; + + @override + void dispose() { + _debounceTimer?.cancel(); + _debounceTimer = null; + _sourceEffect?.dispose(); + _sourceEffect = null; + _streamSubscription?.cancel(); + _streamSubscription = null; + super.dispose(); + } + + /// Re-fetches or re-subscribes to the resource. + /// + /// If the resource has not been resolved yet, this triggers [resolve] + /// instead. + Future refresh() async { + if (!_resolved) { + await resolve(); + return; + } + + if (fetcher != null) { + return _refetch(); + } + + if (stream != null) { + _resubscribe(); + return; + } + } + + /// Returns a future that completes with the value when the resource is ready. + /// + /// If the resource is already ready, it completes immediately. + Future untilReady() async { + final state = await Future.value(until((value) => value.isReady)); + return state.asReady!.value; + } + + /// Resolves the resource if it has not been resolved yet. + /// + /// Multiple calls are coalesced into a single in-flight resolve. + Future resolve() async { + if (isDisposed) return; + if (_resolveFuture != null) return _resolveFuture!; + if (_resolved) return; + + _resolved = true; + _resolveFuture = _doResolve().whenComplete(() { + _resolveFuture = null; + }); + + return _resolveFuture!; + } + + Future _doResolve() async { + if (fetcher != null) { + await _fetch(); + } + + if (stream != null) { + _subscribe(); + } + + if (source != null) { + _setupSourceEffect(); + } + } + + Future _fetch() async { + final requestId = ++_version; + try { + final result = await fetcher!(); + if (_isStale(requestId)) return; + state = ResourceState.ready(result); + } catch (e, s) { + if (_isStale(requestId)) return; + state = ResourceState.error(e, stackTrace: s); + } + } + + bool _isStale(int requestId) => requestId != _version || isDisposed; + + void _listenStream() { + final requestId = ++_version; + _streamSubscription = stream!().listen( + (data) { + if (_isStale(requestId)) return; + state = ResourceState.ready(data); + }, + onError: (Object error, StackTrace stackTrace) { + if (_isStale(requestId)) return; + state = ResourceState.error(error, stackTrace: stackTrace); + }, + ); + } + + Future _refetch() async { + _transition(); + return _fetch(); + } + + void _resolveIfNeeded() { + if (!_resolved) { + unawaited(resolve()); + } + } + + void _resubscribe() { + _streamSubscription?.cancel(); + _streamSubscription = null; + _transition(); + _listenStream(); + } + + void _setupSourceEffect() { + var skipped = false; + _sourceEffect = Effect( + () { + source!.value; + if (!skipped) { + skipped = true; + return; + } + if (debounceDelay != null) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(debounceDelay!, () { + if (isDisposed) return; + untracked(refresh); + }); + } else { + untracked(refresh); + } + }, + autoDispose: false, + ); + } + + void _subscribe() { + _listenStream(); + } + + void _transition() { + if (!useRefreshing) { + state = ResourceState.loading(); + return; + } + state.map( + ready: (ready) { + state = ready.copyWith(isRefreshing: true); + }, + error: (error) { + state = error.copyWith(isRefreshing: true); + }, + loading: (_) { + state = ResourceState.loading(); + }, + ); + } +} diff --git a/packages/solidart/lib/src/resources/resource_state.dart b/packages/solidart/lib/src/resources/resource_state.dart new file mode 100644 index 00000000..1ba5eeb7 --- /dev/null +++ b/packages/solidart/lib/src/resources/resource_state.dart @@ -0,0 +1,187 @@ +part of '../solidart.dart'; + +/// Error state containing an error and optional stack trace. +@immutable +class ResourceError implements ResourceState { + /// Creates an error state. + const ResourceError( + this.error, { + this.stackTrace, + this.isRefreshing = false, + }); + + /// The error object. + final Object error; + + /// Optional stack trace. + final StackTrace? stackTrace; + + /// Whether the resource is refreshing. + final bool isRefreshing; + + @override + int get hashCode => Object.hash(runtimeType, error, stackTrace, isRefreshing); + + @override + bool operator ==(Object other) { + return runtimeType == other.runtimeType && + other is ResourceError && + other.error == error && + other.stackTrace == stackTrace && + other.isRefreshing == isRefreshing; + } + + /// Returns a copy with updated fields. + ResourceError copyWith({ + Object? error, + StackTrace? stackTrace, + bool? isRefreshing, + }) { + return ResourceError( + error ?? this.error, + stackTrace: stackTrace ?? this.stackTrace, + isRefreshing: isRefreshing ?? this.isRefreshing, + ); + } + + @override + R map({ + required R Function(ResourceReady ready) ready, + required R Function(ResourceError error) error, + required R Function(ResourceLoading loading) loading, + }) { + return error(this); + } + + @override + String toString() { + return 'ResourceError<$T>(error: $error, stackTrace: $stackTrace, ' + 'refreshing: $isRefreshing)'; + } +} + +/// Loading state. +@immutable +class ResourceLoading implements ResourceState { + /// Creates a loading state. + const ResourceLoading(); + + @override + int get hashCode => runtimeType.hashCode; + + @override + bool operator ==(Object other) => runtimeType == other.runtimeType; + + @override + R map({ + required R Function(ResourceReady ready) ready, + required R Function(ResourceError error) error, + required R Function(ResourceLoading loading) loading, + }) { + return loading(this); + } + + @override + String toString() => 'ResourceLoading<$T>()'; +} + +/// Ready state containing data. +@immutable +class ResourceReady implements ResourceState { + /// Creates a ready state with [value]. + const ResourceReady(this.value, {this.isRefreshing = false}); + + /// The resource value. + final T value; + + /// Whether the resource is refreshing. + final bool isRefreshing; + + @override + int get hashCode => Object.hash(runtimeType, value, isRefreshing); + + @override + bool operator ==(Object other) { + return runtimeType == other.runtimeType && + other is ResourceReady && + other.value == value && + other.isRefreshing == isRefreshing; + } + + /// Returns a copy with updated fields. + ResourceReady copyWith({ + T? value, + bool? isRefreshing, + }) { + return ResourceReady( + value ?? this.value, + isRefreshing: isRefreshing ?? this.isRefreshing, + ); + } + + @override + R map({ + required R Function(ResourceReady ready) ready, + required R Function(ResourceError error) error, + required R Function(ResourceLoading loading) loading, + }) { + return ready(this); + } + + @override + String toString() { + return 'ResourceReady<$T>(value: $value, refreshing: $isRefreshing)'; + } +} + +/// {@template solidart.resource-state} +/// Represents the state of a [Resource]. +/// +/// A resource is always in one of: +/// - `ready(data)` when a value is available +/// - `loading()` while work is in progress +/// - `error(error)` when a failure occurs +/// +/// Use [ResourceStateExtensions] helpers to map or pattern-match: +/// ```dart +/// final state = resource.state; +/// final label = state.when( +/// ready: (data) => 'ready: $data', +/// error: (err, _) => 'error: $err', +/// loading: () => 'loading', +/// ); +/// ``` +/// {@endtemplate} +@sealed +@immutable +sealed class ResourceState { + /// Base constructor for resource states. + const ResourceState(); // coverage:ignore-line + + /// {@macro solidart.resource-state} + /// + /// Creates an error state. + const factory ResourceState.error( + Object error, { + StackTrace? stackTrace, + bool isRefreshing, + }) = ResourceError; + + /// {@macro solidart.resource-state} + /// + /// Creates a loading state. + const factory ResourceState.loading() = ResourceLoading; + + /// {@macro solidart.resource-state} + /// + /// Creates a ready state with [data]. + const factory ResourceState.ready(T data, {bool isRefreshing}) = + ResourceReady; + + /// Maps each concrete state to a value. + R map({ + required R Function(ResourceReady ready) ready, + required R Function(ResourceError error) error, + required R Function(ResourceLoading loading) loading, + }); +} diff --git a/packages/solidart/lib/src/resources/resource_state_extensions.dart b/packages/solidart/lib/src/resources/resource_state_extensions.dart new file mode 100644 index 00000000..2c298f16 --- /dev/null +++ b/packages/solidart/lib/src/resources/resource_state_extensions.dart @@ -0,0 +1,112 @@ +part of '../solidart.dart'; + +/// Convenience accessors for [ResourceState]. +/// +/// Includes common flags (`isLoading`, `isReady`, `hasError`), casting helpers +/// (`asReady`, `asError`), and pattern matching helpers (`when`, `maybeWhen`, +/// `maybeMap`). +extension ResourceStateExtensions on ResourceState { + /// Casts to [ResourceError] if possible. + ResourceError? get asError => map( + error: (e) => e, + ready: (_) => null, + loading: (_) => null, + ); + + /// Casts to [ResourceReady] if possible. + ResourceReady? get asReady => map( + ready: (r) => r, + error: (_) => null, + loading: (_) => null, + ); + + /// Returns the error for error state. + Object? get error => map( + error: (r) => r.error, + ready: (_) => null, + loading: (_) => null, + ); + + /// Whether this state is an error. + bool get hasError => this is ResourceError; + + /// Whether this state is loading. + bool get isLoading => this is ResourceLoading; + + /// Whether this state is ready. + bool get isReady => this is ResourceReady; + + /// Whether this state is marked as refreshing. + bool get isRefreshing => switch (this) { + ResourceReady(:final isRefreshing) => isRefreshing, + ResourceError(:final isRefreshing) => isRefreshing, + ResourceLoading() => false, + }; + + /// Returns the value for ready state, throws for error state. + T? get value => map( + ready: (r) => r.value, + // ignore: only_throw_errors + error: (r) => throw r.error, + loading: (_) => null, + ); + + /// Executes callbacks for available handlers, otherwise [orElse]. + R maybeMap({ + required R Function() orElse, + R Function(ResourceReady ready)? ready, + R Function(ResourceError error)? error, + R Function(ResourceLoading loading)? loading, + }) { + return map( + ready: (r) { + if (ready != null) return ready(r); + return orElse(); + }, + error: (e) { + if (error != null) return error(e); + return orElse(); + }, + loading: (l) { + if (loading != null) return loading(l); + return orElse(); + }, + ); + } + + /// Executes callbacks for available handlers, otherwise [orElse]. + R maybeWhen({ + required R Function() orElse, + R Function(T data)? ready, + R Function(Object error, StackTrace? stackTrace)? error, + R Function()? loading, + }) { + return map( + ready: (r) { + if (ready != null) return ready(r.value); + return orElse(); + }, + error: (e) { + if (error != null) return error(e.error, e.stackTrace); + return orElse(); + }, + loading: (l) { + if (loading != null) return loading(); + return orElse(); + }, + ); + } + + /// Executes callbacks for each state. + R when({ + required R Function(T data) ready, + required R Function(Object error, StackTrace? stackTrace) error, + required R Function() loading, + }) { + return map( + ready: (r) => ready(r.value), + error: (e) => error(e.error, e.stackTrace), + loading: (_) => loading(), + ); + } +} diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart index 9502eee2..f1b525ca 100644 --- a/packages/solidart/lib/src/solidart.dart +++ b/packages/solidart/lib/src/solidart.dart @@ -8,2221 +8,27 @@ import 'package:meta/meta.dart'; import 'package:solidart/deps/preset.dart' as preset; import 'package:solidart/deps/system.dart' as system; -/// Batches signal updates and flushes once at the end. -/// -/// Nested batches are supported; the final flush happens when the outermost -/// batch completes. -/// -/// ```dart -/// final a = Signal(1); -/// final b = Signal(2); -/// Effect(() => print('sum: ${a.value + b.value}')); -/// -/// batch(() { -/// a.value = 3; -/// b.value = 4; -/// }); -/// ``` -T batch(T Function() fn) { - preset.startBatch(); - try { - return fn(); - } finally { - preset.endBatch(); - } -} - -/// Runs [callback] without tracking dependencies. -/// -/// This is useful when you want to read or write signals inside an effect -/// without establishing a dependency. -/// -/// ```dart -/// final count = Signal(0); -/// Effect(() { -/// print(count.value); -/// untracked(() => count.value = count.value + 1); -/// }); -/// ``` -T untracked(T Function() callback) { - final prevSub = preset.setActiveSub(); - try { - return callback(); - } finally { - preset.setActiveSub(prevSub); - } -} - -Object? _computedValue(Computed signal) { - final current = signal.currentValue; - if (current != null || null is T) { - return current; - } - return null; -} - -// coverage:ignore-end - -// coverage:ignore-start -bool _hasPreviousValue(ReadonlySignal signal) { - if (!signal.trackPreviousValue) return false; - if (signal is Signal) { - return signal._previousValue is Some; - } - if (signal is Computed) { - return signal._previousValue is Some; - } - return false; -} - -// coverage:ignore-start -int _listenerCount(system.ReactiveNode node) { - var count = 0; - var link = node.subs; - while (link != null) { - count++; - link = link.nextSub; - } - return count; -} - -// coverage:ignore-end - -void _notifyDevToolsAboutSignal( - ReadonlySignal signal, { - required _DevToolsEventType eventType, -}) { - if (!SolidartConfig.devToolsEnabled || !signal.trackInDevTools) return; - final eventName = 'ext.solidart.signal.${eventType.name}'; - final value = _signalValue(signal); - final previousValue = _signalPreviousValue(signal); - final hasPreviousValue = _hasPreviousValue(signal); - - dev.postEvent(eventName, { - '_id': signal.identifier.value.toString(), - 'name': signal.identifier.name, - 'value': _toJson(value), - 'previousValue': _toJson(previousValue), - 'hasPreviousValue': hasPreviousValue, - 'type': _signalType(signal), - 'valueType': value.runtimeType.toString(), - if (hasPreviousValue) - 'previousValueType': previousValue.runtimeType.toString(), - 'disposed': signal.isDisposed, - 'autoDispose': signal.autoDispose, - 'listenerCount': _listenerCount(signal), - 'lastUpdate': DateTime.now().toIso8601String(), - }); -} - -void _notifySignalCreation(ReadonlySignal signal) { - if (signal.trackInDevTools && SolidartConfig.observers.isNotEmpty) { - for (final observer in SolidartConfig.observers) { - observer.didCreateSignal(signal); - } - } - _notifyDevToolsAboutSignal(signal, eventType: _DevToolsEventType.created); -} - -void _notifySignalDisposal(ReadonlySignal signal) { - if (signal.trackInDevTools && SolidartConfig.observers.isNotEmpty) { - for (final observer in SolidartConfig.observers) { - observer.didDisposeSignal(signal); - } - } - _notifyDevToolsAboutSignal(signal, eventType: _DevToolsEventType.disposed); -} - -void _notifySignalUpdate(ReadonlySignal signal) { - if (signal.trackInDevTools && SolidartConfig.observers.isNotEmpty) { - for (final observer in SolidartConfig.observers) { - observer.didUpdateSignal(signal); - } - } - _notifyDevToolsAboutSignal(signal, eventType: _DevToolsEventType.updated); -} - -Object? _resourceValue(ResourceState? state) { - if (state == null) return null; - return state.maybeWhen(orElse: () => null, ready: (value) => value); -} - -Object? _signalPreviousValue(ReadonlySignal signal) { - if (signal is Resource) { - return _resourceValue(signal.untrackedPreviousState); - } - return signal.untrackedPreviousValue; -} - -String _signalType(ReadonlySignal signal) => switch (signal) { - Resource() => 'Resource', - ListSignal() => 'ListSignal', - MapSignal() => 'MapSignal', - SetSignal() => 'SetSignal', - LazySignal() => 'LazySignal', - Signal() => 'Signal', - Computed() => 'Computed', - _ => 'ReadonlySignal', -}; - -Object? _signalValue(ReadonlySignal signal) { - if (signal is Resource) { - return _resourceValue(signal.untrackedState); - } - if (signal is LazySignal && !signal.isInitialized) { - return null; - } - if (signal is Computed) { - return _computedValue(signal); - } - return signal.untrackedValue; -} - -// coverage:ignore-start -dynamic _toJson(Object? obj, [int depth = 0, Set? visited]) { - const maxDepth = 20; - if (depth > maxDepth) return ''; - try { - return jsonEncode(obj); - } catch (_) { - if (obj is List) { - final visitedSet = visited ?? Set.identity(); - if (!visitedSet.add(obj)) return ''; - try { - return obj - .map((e) => _toJson(e, depth + 1, visitedSet)) - .toList() - .toString(); - } finally { - visitedSet.remove(obj); - } - } - if (obj is Set) { - final visitedSet = visited ?? Set.identity(); - if (!visitedSet.add(obj)) return ''; - try { - return obj - .map((e) => _toJson(e, depth + 1, visitedSet)) - .toList() - .toString(); - } finally { - visitedSet.remove(obj); - } - } - if (obj is Map) { - final visitedSet = visited ?? Set.identity(); - if (!visitedSet.add(obj)) return ''; - try { - return obj - .map( - (key, value) => MapEntry( - _toJson(key, depth + 1, visitedSet), - _toJson(value, depth + 1, visitedSet), - ), - ) - .toString(); - } finally { - visitedSet.remove(obj); - } - } - return jsonEncode(obj.toString()); - } -} - -/// Disposer returned by [ObserveSignal.observe]. -typedef DisposeObservation = void Function(); - -/// Signature for callbacks fired when a signal changes. -typedef ObserveCallback = void Function(T? previousValue, T value); - -/// Compares two values for equality. -/// -/// Return `true` when the update should be skipped because values are -/// considered equivalent. -typedef ValueComparator = bool Function(T? a, T? b); - -/// Lazily produces a value. -typedef ValueGetter = T Function(); - -/// A callback that returns no value. -typedef VoidCallback = ValueGetter; - -/// {@template solidart.computed} -/// # Computed -/// A computed signal derives its value from other signals. It is read-only -/// and recalculates whenever any dependency changes. -/// -/// Use `Computed` to derive state or combine multiple signals: -/// ```dart -/// final firstName = Signal('Josh'); -/// final lastName = Signal('Brown'); -/// final fullName = Computed(() => '${firstName.value} ${lastName.value}'); -/// ``` -/// -/// Computeds only notify when the derived value changes. You can customize -/// equality via [equals] to skip updates for equivalent values. -/// -/// Like signals, computeds can track previous values once they have been read. -/// {@endtemplate} -class Computed extends preset.ComputedNode - with DisposableMixin - implements ReadonlySignal { - /// {@macro solidart.computed} - Computed( - ValueGetter getter, { - this.equals = identical, - bool? autoDispose, - String? name, - bool? trackPreviousValue, - bool? trackInDevTools, - }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, - trackPreviousValue = - trackPreviousValue ?? SolidartConfig.trackPreviousValue, - trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, - identifier = ._(name), - super(flags: system.ReactiveFlags.none, getter: (_) => getter()) { - _notifySignalCreation(this); - } - - @override - final bool autoDispose; - - @override - final Identifier identifier; - - @override - final ValueComparator equals; - - @override - final bool trackPreviousValue; - - @override - final bool trackInDevTools; - - Option _previousValue = const None(); - - @override - T? get previousValue { - if (!trackPreviousValue) return null; - value; - return _previousValue.safeUnwrap(); - } - - @override - T? get untrackedPreviousValue { - if (!trackPreviousValue) return null; - return _previousValue.safeUnwrap(); - } - - @override - T get untrackedValue { - if (currentValue != null || null is T) { - return currentValue as T; - } - return untracked(() => value); - } - - @override - T get value { - assert(!isDisposed, 'Computed is disposed'); - return get(); - } - - @override - T call() => value; - - @override - bool didUpdate() { - preset.cycle++; - depsTail = null; - flags = system.ReactiveFlags.mutable | system.ReactiveFlags.recursedCheck; - - final prevSub = preset.setActiveSub(this); - try { - final previousValue = currentValue; - final pendingValue = getter(previousValue); - if (equals(previousValue, pendingValue)) { - return false; - } - - if (trackPreviousValue && (previousValue is T)) { - _previousValue = Some(previousValue); - } - - currentValue = pendingValue; - _notifySignalUpdate(this); - return true; - } finally { - preset.activeSub = prevSub; - flags &= ~system.ReactiveFlags.recursedCheck; - preset.purgeDeps(this); - } - } - - @override - void dispose() { - if (isDisposed) return; - Disposable.unlinkDeps(this); - Disposable.unlinkSubs(this); - preset.stop(this); - super.dispose(); - _notifySignalDisposal(this); - } -} - -/// Base configuration shared by reactive primitives. -abstract interface class Configuration { - /// Whether the instance auto-disposes. - bool get autoDispose; - - /// Identifier for the instance. - Identifier get identifier; -} - -/// Disposable behavior for reactive primitives. -abstract class Disposable { - /// Whether this instance has been disposed. - bool get isDisposed; - - /// Disposes the instance. - void dispose(); - - /// Registers a callback to run on dispose. - void onDispose(VoidCallback callback); - - /// Whether the node can be auto-disposed. - static bool canAutoDispose(system.ReactiveNode node) => switch (node) { - Disposable(:final isDisposed) && Configuration(:final autoDispose) => - !isDisposed && autoDispose, - _ => false, - }; - - /// Unlinks dependencies from a node. - /// - /// This is used to break reactive links during disposal. - static void unlinkDeps(system.ReactiveNode node) { - var link = node.deps; - while (link != null) { - final next = link.nextDep; - final dep = link.dep; - final isLastSub = - identical(dep.subs, link) && - link.prevSub == null && - link.nextSub == null; - if (canAutoDispose(dep) && isLastSub) { - (dep as Disposable).dispose(); - } else { - preset.unlink(link, node); - if (canAutoDispose(dep) && dep.subs == null) { - (dep as Disposable).dispose(); - } - } - link = next; - } - } - - /// Unlinks subscribers from a node. - /// - /// This is used to break reactive links during disposal. - static void unlinkSubs(system.ReactiveNode node) { - var link = node.subs; - while (link != null) { - final next = link.nextSub; - final sub = link.sub; - preset.unlink(link, sub); - if (canAutoDispose(sub) && sub.deps == null) { - (sub as Disposable).dispose(); - } - link = next; - } - } -} - -/// Default [Disposable] implementation using cleanup callbacks. -mixin DisposableMixin implements Disposable { - @internal - /// Registered cleanup callbacks invoked on dispose. - late final cleanups = []; - - @override - bool isDisposed = false; - - @mustCallSuper - @override - void dispose() { - if (isDisposed) return; - isDisposed = true; - try { - for (final callback in cleanups) { - callback(); - } - } finally { - cleanups.clear(); - } - } - - @mustCallSuper - @override - void onDispose(VoidCallback callback) { - cleanups.add(callback); - } -} -// coverage:ignore-end - -/// {@template solidart.effect} -/// # Effect -/// Effects run a side-effect whenever any signal they read changes. -/// -/// ```dart -/// final counter = Signal(0); -/// Effect(() { -/// print('count: ${counter.value}'); -/// }); -/// ``` -/// -/// Effects run once immediately when created. If you need a lazy effect, -/// create it with [Effect.manual] and call [run] yourself. -/// -/// Nested effects can either attach to their parent (default) or detach by -/// passing `detach: true` or by enabling [SolidartConfig.detachEffects]. -/// -/// Call [dispose] to stop the effect and release dependencies. -/// {@endtemplate} -class Effect extends preset.EffectNode - with DisposableMixin - implements Disposable, Configuration { - /// {@macro solidart.effect} - factory Effect( - VoidCallback callback, { - bool? autoDispose, - String? name, - bool? detach, - }) => .manual( - callback, - autoDispose: autoDispose, - name: name, - detach: detach, - )..run(); - - /// Creates an effect without running it. - /// - /// Use this when you need to *delay* the first run or decide *when* the - /// effect should start tracking dependencies. Common cases: - /// - you must create several signals first and only then start the effect - /// - you want to control the first run in tests - /// - you need conditional startup (e.g. after async setup) - /// - /// The effect will not track anything until you call [run]: - /// ```dart - /// final count = Signal(0); - /// final effect = Effect.manual(() { - /// print('count: ${count.value}'); - /// }); - /// - /// count.value = 1; // no output yet - /// effect.run(); // prints "count: 1" and starts tracking - /// ``` - /// - /// If you want the effect to run immediately, use the [Effect] factory. - Effect.manual( - VoidCallback callback, { - bool? autoDispose, - String? name, - bool? detach, - }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, - identifier = ._(name), - detach = detach ?? SolidartConfig.detachEffects, - super( - fn: callback, - flags: - system.ReactiveFlags.watching | system.ReactiveFlags.recursedCheck, - ); - - @override - final bool autoDispose; - - @override - final Identifier identifier; - - /// Whether this effect detaches from parent subscriptions. - final bool detach; - - @override - void dispose() { - if (isDisposed) return; - Disposable.unlinkDeps(this); - preset.stop(this); - super.dispose(); - } - - /// Runs the effect and tracks dependencies. - void run() { - final prevSub = preset.setActiveSub(this); - if (!detach && prevSub != null) { - preset.link(this, prevSub, 0); - } - - try { - fn(); - } finally { - preset.activeSub = prevSub; - flags &= ~system.ReactiveFlags.recursedCheck; - } - } -} - -/// A unique identifier with an optional name. -/// -/// Used by DevTools and diagnostics to track instances. -class Identifier { - Identifier._(this.name) : value = _counter++; - static int _counter = 0; - - /// Optional human-readable name. - final String? name; - - /// Unique numeric identifier. - final int value; -} - -/// A signal that starts uninitialized until first set. -/// -/// This is the concrete type behind [Signal.lazy]. Reading [value] before the -/// first assignment throws [StateError]. -/// -/// ```dart -/// final lazy = Signal.lazy(); -/// lazy.value = 1; -/// print(lazy.value); // 1 -/// ``` -class LazySignal extends Signal { - /// Creates a lazy signal. - LazySignal({ - String? name, - bool? autoDispose, - ValueComparator equals = identical, - bool? trackPreviousValue, - bool? trackInDevTools, - }) : super._internal( - const None(), - name: name, - autoDispose: autoDispose, - equals: equals, - trackPreviousValue: trackPreviousValue, - trackInDevTools: trackInDevTools, - ); - - @override - bool get isInitialized => currentValue is Some; - - @override - T get value { - if (isInitialized || pendingValue is Some) { - return super.value; - } - throw StateError( - 'LazySignal is not initialized, please call `.value = ` first.', - ); - } - - @override - bool didUpdate() { - if (!isInitialized) { - flags = system.ReactiveFlags.mutable; - currentValue = pendingValue; - return true; - } - - return super.didUpdate(); - } -} - -/// {@template solidart.list-signal} -/// A reactive wrapper around a [List] that copies on write. -/// -/// Mutations create a new list instance so that updates are observable: -/// ```dart -/// final list = ListSignal([1, 2]); -/// Effect(() => print(list.length)); -/// list.add(3); // triggers effect -/// ``` -/// -/// Reads (like `length` or index access) establish dependencies; the usual -/// list API is supported. -/// {@endtemplate} -class ListSignal extends Signal> with ListMixin { - /// {@macro solidart.list-signal} - /// - /// Creates a reactive list with the provided initial values. - ListSignal( - Iterable initialValue, { - bool? autoDispose, - String? name, - ValueComparator> equals = identical, - bool? trackPreviousValue, - bool? trackInDevTools, - }) : super( - List.of(initialValue), - autoDispose: autoDispose, - name: name, - equals: equals, - trackPreviousValue: trackPreviousValue, - trackInDevTools: trackInDevTools, - ); - - @override - int get length => value.length; - - @override - set length(int newLength) { - final current = untrackedValue; - if (current.length == newLength) return; - value = List.of(current)..length = newLength; - } - - @override - E operator [](int index) => value[index]; - - @override - void operator []=(int index, E element) { - final current = untrackedValue; - if (current[index] == element) return; - final next = List.of(current); - next[index] = element; - value = next; - } - - @override - void add(E element) { - final next = _copy()..add(element); - value = next; - } - - @override - void addAll(Iterable iterable) { - if (iterable.isEmpty) return; - final next = _copy()..addAll(iterable); - value = next; - } - - @override - List cast() => ListSignal(untrackedValue.cast()); - - @override - void clear() { - if (untrackedValue.isEmpty) return; - value = []; - } - - @override - void fillRange(int start, int end, [E? fill]) { - if (end <= start) return; - final next = _copy()..fillRange(start, end, fill); - if (_listEquals(untrackedValue, next)) return; - value = next; - } - - @override - void insert(int index, E element) { - final next = _copy()..insert(index, element); - value = next; - } - - @override - void insertAll(int index, Iterable iterable) { - if (iterable.isEmpty) return; - final next = _copy()..insertAll(index, iterable); - value = next; - } - - @override - bool remove(Object? element) { - final current = untrackedValue; - final index = current.indexWhere((value) => value == element); - if (index == -1) return false; - final next = List.of(current)..removeAt(index); - value = next; - return true; - } - - @override - E removeAt(int index) { - final current = untrackedValue; - final removed = current[index]; - final next = List.of(current)..removeAt(index); - value = next; - return removed; - } - - @override - E removeLast() { - final current = untrackedValue; - final removed = current.last; - final next = List.of(current)..removeLast(); - value = next; - return removed; - } - - @override - void removeRange(int start, int end) { - if (end <= start) return; - final next = _copy()..removeRange(start, end); - value = next; - } - - @override - void removeWhere(bool Function(E element) test) { - final current = untrackedValue; - final next = List.of(current)..removeWhere(test); - if (next.length == current.length) return; - value = next; - } - - @override - void replaceRange(int start, int end, Iterable newContents) { - final next = _copy()..replaceRange(start, end, newContents); - if (_listEquals(untrackedValue, next)) return; - value = next; - } - - @override - void retainWhere(bool Function(E element) test) { - final current = untrackedValue; - final next = List.of(current)..retainWhere(test); - if (next.length == current.length) return; - value = next; - } - - @override - void setAll(int index, Iterable iterable) { - final next = _copy()..setAll(index, iterable); - if (_listEquals(untrackedValue, next)) return; - value = next; - } - - @override - void setRange(int start, int end, Iterable iterable, [int skipCount = 0]) { - final next = _copy()..setRange(start, end, iterable, skipCount); - if (_listEquals(untrackedValue, next)) return; - value = next; - } - - @override - void shuffle([Random? random]) { - if (untrackedValue.length < 2) return; - final next = _copy()..shuffle(random); - if (_listEquals(untrackedValue, next)) return; - value = next; - } - - @override - void sort([int Function(E a, E b)? compare]) { - if (untrackedValue.length < 2) return; - final next = _copy()..sort(compare); - if (_listEquals(untrackedValue, next)) return; - value = next; - } - - @override - String toString() => - 'ListSignal<$E>(value: $untrackedValue, ' - 'previousValue: $untrackedPreviousValue)'; - - List _copy() => List.of(untrackedValue); - - bool _listEquals(List a, List b) { - if (identical(a, b)) return true; - if (a.length != b.length) return false; - for (var i = 0; i < a.length; i++) { - if (a[i] != b[i]) return false; - } - return true; - } -} - -/// {@template solidart.map-signal} -/// A reactive wrapper around a [Map] that copies on write. -/// -/// Mutations create a new map instance so that updates are observable: -/// ```dart -/// final map = MapSignal({'a': 1}); -/// Effect(() => print(map['a'])); -/// map['a'] = 2; // triggers effect -/// ``` -/// -/// Reads (like `[]`, `keys`, or `length`) establish dependencies. -/// {@endtemplate} -class MapSignal extends Signal> with MapMixin { - /// {@macro solidart.map-signal} - /// - /// Creates a reactive map with the provided initial values. - MapSignal( - Map initialValue, { - bool? autoDispose, - String? name, - ValueComparator> equals = identical, - bool? trackPreviousValue, - bool? trackInDevTools, - }) : super( - Map.of(initialValue), - autoDispose: autoDispose, - name: name, - equals: equals, - trackPreviousValue: trackPreviousValue, - trackInDevTools: trackInDevTools, - ); - - @override - bool get isEmpty { - value; - return untrackedValue.isEmpty; - } - - @override - bool get isNotEmpty { - value; - return untrackedValue.isNotEmpty; - } - - @override - Iterable get keys { - value; - return untrackedValue.keys; - } - - @override - int get length { - value; - return untrackedValue.length; - } - - @override - V? operator [](Object? key) { - value; - return untrackedValue[key]; - } - - @override - void operator []=(K key, V value) { - final current = untrackedValue; - final existing = current[key]; - if (current.containsKey(key) && existing == value) return; - final next = _copy(); - next[key] = value; - this.value = next; - } - - @override - void addAll(Map other) { - if (other.isEmpty) return; - final current = untrackedValue; - final next = _copy()..addAll(other); - if (_mapEquals(next, current)) return; - value = next; - } - - @override - Map cast() => - MapSignal(untrackedValue.cast()); - - @override - void clear() { - if (untrackedValue.isEmpty) return; - value = {}; - } - - @override - bool containsKey(Object? key) { - value; - return untrackedValue.containsKey(key); - } - - @override - bool containsValue(Object? value) { - this.value; - return untrackedValue.containsValue(value); - } - - @override - V putIfAbsent(K key, V Function() ifAbsent) { - final current = untrackedValue; - if (current.containsKey(key)) { - return current[key] as V; - } - final next = _copy(); - final value = ifAbsent(); - next[key] = value; - this.value = next; - return value; - } - - @override - V? remove(Object? key) { - final current = untrackedValue; - if (!current.containsKey(key)) return null; - final next = _copy(); - final removed = next.remove(key); - value = next; - return removed; - } - - @override - void removeWhere(bool Function(K key, V value) test) { - final current = untrackedValue; - if (current.isEmpty) return; - final next = _copy()..removeWhere(test); - if (next.length == current.length) return; - value = next; - } - - @override - String toString() => - 'MapSignal<$K, $V>(value: $untrackedValue, ' - 'previousValue: $untrackedPreviousValue)'; - - @override - V update( - K key, - V Function(V value) update, { - V Function()? ifAbsent, - }) { - final current = untrackedValue; - if (!current.containsKey(key)) { - if (ifAbsent == null) { - throw ArgumentError.value(key, 'key', 'Key not in map.'); - } - final next = _copy(); - final value = ifAbsent(); - next[key] = value; - this.value = next; - return value; - } - - final next = _copy(); - final value = update(next[key] as V); - next[key] = value; - this.value = next; - return value; - } - - @override - void updateAll(V Function(K key, V value) update) { - final current = untrackedValue; - if (current.isEmpty) return; - final next = _copy()..updateAll(update); - if (next.length == current.length && - next.keys.every((key) { - return current.containsKey(key) && current[key] == next[key]; - })) { - return; - } - value = next; - } - - Map _copy() => Map.of(untrackedValue); - - bool _mapEquals(Map a, Map b) { - if (identical(a, b)) return true; - if (a.length != b.length) return false; - for (final entry in a.entries) { - if (!b.containsKey(entry.key)) return false; - if (b[entry.key] != entry.value) return false; - } - return true; - } -} - -/// An absent optional value. -final class None extends Option { - /// Creates an option with no value. - const None(); -} - -/// An optional value container. -/// -/// Use [Some] to represent presence and [None] to represent absence without -/// relying on `null`. -sealed class Option { - /// Base constructor for option values. - const Option(); - - /// Returns the contained value or `null` if this is [None]. - T? safeUnwrap() => switch (this) { - Some(:final value) => value, - _ => null, - }; - - /// Returns the contained value or throws if this is [None]. - T unwrap() => switch (this) { - Some(:final value) => value, - _ => throw StateError('Option is None'), - }; -} - -/// Read-only reactive value. -/// -/// Reading [value] establishes a dependency; [untrackedValue] does not. -/// This interface is implemented by [Signal], [Computed], and [Resource]. -/// -/// ```dart -/// final count = Signal(0); -/// ReadonlySignal readonly = count.toReadonly(); -/// ``` -// TODO(nank1ro): Maybe rename to `ReadSignal`? medz: I still recommend `ReadonlySignal` because it is semantically clearer., https://github.com/nank1ro/solidart/pull/166#issuecomment-3623175977 -abstract interface class ReadonlySignal - implements system.ReactiveNode, Disposable, SignalConfiguration { - /// Returns the previous value (tracked read). - /// - /// This may return `null` if tracking is disabled or the signal has not been - /// read since the last update. - T? get previousValue; - - /// Returns the previous value without tracking. - T? get untrackedPreviousValue; - - /// Returns the current value without tracking. - T get untrackedValue; - - /// Returns the current value and tracks dependencies. - T get value; - - /// Returns [value]. This allows using a signal as a callable. - T call(); -} - -/// {@template solidart.resource} -/// # Resource -/// A resource is a signal designed for async data. It wraps the common states -/// of asynchronous work: `ready`, `loading`, and `error`. -/// -/// Resources can be driven by: -/// - a `fetcher` that returns a `Future` -/// - a `stream` that yields values over time -/// - an optional `source` signal that triggers refreshes -/// -/// Example using a fetcher: -/// ```dart -/// final userId = Signal(1); -/// -/// Future fetchUser() async { -/// final id = userId.value; -/// return 'user:$id'; -/// } -/// -/// final user = Resource(fetchUser, source: userId); -/// ``` -/// -/// The current state is available via [state] and provides helpers like -/// `when`, `maybeWhen`, `asReady`, `asError`, `isLoading`, and `isRefreshing`. -/// -/// The [resolve] method starts the resource once. The [refresh] method forces -/// a new fetch or re-subscribes to the stream. When [useRefreshing] is true, -/// refresh updates the current state with `isRefreshing` instead of resetting -/// to `loading`. -/// {@endtemplate} -class Resource extends Signal> { - /// {@macro solidart.resource} - /// - /// Creates a resource backed by a future-producing [fetcher]. - Resource( - this.fetcher, { - this.source, - this.lazy = true, - bool? useRefreshing, - bool? trackPreviousState, - this.debounceDelay, - bool? autoDispose, - String? name, - bool? trackInDevTools, - ValueComparator> equals = identical, - }) : stream = null, - useRefreshing = useRefreshing ?? SolidartConfig.useRefreshing, - super( - ResourceState.loading(), - autoDispose: autoDispose, - name: name, - equals: equals, - trackPreviousValue: - trackPreviousState ?? SolidartConfig.trackPreviousValue, - trackInDevTools: trackInDevTools, - ) { - if (!lazy) { - _resolveIfNeeded(); - } - } - - /// {@macro solidart.resource} - /// - /// Creates a resource backed by a stream factory. - /// - /// Use this when your data source is an ongoing stream (e.g. sockets, - /// Firestore snapshots, or SSE). The stream is subscribed on resolve and - /// re-subscribed when [refresh] is called or when [source] changes. - /// - /// ```dart - /// final ticks = Resource.stream( - /// () => Stream.periodic(const Duration(seconds: 1), (i) => i), - /// lazy: false, - /// ); - /// ``` - /// - /// When a refresh happens, the previous subscription is cancelled and - /// events from older subscriptions are ignored. - Resource.stream( - this.stream, { - this.source, - this.lazy = true, - bool? useRefreshing, - bool? trackPreviousState, - this.debounceDelay, - bool? autoDispose, - String? name, - bool? trackInDevTools, - ValueComparator> equals = identical, - }) : fetcher = null, - useRefreshing = useRefreshing ?? SolidartConfig.useRefreshing, - super( - ResourceState.loading(), - autoDispose: autoDispose, - name: name, - equals: equals, - trackPreviousValue: - trackPreviousState ?? SolidartConfig.trackPreviousValue, - trackInDevTools: trackInDevTools, - ) { - if (!lazy) { - _resolveIfNeeded(); - } - } - - /// Optional source signal that triggers refreshes when it changes. - /// - /// When [source] updates, the resource refreshes. If [debounceDelay] is set, - /// multiple source changes are coalesced. - final ReadonlySignal? source; - - /// Fetches the resource value. - final Future Function()? fetcher; - - /// Provides a stream of resource values. - final Stream Function()? stream; - - /// Whether the resource is resolved lazily. - /// - /// When `true`, the resource resolves on first read or when [resolve] is - /// called explicitly. - final bool lazy; - - /// Whether to keep previous value while refreshing. - /// - /// When `true`, refresh updates the current state with `isRefreshing` rather - /// than replacing it with `loading`. - final bool useRefreshing; - - /// Optional debounce duration for source-triggered refreshes. - final Duration? debounceDelay; - - bool _resolved = false; - int _version = 0; - Future? _resolveFuture; - Effect? _sourceEffect; - StreamSubscription? _streamSubscription; - Timer? _debounceTimer; - - /// Returns the previous state (tracked read), or `null`. - /// - /// Previous state is available only after a tracked read. - ResourceState? get previousState { - _resolveIfNeeded(); - if (!_resolved) return null; - return previousValue; - } - - /// Returns the current state, resolving lazily if needed. - ResourceState get state { - _resolveIfNeeded(); - return value; - } - - /// Sets the current state. - set state(ResourceState next) => value = next; - - /// Returns the previous state without tracking. - ResourceState? get untrackedPreviousState => untrackedPreviousValue; - - /// Returns the current state without tracking. - ResourceState get untrackedState => untrackedValue; - - @override - void dispose() { - _debounceTimer?.cancel(); - _debounceTimer = null; - _sourceEffect?.dispose(); - _sourceEffect = null; - _streamSubscription?.cancel(); - _streamSubscription = null; - super.dispose(); - } - - /// Re-fetches or re-subscribes to the resource. - /// - /// If the resource has not been resolved yet, this triggers [resolve] - /// instead. - Future refresh() async { - if (!_resolved) { - await resolve(); - return; - } - - if (fetcher != null) { - return _refetch(); - } - - if (stream != null) { - _resubscribe(); - return; - } - } - - /// Returns a future that completes with the value when the resource is ready. - /// - /// If the resource is already ready, it completes immediately. - Future untilReady() async { - final state = await Future.value(until((value) => value.isReady)); - return state.asReady!.value; - } - - /// Resolves the resource if it has not been resolved yet. - /// - /// Multiple calls are coalesced into a single in-flight resolve. - Future resolve() async { - if (isDisposed) return; - if (_resolveFuture != null) return _resolveFuture!; - if (_resolved) return; - - _resolved = true; - _resolveFuture = _doResolve().whenComplete(() { - _resolveFuture = null; - }); - - return _resolveFuture!; - } - - Future _doResolve() async { - if (fetcher != null) { - await _fetch(); - } - - if (stream != null) { - _subscribe(); - } - - if (source != null) { - _setupSourceEffect(); - } - } - - Future _fetch() async { - final requestId = ++_version; - try { - final result = await fetcher!(); - if (_isStale(requestId)) return; - state = ResourceState.ready(result); - } catch (e, s) { - if (_isStale(requestId)) return; - state = ResourceState.error(e, stackTrace: s); - } - } - - bool _isStale(int requestId) => requestId != _version || isDisposed; - - void _listenStream() { - final requestId = ++_version; - _streamSubscription = stream!().listen( - (data) { - if (_isStale(requestId)) return; - state = ResourceState.ready(data); - }, - onError: (Object error, StackTrace stackTrace) { - if (_isStale(requestId)) return; - state = ResourceState.error(error, stackTrace: stackTrace); - }, - ); - } - - Future _refetch() async { - _transition(); - return _fetch(); - } - - void _resolveIfNeeded() { - if (!_resolved) { - unawaited(resolve()); - } - } - - void _resubscribe() { - _streamSubscription?.cancel(); - _streamSubscription = null; - _transition(); - _listenStream(); - } - - void _setupSourceEffect() { - var skipped = false; - _sourceEffect = Effect( - () { - source!.value; - if (!skipped) { - skipped = true; - return; - } - if (debounceDelay != null) { - _debounceTimer?.cancel(); - _debounceTimer = Timer(debounceDelay!, () { - if (isDisposed) return; - untracked(refresh); - }); - } else { - untracked(refresh); - } - }, - autoDispose: false, - ); - } - - void _subscribe() { - _listenStream(); - } - - void _transition() { - if (!useRefreshing) { - state = ResourceState.loading(); - return; - } - state.map( - ready: (ready) { - state = ready.copyWith(isRefreshing: true); - }, - error: (error) { - state = error.copyWith(isRefreshing: true); - }, - loading: (_) { - state = ResourceState.loading(); - }, - ); - } -} - -/// Error state containing an error and optional stack trace. -@immutable -class ResourceError implements ResourceState { - /// Creates an error state. - const ResourceError( - this.error, { - this.stackTrace, - this.isRefreshing = false, - }); - - /// The error object. - final Object error; - - /// Optional stack trace. - final StackTrace? stackTrace; - - /// Whether the resource is refreshing. - final bool isRefreshing; - - @override - int get hashCode => Object.hash(runtimeType, error, stackTrace, isRefreshing); - - @override - bool operator ==(Object other) { - return runtimeType == other.runtimeType && - other is ResourceError && - other.error == error && - other.stackTrace == stackTrace && - other.isRefreshing == isRefreshing; - } - - /// Returns a copy with updated fields. - ResourceError copyWith({ - Object? error, - StackTrace? stackTrace, - bool? isRefreshing, - }) { - return ResourceError( - error ?? this.error, - stackTrace: stackTrace ?? this.stackTrace, - isRefreshing: isRefreshing ?? this.isRefreshing, - ); - } - - @override - R map({ - required R Function(ResourceReady ready) ready, - required R Function(ResourceError error) error, - required R Function(ResourceLoading loading) loading, - }) { - return error(this); - } - - @override - String toString() { - return 'ResourceError<$T>(error: $error, stackTrace: $stackTrace, ' - 'refreshing: $isRefreshing)'; - } -} - -/// Loading state. -@immutable -class ResourceLoading implements ResourceState { - /// Creates a loading state. - const ResourceLoading(); - - @override - int get hashCode => runtimeType.hashCode; - - @override - bool operator ==(Object other) => runtimeType == other.runtimeType; - - @override - R map({ - required R Function(ResourceReady ready) ready, - required R Function(ResourceError error) error, - required R Function(ResourceLoading loading) loading, - }) { - return loading(this); - } - - @override - String toString() => 'ResourceLoading<$T>()'; -} - -/// Ready state containing data. -@immutable -class ResourceReady implements ResourceState { - /// Creates a ready state with [value]. - const ResourceReady(this.value, {this.isRefreshing = false}); - - /// The resource value. - final T value; - - /// Whether the resource is refreshing. - final bool isRefreshing; - - @override - int get hashCode => Object.hash(runtimeType, value, isRefreshing); - - @override - bool operator ==(Object other) { - return runtimeType == other.runtimeType && - other is ResourceReady && - other.value == value && - other.isRefreshing == isRefreshing; - } - - /// Returns a copy with updated fields. - ResourceReady copyWith({ - T? value, - bool? isRefreshing, - }) { - return ResourceReady( - value ?? this.value, - isRefreshing: isRefreshing ?? this.isRefreshing, - ); - } - - @override - R map({ - required R Function(ResourceReady ready) ready, - required R Function(ResourceError error) error, - required R Function(ResourceLoading loading) loading, - }) { - return ready(this); - } - - @override - String toString() { - return 'ResourceReady<$T>(value: $value, refreshing: $isRefreshing)'; - } -} - -/// {@template solidart.resource-state} -/// Represents the state of a [Resource]. -/// -/// A resource is always in one of: -/// - `ready(data)` when a value is available -/// - `loading()` while work is in progress -/// - `error(error)` when a failure occurs -/// -/// Use [ResourceStateExtensions] helpers to map or pattern-match: -/// ```dart -/// final state = resource.state; -/// final label = state.when( -/// ready: (data) => 'ready: $data', -/// error: (err, _) => 'error: $err', -/// loading: () => 'loading', -/// ); -/// ``` -/// {@endtemplate} -@sealed -@immutable -sealed class ResourceState { - /// Base constructor for resource states. - const ResourceState(); // coverage:ignore-line - - /// {@macro solidart.resource-state} - /// - /// Creates an error state. - const factory ResourceState.error( - Object error, { - StackTrace? stackTrace, - bool isRefreshing, - }) = ResourceError; - - /// {@macro solidart.resource-state} - /// - /// Creates a loading state. - const factory ResourceState.loading() = ResourceLoading; - - /// {@macro solidart.resource-state} - /// - /// Creates a ready state with [data]. - const factory ResourceState.ready(T data, {bool isRefreshing}) = - ResourceReady; - - /// Maps each concrete state to a value. - R map({ - required R Function(ResourceReady ready) ready, - required R Function(ResourceError error) error, - required R Function(ResourceLoading loading) loading, - }); -} - -/// {@template solidart.set-signal} -/// A reactive wrapper around a [Set] that copies on write. -/// -/// Mutations create a new set instance so that updates are observable: -/// ```dart -/// final set = SetSignal({1}); -/// Effect(() => print(set.length)); -/// set.add(2); // triggers effect -/// ``` -/// -/// Reads (like `length` or `contains`) establish dependencies. -/// {@endtemplate} -class SetSignal extends Signal> with SetMixin { - /// {@macro solidart.set-signal} - /// - /// Creates a reactive set with the provided initial values. - SetSignal( - Iterable initialValue, { - bool? autoDispose, - String? name, - ValueComparator> equals = identical, - bool? trackPreviousValue, - bool? trackInDevTools, - }) : super( - Set.of(initialValue), - autoDispose: autoDispose, - name: name, - equals: equals, - trackPreviousValue: trackPreviousValue, - trackInDevTools: trackInDevTools, - ); - - @override - Iterator get iterator => value.iterator; - - @override - int get length => value.length; - - @override - bool add(E value) { - final current = untrackedValue; - if (current.contains(value)) return false; - final next = Set.of(current)..add(value); - this.value = next; - return true; - } - - @override - void addAll(Iterable elements) { - if (elements.isEmpty) return; - final next = _copy()..addAll(elements); - if (next.length == untrackedValue.length) return; - value = next; - } - - @override - Set cast() => SetSignal(untrackedValue.cast()); - - @override - void clear() { - if (untrackedValue.isEmpty) return; - value = {}; - } - - @override - bool contains(Object? element) { - value; - return untrackedValue.contains(element); - } - - @override - E? lookup(Object? element) { - value; - return untrackedValue.lookup(element); - } - - @override - bool remove(Object? value) { - final current = untrackedValue; - if (!current.contains(value)) return false; - final next = Set.of(current)..remove(value); - this.value = next; - return true; - } - - @override - void removeAll(Iterable elements) { - if (elements.isEmpty) return; - final current = untrackedValue; - final next = Set.of(current)..removeAll(elements); - if (next.length == current.length) return; - value = next; - } - - @override - void removeWhere(bool Function(E element) test) { - final current = untrackedValue; - final next = Set.of(current)..removeWhere(test); - if (next.length == current.length) return; - value = next; - } - - @override - void retainAll(Iterable elements) { - final current = untrackedValue; - final next = Set.of(current)..retainAll(elements); - if (next.length == current.length) return; - value = next; - } - - @override - void retainWhere(bool Function(E element) test) { - final current = untrackedValue; - final next = Set.of(current)..retainWhere(test); - if (next.length == current.length) return; - value = next; - } - - @override - Set toSet() => Set.of(untrackedValue); - - @override - String toString() => - 'SetSignal<$E>(value: $untrackedValue, ' - 'previousValue: $untrackedPreviousValue)'; - - Set _copy() => Set.of(untrackedValue); -} - -/// {@template solidart.signal} -/// # Signals -/// Signals are the cornerstone of reactivity in v3. They store values that -/// change over time, and any reactive computation that reads a signal will -/// automatically update when the signal changes. -/// -/// Create a signal with an initial value: -/// ```dart -/// final counter = Signal(0); -/// ``` -/// -/// Read the current value: -/// ```dart -/// counter.value; // 0 -/// ``` -/// -/// Update the value: -/// ```dart -/// counter.value++; -/// // or -/// counter.value = 10; -/// ``` -/// -/// Signals support previous value tracking. When enabled, `previousValue` -/// updates only after the signal has been read at least once: -/// ```dart -/// final count = Signal(0); -/// count.value = 1; -/// count.previousValue; // null (not read yet) -/// count.value; // establishes tracking -/// count.previousValue; // 0 -/// ``` -/// -/// Signals can be created lazily using [Signal.lazy]. A lazy signal does not -/// have a value until it is first assigned, and reading it early throws -/// [StateError]. -/// {@endtemplate} -/// {@template solidart.signal-equals} -/// Updates are skipped when [equals] reports the new value is equivalent to -/// the previous one. -/// {@endtemplate} -class Signal extends preset.SignalNode> - with DisposableMixin - implements ReadonlySignal { - /// {@macro solidart.signal} - /// - /// {@macro solidart.signal-equals} - Signal( - T initialValue, { - bool? autoDispose, - String? name, - ValueComparator equals = identical, - bool? trackPreviousValue, - bool? trackInDevTools, - }) : this._internal( - Some(initialValue), - autoDispose: autoDispose, - name: name, - equals: equals, - trackPreviousValue: trackPreviousValue, - trackInDevTools: trackInDevTools, - ); - - /// {@macro solidart.signal} - /// - /// This is a lazy signal: it has no value at construction time. - /// Reading [value] before the first assignment throws [StateError]. - factory Signal.lazy({ - String? name, - bool? autoDispose, - ValueComparator equals, - bool? trackPreviousValue, - bool? trackInDevTools, - }) = LazySignal; - - Signal._internal( - Option initialValue, { - this.equals = identical, - String? name, - bool? autoDispose, - bool? trackPreviousValue, - bool? trackInDevTools, - }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, - trackPreviousValue = - trackPreviousValue ?? SolidartConfig.trackPreviousValue, - trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, - identifier = ._(name), - super( - flags: system.ReactiveFlags.mutable, - currentValue: initialValue, - pendingValue: initialValue, - ) { - _notifySignalCreation(this); - } - - @override - final bool autoDispose; - - @override - final Identifier identifier; - - @override - final ValueComparator equals; - - @override - final bool trackPreviousValue; - - @override - final bool trackInDevTools; - - Option _previousValue = const None(); - - /// Whether the signal has been initialized. - /// - /// Regular signals are always initialized at construction time. - bool get isInitialized => true; - - @override - T? get previousValue { - if (!trackPreviousValue) return null; - value; - return _previousValue.safeUnwrap(); - } - - @override - T? get untrackedPreviousValue { - if (!trackPreviousValue) return null; - return _previousValue.safeUnwrap(); - } - - @override - T get untrackedValue => super.currentValue.unwrap(); - - @override - T get value { - assert(!isDisposed, 'Signal is disposed'); - return super.get().unwrap(); - } - - /// Sets the current value. - /// - /// {@macro solidart.signal-equals} - set value(T newValue) { - assert(!isDisposed, 'Signal is disposed'); - set(Some(newValue)); - } - - @override - T call() => value; - - // TODO(nank1ro): See ReadonlySignal TODO, If `ReadonlySignal` rename - // to `ReadSignal`, the `.toReadonly` method should be rename? - @override - bool didUpdate() { - flags = system.ReactiveFlags.mutable; - final current = currentValue; - final pending = pendingValue; - if (current is Some && - pending is Some && - equals(pending.value, current.value)) { - return false; - } - - if (trackPreviousValue && current is Some) { - _previousValue = current; - } - - currentValue = pending; - _notifySignalUpdate(this); - return true; - } - - @override - void dispose() { - if (isDisposed) return; - Disposable.unlinkSubs(this); - preset.stop(this); - super.dispose(); - _notifySignalDisposal(this); - } - - /// Returns a read-only view of this signal. - ReadonlySignal toReadonly() => this; -} - -/// Common configuration for signals. -abstract interface class SignalConfiguration implements Configuration { - /// Comparator used to skip equal updates. - /// - /// When it returns `true`, the new value is treated as equal and the update - /// is skipped. - ValueComparator get equals; - - /// Whether to report to DevTools. - bool get trackInDevTools; - - /// Whether to track previous values. - /// - /// Previous values are captured on successful updates after a tracked read. - bool get trackPreviousValue; -} - -/// {@template solidart.config} -/// Global configuration for v3 reactive primitives. -/// -/// These flags provide defaults for newly created signals/effects/resources. -/// You can override them per-instance via constructor parameters. -/// {@endtemplate} -final class SolidartConfig { - const SolidartConfig._(); // coverage:ignore-line - - /// Whether nodes auto-dispose when they lose all subscribers. - /// - /// When enabled, signals/computeds/effects may dispose themselves once - /// nothing depends on them. - static bool autoDispose = false; - - /// Whether nested effects detach from parent subscriptions. - /// - /// When `true`, inner effects do not become dependencies of their parent - /// effect unless explicitly linked. - static bool detachEffects = false; - - /// Whether to track previous values by default. - /// - /// Previous values are captured only after a signal has been read at least - /// once. - static bool trackPreviousValue = true; - - /// Whether to keep values while refreshing resources. - /// - /// When `true`, a refresh marks the state as `isRefreshing` instead of - /// replacing it with `loading`. - static bool useRefreshing = true; - - /// Whether DevTools tracking is enabled. - /// - /// Signals only emit DevTools events when both this flag and - /// `trackInDevTools` are `true`. - static bool devToolsEnabled = false; - - /// Whether to assert that SignalBuilder has at least one dependency during - /// its build. Defaults to true. - /// - /// If you set this to false, you must ensure that the SignalBuilder has at - /// least one dependency, otherwise it won't rebuild when the signals change. - /// - /// The ability to disable this assertion is provided for advanced use cases - /// where you might have a SignalBuilder that builds something based on - /// disposed signals where you might be interested in their latest values. - static bool assertSignalBuilderWithoutDependencies = true; - - /// Registered observers for signal lifecycle events. - /// - /// Observers are notified only when `trackInDevTools` is enabled for the - /// signal instance. - static final observers = []; -} - -/// {@template solidart.observer} -/// Observer for signal lifecycle events. -/// -/// Use this for logging or instrumentation without depending on DevTools: -/// ```dart -/// class Logger extends SolidartObserver { -/// @override -/// void didCreateSignal(ReadonlySignal signal) { -/// print('created: ${signal.identifier.value}'); -/// } -/// @override -/// void didUpdateSignal(ReadonlySignal signal) {} -/// @override -/// void didDisposeSignal(ReadonlySignal signal) {} -/// } -/// -/// SolidartConfig.observers.add(Logger()); -/// ``` -/// {@endtemplate} -abstract class SolidartObserver { - /// {@macro solidart.observer} - const SolidartObserver(); // coverage:ignore-line - - /// Called when a signal is created. - void didCreateSignal(ReadonlySignal signal); - - /// Called when a signal is disposed. - void didDisposeSignal(ReadonlySignal signal); - - /// Called when a signal updates. - void didUpdateSignal(ReadonlySignal signal); -} - -/// A present optional value. -final class Some extends Option { - /// Creates an option that wraps [value]. - const Some(this.value); - - /// The wrapped value. - final T value; -} - -enum _DevToolsEventType { - created, - updated, - disposed, -} - -/// Observes [ReadonlySignal] changes with previous and current values. -extension ObserveSignal on ReadonlySignal { - /// Observe the signal and invoke [listener] whenever the value changes. - /// - /// When [fireImmediately] is `true`, the listener runs once on subscription. - /// Returns a disposer that stops the observation. - DisposeObservation observe( - ObserveCallback listener, { - bool fireImmediately = false, - }) { - var skipped = false; - final effect = Effect( - () { - value; - if (!fireImmediately && !skipped) { - skipped = true; - return; - } - untracked(() { - listener(untrackedPreviousValue, untrackedValue); - }); - }, - detach: true, - ); - - return effect.dispose; - } -} - -/// Waits until a signal satisfies [condition]. -extension UntilSignal on ReadonlySignal { - /// Returns a future that completes when [condition] becomes true. - /// - /// If [condition] is already true, this returns the current value - /// immediately. - /// - /// When [timeout] is provided, the returned future completes with a - /// [TimeoutException] if the condition is not met in time. - FutureOr until( - bool Function(T value) condition, { - Duration? timeout, - }) { - if (condition(value)) return value; - - final completer = Completer(); - Timer? timer; - late final Effect effect; - - void dispose() { - effect.dispose(); - timer?.cancel(); - timer = null; - } - - effect = Effect( - () { - final current = value; - if (!condition(current)) return; - dispose(); - if (!completer.isCompleted) { - completer.complete(current); - } - }, - autoDispose: false, - ); - - onDispose(dispose); - - if (timeout != null) { - timer = Timer(timeout, () { - if (completer.isCompleted) return; - dispose(); - completer.completeError(TimeoutException(null, timeout)); - }); - } - - return completer.future; - } -} - -/// Convenience accessors for [ResourceState]. -/// -/// Includes common flags (`isLoading`, `isReady`, `hasError`), casting helpers -/// (`asReady`, `asError`), and pattern matching helpers (`when`, `maybeWhen`, -/// `maybeMap`). -extension ResourceStateExtensions on ResourceState { - /// Casts to [ResourceError] if possible. - ResourceError? get asError => map( - error: (e) => e, - ready: (_) => null, - loading: (_) => null, - ); - - /// Casts to [ResourceReady] if possible. - ResourceReady? get asReady => map( - ready: (r) => r, - error: (_) => null, - loading: (_) => null, - ); - - /// Returns the error for error state. - Object? get error => map( - error: (r) => r.error, - ready: (_) => null, - loading: (_) => null, - ); - - /// Whether this state is an error. - bool get hasError => this is ResourceError; - - /// Whether this state is loading. - bool get isLoading => this is ResourceLoading; - - /// Whether this state is ready. - bool get isReady => this is ResourceReady; - - /// Whether this state is marked as refreshing. - bool get isRefreshing => switch (this) { - ResourceReady(:final isRefreshing) => isRefreshing, - ResourceError(:final isRefreshing) => isRefreshing, - ResourceLoading() => false, - }; - - /// Returns the value for ready state, throws for error state. - T? get value => map( - ready: (r) => r.value, - // ignore: only_throw_errors - error: (r) => throw r.error, - loading: (_) => null, - ); - - /// Executes callbacks for available handlers, otherwise [orElse]. - R maybeMap({ - required R Function() orElse, - R Function(ResourceReady ready)? ready, - R Function(ResourceError error)? error, - R Function(ResourceLoading loading)? loading, - }) { - return map( - ready: (r) { - if (ready != null) return ready(r); - return orElse(); - }, - error: (e) { - if (error != null) return error(e); - return orElse(); - }, - loading: (l) { - if (loading != null) return loading(l); - return orElse(); - }, - ); - } - - /// Executes callbacks for available handlers, otherwise [orElse]. - R maybeWhen({ - required R Function() orElse, - R Function(T data)? ready, - R Function(Object error, StackTrace? stackTrace)? error, - R Function()? loading, - }) { - return map( - ready: (r) { - if (ready != null) return ready(r.value); - return orElse(); - }, - error: (e) { - if (error != null) return error(e.error, e.stackTrace); - return orElse(); - }, - loading: (l) { - if (loading != null) return loading(); - return orElse(); - }, - ); - } - - /// Executes callbacks for each state. - R when({ - required R Function(T data) ready, - required R Function(Object error, StackTrace? stackTrace) error, - required R Function() loading, - }) { - return map( - ready: (r) => ready(r.value), - error: (e) => error(e.error, e.stackTrace), - loading: (_) => loading(), - ); - } -} +part 'core/typedefs.dart'; +part 'core/configuration.dart'; +part 'core/disposable.dart'; +part 'core/identifier.dart'; +part 'core/option.dart'; +part 'core/readonly_signal.dart'; +part 'core/signal_configuration.dart'; +part 'core/config.dart'; +part 'core/devtools.dart'; +part 'core/batch.dart'; +part 'core/untracked.dart'; +part 'core/observer.dart'; +part 'core/effect.dart'; +part 'core/computed.dart'; +part 'core/signal.dart'; +part 'core/lazy_signal.dart'; +part 'core/list_signal.dart'; +part 'core/map_signal.dart'; +part 'core/set_signal.dart'; +part 'extensions/observe_signal.dart'; +part 'extensions/until.dart'; +part 'resources/resource.dart'; +part 'resources/resource_state.dart'; +part 'resources/resource_state_extensions.dart'; diff --git a/packages/solidart/test/until_test.dart b/packages/solidart/test/until_test.dart index 71b6df1e..210c83f4 100644 --- a/packages/solidart/test/until_test.dart +++ b/packages/solidart/test/until_test.dart @@ -35,11 +35,14 @@ void main() { ); var completed = false; - expectLater(future, throwsA(isA())) - .whenComplete(() => completed = true); - - async.elapse(const Duration(seconds: 1)); - async.flushMicrotasks(); + expectLater( + future, + throwsA(isA()), + ).whenComplete(() => completed = true); + + async + ..elapse(const Duration(seconds: 1)) + ..flushMicrotasks(); expect(completed, isTrue); }); From facc4118a8750b7c2aba30c4e16b664d9911dfba Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 31 Jan 2026 01:41:12 +0800 Subject: [PATCH 115/121] Fix coverage ignore markers --- packages/solidart/lib/src/core/devtools.dart | 1 + packages/solidart/lib/src/core/disposable.dart | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/solidart/lib/src/core/devtools.dart b/packages/solidart/lib/src/core/devtools.dart index 09d09e04..0c23703f 100644 --- a/packages/solidart/lib/src/core/devtools.dart +++ b/packages/solidart/lib/src/core/devtools.dart @@ -175,6 +175,7 @@ dynamic _toJson(Object? obj, [int depth = 0, Set? visited]) { return jsonEncode(obj.toString()); } } +// coverage:ignore-end enum _DevToolsEventType { created, diff --git a/packages/solidart/lib/src/core/disposable.dart b/packages/solidart/lib/src/core/disposable.dart index 3a0a3c82..f99aa04f 100644 --- a/packages/solidart/lib/src/core/disposable.dart +++ b/packages/solidart/lib/src/core/disposable.dart @@ -88,4 +88,3 @@ mixin DisposableMixin implements Disposable { cleanups.add(callback); } } -// coverage:ignore-end From 9ea195ff29ab6852ed5db6659e2e339cc4105777 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 31 Jan 2026 02:15:12 +0800 Subject: [PATCH 116/121] Update migration guide for v3 API changes The migration guide has been completely rewritten to reflect the actual API changes between v2 and v3. Key updates include: - Restructured content to focus on actual public API changes rather than hypothetical ones - Added detailed comparison tables for core types, naming conventions, and API changes - Documented behavioral differences including autoDispose default change and previous value tracking - Provided specific migration examples for Signals, Effects, Resources, and Flutter integration - Added quick migration checklist for practical upgrade steps --- docs-v2/src/content/docs/migration.mdx | 314 +++++++++---------------- 1 file changed, 107 insertions(+), 207 deletions(-) diff --git a/docs-v2/src/content/docs/migration.mdx b/docs-v2/src/content/docs/migration.mdx index 72e072a2..d7462e21 100644 --- a/docs-v2/src/content/docs/migration.mdx +++ b/docs-v2/src/content/docs/migration.mdx @@ -2,268 +2,168 @@ title: Migration (v2 -> v3) description: Upgrade guide for solidart v3 --- -This guide covers the breaking changes when moving from solidart v2.x to v3. -## 1) Update package versions +This guide is generated by comparing **v2** with **v3**. It reflects the actual +public API changes in +`solidart`, `flutter_solidart`, and `solidart_hooks`. -Update all Solidart ecosystem packages together: +## 1) Update package versions (upgrade together) ```yaml # pubspec.yaml dependencies: - solidart: ^3.0.0 - flutter_solidart: ^3.0.0 - solidart_hooks: ^3.1.0 + solidart: 3.0.0-dev.0 + flutter_solidart: 3.0.0-dev.0 + solidart_hooks: 3.2.0-dev.0 dev_dependencies: - solidart_lint: ^3.0.0 + solidart_lint: 3.0.3 ``` -## 2) Imports and entry points +## 2) Entry points and exports -The public entry points stay the same in v3. Keep using the standard imports: +### solidart -```dart -import 'package:solidart/solidart.dart'; -import 'package:flutter_solidart/flutter_solidart.dart'; -``` +**v2 (main) exported:** -## 3) API mapping (v2 -> v3) +- `src/core/core.dart` +- `src/extensions/until.dart` +- `src/utils.dart` (Debouncer / SolidartException, etc.) -### Signals and ReadSignal +**v3 (current) exported:** -| v2 API | v3 API | Notes | -| --- | --- | --- | -| `ReadSignal` | `ReadonlySignal` | Read-only interface renamed. Use `toReadonly()` to create one. | -| `toReadSignal()` | `toReadonly()` | Read-only conversion rename. | -| `signal()` / `ReadSignal.call()` | `signal.value` | Call operator still works; `.value` is preferred. | -| `signal.set(value)` | `signal.value = value` | Setter only. | -| `signal.updateValue(fn)` | `signal.value = fn(signal.untrackedValue)` | Use `untrackedValue` to match v2 semantics. | -| `signal.observe(listener, fireImmediately)` | `Effect(() { ... })` | Use effects for reactive side effects. | -| `signal.until(condition, timeout)` | — | Removed. Use `Completer` + `Effect` (example below). | -| `hasPreviousValue` | — | Removed. Use `previousValue` / `untrackedPreviousValue`. | -| `SignalOptions` | named params | `name`, `autoDispose`, `equals`, `trackPreviousValue`, `trackInDevTools`. | +- `Signal`, `Computed`, `Effect`, `Resource`, `ListSignal`, `MapSignal`, `SetSignal` +- `ReadonlySignal`, `SolidartConfig`, `SolidartObserver` +- `ObserveSignal`, `UntilSignal` +- `batch`, `untracked` +- `DisposeObservation`, `ObserveCallback`, `ValueComparator` -**Until replacement (v2 -> v3):** (requires `dart:async`) -```dart -final completer = Completer(); -late final Effect effect; -effect = Effect(() { - final value = counter.value; - if (value >= 10 && !completer.isCompleted) { - completer.complete(value); - effect.dispose(); - } -}); -await completer.future; -``` +**New advanced entry point:** -### Computed - -| v2 API | v3 API | Notes | -| --- | --- | --- | -| `Computed(selector, options)` | `Computed(selector, ...)` | Options are named params. | -| `computed()` | `computed.value` | Call operator still works; `.value` is preferred. | -| `Computed.run()` | `computed.value` | No manual run; reading evaluates lazily. | - -### Effect - -| v2 API | v3 API | Notes | -| --- | --- | --- | -| `Effect((dispose) { ... }, onError, options)` | `Effect(() { ... }, autoDispose:, name:, detach:)` | Callback no longer receives `dispose`. | -| `EffectOptions(delay: ...)` | — | Delay removed; use your own debounce/timer if needed. | -| `effect()` to dispose | `effect.dispose()` | Call operator removed. | -| `Effect.manual` | `Effect.manual` | Still available; call `run()` to start tracking. | - -### Resource - -| v2 API | v3 API | Notes | -| --- | --- | --- | -| `Resource(fetcher, source: SignalBase)` | `Resource(fetcher, source: ReadonlySignal)` | `SignalBase` removed. | -| `ResourceOptions` | named params | `name`, `autoDispose`, `equals`, `trackInDevTools`, `useRefreshing`, `debounceDelay`, `trackPreviousState`. | -| `resource.update((state) => ...)` | `resource.state = ...` | Write the new state directly. | -| `resource.select(selector)` | `Computed(() => ...)` | Use `Computed` to derive data. | -| `resource.untilReady()` | — | Removed. Use `Completer` + `Effect`. | -| `ResourceState.on / maybeOn` | `when / maybeWhen` | `on`/`maybeOn` removed. | -| `ResourceState.value` / `call()` | `asReady?.value` / `when` | Use extensions and pattern matching. | -| `ResourceUnresolved` | — | Removed; initial state is `loading`. | - -### Collections - -| v2 API | v3 API | Notes | -| --- | --- | --- | -| `ListSignal` | `ListSignal` | Copy-on-write collection. | -| `SetSignal` | `SetSignal` | Copy-on-write collection. | -| `MapSignal` | `MapSignal` | Copy-on-write collection. | -| `useListSignal` | `useListSignal` | Hook for list signal. | -| `useSetSignal` | `useSetSignal` | Hook for set signal. | -| `useMapSignal` | `useMapSignal` | Hook for map signal. | - -### Flutter integration - -| v2 API | v3 API | Notes | -| --- | --- | --- | -| `ReadSignal.toValueNotifier()` | `ReadonlySignal.toValueNotifier()` | Now provided via extension. | -| `ValueListenable.toSignal()` | `ValueListenable.toSignal()` | Still available via extension. | - -## 4) Behavioral differences - -- **Auto-dispose default changed**: v2 defaults `SolidartConfig.autoDispose` to `true`, v3 defaults to `false`. If you relied on auto-dispose, enable it globally or per-instance: - ```dart - SolidartConfig.autoDispose = true; - final signal = Signal(0, autoDispose: true); - ``` -- **DevTools tracking default changed**: v2 defaults to `kDebugMode`, v3 defaults to `false`. Enable it explicitly if you need observer events: - ```dart - SolidartConfig.devToolsEnabled = true; - final signal = Signal(0, trackInDevTools: true); - ``` -- **Previous value tracking is read-driven**: `previousValue` / `previousState` update only after a tracked read. -- **Nullable previous values**: `hasPreviousValue` was removed, so `previousValue == null` can mean either “no previous value” or “previous value was null.” Use a sentinel or wrap nullable values if you need to distinguish. -- **Reactive collections are copy-on-write**: mutating methods replace the internal collection value. Avoid mutating `untrackedValue` directly. -- **Effect timing and errors**: `EffectOptions.delay` and `onError` were removed. Use `Timer`/debounce and `try/catch` inside the effect. -- **Nested effects**: v3 adds `detach` / `SolidartConfig.detachEffects` to control whether inner effects become dependencies of their parent. - -## 5) Signals: API changes - -### Read-only signals - -`toReadSignal()` and `ReadSignal` were removed. Use `ReadonlySignal` and -`toReadonly()` instead. - -**Before (v2):** ```dart -final counter = Signal(0); -final readOnly = counter.toReadSignal(); +import 'package:solidart/advanced.dart'; ``` -**After (v3):** -```dart -final counter = Signal(0); -final readOnly = counter.toReadonly(); -``` +Exports `Configuration`, `Disposable`, `DisposableMixin`, `Identifier`, +`Option`, `Some`, `None` (previously in core/utils). -### Updating values +## 3) Core API changes (main -> current) -`updateValue` is gone. Update via `.value` and your own logic. Use -`untrackedValue` if you want the old no-tracking behavior. +### 3.1 Types and naming -**Before (v2):** -```dart -counter.updateValue((value) => value + 1); -``` +| v2 (main) | v3 (current) | Notes | +| ------------------------------------- | --------------------------------- | ---------------------------------------------- | +| `SignalBase` | `ReadonlySignal` / `Signal` | `SignalBase` removed | +| `ReadSignal` / `ReadableSignal` | `ReadonlySignal` | ReadSignal types removed | +| `toReadSignal()` | `toReadonly()` | Rename | +| `disposed` | `isDisposed` | Rename | +| `hasValue` | `LazySignal.isInitialized` | Normal `Signal` is always initialized | +| `hasPreviousValue` | Removed | Use `previousValue` / `untrackedPreviousValue` | +| `name` | `identifier.name` | `name` field removed from public API | -**After (v3):** -```dart -counter.value = counter.untrackedValue + 1; -``` +### 3.2 Signal updates -### Observing changes +**Removed:** `updateValue` / `setValue` public API. -`observe` was removed. Use `Effect` for reactive side effects. +Use `.value` assignment instead. -**Before (v2):** ```dart -counter.observe((prev, next) { - print('from $prev to $next'); -}); -``` +// v2 +counter.updateValue((v) => v + 1); -**After (v3):** -```dart -Effect(() { - final next = counter.value; - final prev = counter.untrackedPreviousValue; - print('from $prev to $next'); -}); +// v3 +counter.value = counter.untrackedValue + 1; ``` -### Previous values +**Comparator change:** -`previousValue` now updates only after a tracked read. You also get -`untrackedPreviousValue` and can disable tracking with `trackPreviousValue`. +- v2 had `equals` (bool) + `comparator` +- v3 keeps only a comparator: ```dart -final counter = Signal(0, trackPreviousValue: true); -final _ = counter.value; // tracked read -counter.value = 1; -print(counter.previousValue); // 0 +final s = Signal(0, equals: (a, b) => a == b); ``` -## 6) Reactive collections +### 3.3 Effect -`ReactiveList`, `ReactiveMap`, and `ReactiveSet` have been replaced by -`ListSignal`, `MapSignal`, and `SetSignal`. +**v2:** `Effect(callback, delay:, onError:, autorun:)` and `effect()` to dispose. +**v3:** `delay`, `onError`, `autorun` removed. Dispose via `effect.dispose()`. -**Before (v2):** ```dart -final todos = ReactiveList([]); -``` +// v2 +final dispose = Effect(() { ... }, delay: ..., onError: ...); +dispose(); -**After (v3):** -```dart -final todos = ListSignal([]); +// v3 +final effect = Effect(() { ... }); +effect.dispose(); ``` -## 7) Untracked and batch helpers +If you need delayed/debounced effects, use `Timer`/debounce in user code. -Use the new global helpers to opt out of tracking or batch updates: +### 3.4 Resource + +- `source` now accepts `ReadonlySignal` instead of `SignalBase`. +- `update` / `updateValue` removed → set `state` directly. +- `ResourceExtensions` replaced by `ResourceStateExtensions`. +- `on()` is removed (was deprecated in v2). +- `resolve()` is now public (v2 had private `_resolve()`). ```dart -untracked(() { - // reads here do not create dependencies - print(counter.value); -}); - -batch(() { - counter.value++; - other.value++; -}); +// v2 +resource.update((state) => state); + +// v3 +resource.state = ResourceState.ready(value); ``` -## 8) Resource updates +### 3.5 Utils removal -Resources keep the same concept but have a clearer API: +`solidart.dart` no longer exports: -- `Resource.state` is the primary value. -- `previousState` / `untrackedPreviousState` are available for history. -- `Resource.stream` is the stream-based constructor. -- `debounceDelay` debounces source-triggered refreshes. -- `useRefreshing` controls `isRefreshing` behavior. +- `Debouncer` / `DebounceOperation` +- `SolidartException`, `SolidartReactionException`, `SolidartCaughtException` +- `createDelayedScheduler` -```dart -final userId = Signal(1); -final user = Resource(fetchUser, source: userId, debounceDelay: Duration(milliseconds: 300)); - -SignalBuilder( - builder: (_, __) => user.state.when( - ready: (data) => Text(data.name), - loading: () => const CircularProgressIndicator(), - error: (e, _) => Text('Error: $e'), - ), -); -``` +If you relied on them, reimplement in your app or wrap them in a utility layer. -## 9) Flutter integration +## 4) Flutter integration (flutter_solidart) -`flutter_solidart` now re-exports `solidart/solidart.dart` directly and only -adds Flutter widgets and extensions. Core wrappers were removed. +- `ReadableSignal` removed; use `ReadonlySignal`. +- `ValueNotifierSignalMixin` is no longer exported. -New conversion helpers: +Extensions changed: -```dart -final notifier = counter.toReadonly().toValueNotifier(); -final signal = someValueListenable.toSignal(); -``` +- v2: `SignalBase.toValueNotifier()` and `ValueNotifier.toSignal()` +- v3: `ReadonlySignal.toValueNotifier()` and `ValueListenable.toSignal()` + +## 5) Hooks (solidart_hooks) + +- `ReadSignal` → `ReadonlySignal` +- `equals/comparator` replaced by `equals: ValueComparator` + +Lifecycle change: + +- v2: dispose on unmount only if `autoDispose` was true +- v3: hook-created signals are **always** disposed on unmount + +Use `useExistingSignal(...)` to bind external signals without disposing them. + +## 6) Behavioral differences -## 10) Removed APIs (summary) +- `SolidartConfig.autoDispose` default changed **true → false** +- `SolidartConfig.equals` removed +- `previousValue` updates only after a tracked read +- `LazySignal` throws if read before first assignment (check `isInitialized`) -- `toReadSignal`, `ReadSignal`, `SignalBase` -- `SignalOptions`, `ResourceOptions`, `EffectOptions` -- `updateValue`, `observe`, `until` -- `ReactiveList`, `ReactiveMap`, `ReactiveSet` -- `Debouncer` and other v2-only utils +## 7) Quick migration checklist -If you hit a missing API, prefer the v3 primitives (`Signal`, `Computed`, -`Effect`, `Resource`) and the new helpers (`untracked`, `batch`). +- [ ] Upgrade versions in all solidart ecosystem packages +- [ ] Replace `ReadSignal`/`SignalBase` with `ReadonlySignal` +- [ ] Replace `toReadSignal()` with `toReadonly()` +- [ ] Remove `updateValue` usages (use `.value = ...`) +- [ ] Update Effect usage (no `delay/onError`, dispose via `.dispose()`) +- [ ] Update Resource state handling (`state =`, `when/maybeWhen`) +- [ ] Update Flutter extensions (`toValueNotifier`/`toSignal`) +- [ ] Adjust hooks disposal expectations From a666b1498fb7d1da199174680431b995e7ff77fa Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 31 Jan 2026 02:16:44 +0800 Subject: [PATCH 117/121] Update migration guide with correct v3 counter increment example --- docs-v2/src/content/docs/migration.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-v2/src/content/docs/migration.mdx b/docs-v2/src/content/docs/migration.mdx index d7462e21..fb0a12da 100644 --- a/docs-v2/src/content/docs/migration.mdx +++ b/docs-v2/src/content/docs/migration.mdx @@ -73,7 +73,7 @@ Use `.value` assignment instead. counter.updateValue((v) => v + 1); // v3 -counter.value = counter.untrackedValue + 1; +counter.value += 1; ``` **Comparator change:** From 9b1b9b7d4821e77ce0c57db1d00b297a85b0d46c Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 31 Jan 2026 02:51:10 +0800 Subject: [PATCH 118/121] Fix lazy signal update and resource refresh --- packages/solidart/lib/src/core/lazy_signal.dart | 4 ++++ packages/solidart/lib/src/resources/resource.dart | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/solidart/lib/src/core/lazy_signal.dart b/packages/solidart/lib/src/core/lazy_signal.dart index 0855ff5f..c80ce154 100644 --- a/packages/solidart/lib/src/core/lazy_signal.dart +++ b/packages/solidart/lib/src/core/lazy_signal.dart @@ -44,7 +44,11 @@ class LazySignal extends Signal { bool didUpdate() { if (!isInitialized) { flags = system.ReactiveFlags.mutable; + if (trackPreviousValue) { + _previousValue = const None(); + } currentValue = pendingValue; + _notifySignalUpdate(this); return true; } diff --git a/packages/solidart/lib/src/resources/resource.dart b/packages/solidart/lib/src/resources/resource.dart index 28249bac..1c267213 100644 --- a/packages/solidart/lib/src/resources/resource.dart +++ b/packages/solidart/lib/src/resources/resource.dart @@ -179,6 +179,7 @@ class Resource extends Signal> { /// If the resource has not been resolved yet, this triggers [resolve] /// instead. Future refresh() async { + if (isDisposed) return; if (!_resolved) { await resolve(); return; From 170e09957ffa5025423b535d5fca9ba66b8b6c6e Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 31 Jan 2026 03:07:57 +0800 Subject: [PATCH 119/121] Add observe callbacks to collection signal examples --- .../example/lib/pages/list_signal.dart | 10 ++++++++++ .../flutter_solidart/example/lib/pages/map_signal.dart | 10 ++++++++++ .../flutter_solidart/example/lib/pages/set_signal.dart | 10 ++++++++++ 3 files changed, 30 insertions(+) diff --git a/packages/flutter_solidart/example/lib/pages/list_signal.dart b/packages/flutter_solidart/example/lib/pages/list_signal.dart index ff00861e..b300968a 100644 --- a/packages/flutter_solidart/example/lib/pages/list_signal.dart +++ b/packages/flutter_solidart/example/lib/pages/list_signal.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'dart:math'; import 'package:flutter/material.dart'; @@ -13,6 +15,14 @@ class ListSignalPage extends StatefulWidget { class _ListSignalPageState extends State { final items = ListSignal([1, 2], name: 'items'); + @override + void initState() { + super.initState(); + items.observe((previousValue, value) { + print('项目已更改:$previousValue -> $value'); + }); + } + @override void dispose() { items.dispose(); diff --git a/packages/flutter_solidart/example/lib/pages/map_signal.dart b/packages/flutter_solidart/example/lib/pages/map_signal.dart index 0dac848a..dd8c4563 100644 --- a/packages/flutter_solidart/example/lib/pages/map_signal.dart +++ b/packages/flutter_solidart/example/lib/pages/map_signal.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'dart:math'; import 'package:flutter/material.dart'; @@ -15,6 +17,14 @@ class MapSignalPage extends StatefulWidget { class _MapSignalPageState extends State { final items = MapSignal({'a': 1, 'b': 2}, name: 'items'); + @override + void initState() { + super.initState(); + items.observe((previousValue, value) { + print('项目已更改:$previousValue -> $value'); + }); + } + @override void dispose() { items.dispose(); diff --git a/packages/flutter_solidart/example/lib/pages/set_signal.dart b/packages/flutter_solidart/example/lib/pages/set_signal.dart index 5ddfc1bc..d8a3fa74 100644 --- a/packages/flutter_solidart/example/lib/pages/set_signal.dart +++ b/packages/flutter_solidart/example/lib/pages/set_signal.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'dart:math'; import 'package:flutter/material.dart'; @@ -13,6 +15,14 @@ class SetSignalPage extends StatefulWidget { class _SetSignalPageState extends State { final items = SetSignal({1, 2}, name: 'items'); + @override + void initState() { + super.initState(); + items.observe((previousValue, value) { + print('项目已更改:$previousValue -> $value'); + }); + } + @override void dispose() { items.dispose(); From 1af3f35789f0cf9a25c4d7f938ca521c9c70b493 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 31 Jan 2026 03:17:48 +0800 Subject: [PATCH 120/121] Translate Chinese debug messages to English in example pages --- packages/flutter_solidart/example/lib/pages/list_signal.dart | 2 +- packages/flutter_solidart/example/lib/pages/map_signal.dart | 2 +- packages/flutter_solidart/example/lib/pages/set_signal.dart | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/flutter_solidart/example/lib/pages/list_signal.dart b/packages/flutter_solidart/example/lib/pages/list_signal.dart index b300968a..edb15829 100644 --- a/packages/flutter_solidart/example/lib/pages/list_signal.dart +++ b/packages/flutter_solidart/example/lib/pages/list_signal.dart @@ -19,7 +19,7 @@ class _ListSignalPageState extends State { void initState() { super.initState(); items.observe((previousValue, value) { - print('项目已更改:$previousValue -> $value'); + print('Items changed: $previousValue -> $value'); }); } diff --git a/packages/flutter_solidart/example/lib/pages/map_signal.dart b/packages/flutter_solidart/example/lib/pages/map_signal.dart index dd8c4563..17acca54 100644 --- a/packages/flutter_solidart/example/lib/pages/map_signal.dart +++ b/packages/flutter_solidart/example/lib/pages/map_signal.dart @@ -21,7 +21,7 @@ class _MapSignalPageState extends State { void initState() { super.initState(); items.observe((previousValue, value) { - print('项目已更改:$previousValue -> $value'); + print('Items changed: $previousValue -> $value'); }); } diff --git a/packages/flutter_solidart/example/lib/pages/set_signal.dart b/packages/flutter_solidart/example/lib/pages/set_signal.dart index d8a3fa74..4e16bb55 100644 --- a/packages/flutter_solidart/example/lib/pages/set_signal.dart +++ b/packages/flutter_solidart/example/lib/pages/set_signal.dart @@ -19,7 +19,7 @@ class _SetSignalPageState extends State { void initState() { super.initState(); items.observe((previousValue, value) { - print('项目已更改:$previousValue -> $value'); + print('Items changed: $previousValue -> $value'); }); } From 9fe9d76d810ff21a6a4b6f2f29ba220f1e9c1213 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 31 Jan 2026 03:22:32 +0800 Subject: [PATCH 121/121] Simplify example signal logging --- packages/flutter_solidart/example/lib/main.dart | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/flutter_solidart/example/lib/main.dart b/packages/flutter_solidart/example/lib/main.dart index 5744bfd5..ef2f8165 100644 --- a/packages/flutter_solidart/example/lib/main.dart +++ b/packages/flutter_solidart/example/lib/main.dart @@ -18,7 +18,9 @@ import 'package:flutter_solidart/flutter_solidart.dart'; class Logger implements SolidartObserver { @override void didCreateSignal(ReadonlySignal signal) { - final value = _safeValue(signal); + final value = signal is Signal && !signal.isInitialized + ? 'uninitialized' + : signal.value; dev.log('didCreateSignal(name: ${signal.identifier.name}, value: $value)'); } @@ -30,22 +32,11 @@ class Logger implements SolidartObserver { @override void didUpdateSignal(ReadonlySignal signal) { dev.log( - 'didUpdateSignal(name: ${signal.identifier.name}, previousValue: ${signal.previousValue}, value: ${_safeValue(signal)})', + 'didUpdateSignal(name: ${signal.identifier.name}, previousValue: ${signal.previousValue}, value: ${signal.value})', ); } } -Object? _safeValue(ReadonlySignal signal) { - if (signal is LazySignal && !signal.isInitialized) { - return 'uninitialized'; - } - try { - return signal.value; - } on StateError { - return 'uninitialized'; - } -} - void main() { SolidartConfig.observers.add(Logger()); runApp(const MyApp());