From 72eee5a518fa57f76ff02d45b63d9e16969d9941 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:35:13 +0800 Subject: [PATCH 01/25] refactor(solidart): upgrade alien_signals adapter --- packages/solidart/lib/src/core/alien.dart | 87 +++++---- packages/solidart/lib/src/core/computed.dart | 2 +- packages/solidart/lib/src/core/core.dart | 3 +- packages/solidart/lib/src/core/effect.dart | 9 +- .../lib/src/core/reactive_system.dart | 168 ++++-------------- .../solidart/lib/src/core/read_signal.dart | 5 +- packages/solidart/pubspec.yaml | 2 +- 7 files changed, 96 insertions(+), 180 deletions(-) diff --git a/packages/solidart/lib/src/core/alien.dart b/packages/solidart/lib/src/core/alien.dart index d6417dfa..af50c88d 100644 --- a/packages/solidart/lib/src/core/alien.dart +++ b/packages/solidart/lib/src/core/alien.dart @@ -1,74 +1,87 @@ 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.ComputedNode { + _AlienComputed(this.parent, T Function(T? oldValue) getter) + : super( + flags: alien_system.ReactiveFlags.none, + getter: getter, + ); final Computed parent; - final T Function(T? oldValue) getter; - T? value; + void dispose() => alien.stop(this); - void dispose() => reactiveSystem.stopEffect(this); + bool update() => didUpdate(); @override - bool update() { - final prevSub = reactiveSystem.setCurrentSub(this); - reactiveSystem.startTracking(this); + bool didUpdate() { + if ((flags & _hasChildEffect) != alien_system.ReactiveFlags.none) { + alien.disposeChildDepsInReverse(this); + } + + depsTail = null; + flags = + alien_system.ReactiveFlags.mutable | + alien_system.ReactiveFlags.recursedCheck; + final prevSub = alien.setActiveSub(this); try { - final oldValue = value; - return oldValue != (value = getter(oldValue)); + ++alien.cycle; + final oldValue = currentValue; + currentValue = getter(oldValue); + return oldValue != currentValue; } finally { - reactiveSystem - ..setCurrentSub(prevSub) - ..endTracking(this); + alien.activeSub = prevSub; + flags &= -5 /* ~ReactiveFlags.recursedCheck */; + alien.purgeDeps(this); } } } -class _AlienEffect extends alien.ReactiveNode { - _AlienEffect(this.parent, this.run, {bool? detach}) +class _AlienEffect extends alien.EffectNode { + _AlienEffect(this.parent, void Function() run, {bool? detach}) : detach = detach ?? SolidartConfig.detachEffects, - super(flags: 2 /* Watching */); - - _AlienEffect? nextEffect; + super(flags: alien_system.ReactiveFlags.watching, fn: run); final bool detach; final Effect parent; - final void Function() run; - void dispose() => reactiveSystem.stopEffect(this); + void run() => fn(); + + void dispose() => alien.stopEffect(this); } -class _AlienSignal extends alien.ReactiveNode implements _AlienUpdatable { - _AlienSignal(this.parent, this.value) - : previousValue = value, - super(flags: 1 /* Mutable */); +class _AlienSignal extends alien.SignalNode> { + _AlienSignal(this.parent, Option value) + : super( + flags: alien_system.ReactiveFlags.mutable, + currentValue: value, + pendingValue: value, + ); final SignalBase parent; - Option previousValue; - Option value; - bool forceDirty = false; + bool update() => didUpdate(); + @override - bool update() { - flags = 1 /* Mutable */; + bool didUpdate() { + final previousValue = currentValue; + currentValue = pendingValue; + flags = alien_system.ReactiveFlags.mutable; + if (forceDirty) { forceDirty = false; return true; } - if (!parent._compare(previousValue.safeUnwrap(), value.safeUnwrap())) { - previousValue = value; + + if (!parent._compare( + previousValue.safeUnwrap(), + currentValue.safeUnwrap(), + )) { return true; } return false; } } - -// ignore: one_member_abstracts -abstract interface class _AlienUpdatable { - bool update(); -} diff --git a/packages/solidart/lib/src/core/computed.dart b/packages/solidart/lib/src/core/computed.dart index dbc80fc6..2cc93502 100644 --- a/packages/solidart/lib/src/core/computed.dart +++ b/packages/solidart/lib/src/core/computed.dart @@ -132,7 +132,7 @@ class Computed extends ReadSignal { return true; } - final _deps = {}; + final _deps = {}; @override void dispose() { diff --git a/packages/solidart/lib/src/core/core.dart b/packages/solidart/lib/src/core/core.dart index 18a6a069..04adce7c 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; +import 'package:alien_signals/system.dart' as alien_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 c9afae32..ab240f5a 100644 --- a/packages/solidart/lib/src/core/effect.dart +++ b/packages/solidart/lib/src/core/effect.dart @@ -164,11 +164,11 @@ 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; + alien_system.ReactiveNode get subscriber => _internalEffect; @override bool get disposed => _disposed; @@ -180,10 +180,11 @@ class Effect implements ReactionInterface { if (currentSub is! _AlienEffect || (!_internalEffect.detach && !currentSub.detach)) { reactiveSystem.link(_internalEffect, currentSub); + currentSub.flags |= _hasChildEffect; } } - final prevSub = reactiveSystem.setCurrentSub(_internalEffect); + final prevSub = reactiveSystem.setCurrentSub(_internalEffect); reactiveSystem.startBatch(); try { _internalEffect.run(); @@ -205,7 +206,7 @@ class Effect implements ReactionInterface { /// Sets the dependencies of the effect, do not use it directly. @internal - void setDependencies(alien.ReactiveNode node) { + void setDependencies(alien_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 25d88943..0006d78b 100644 --- a/packages/solidart/lib/src/core/reactive_system.dart +++ b/packages/solidart/lib/src/core/reactive_system.dart @@ -3,17 +3,19 @@ // 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() { +const _hasChildEffect = 64 as alien_system.ReactiveFlags; + +extension MayDisposeDependencies on alien_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) { @@ -46,158 +48,56 @@ class ReactiveName { @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; - } - } - } +class ReactiveSystem { + int get batchDepth => alien.getBatchDepth(); - @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); - } - } + alien_system.ReactiveNode? get activeSub => alien.getActiveSub(); - @override - bool update(alien.ReactiveNode node) { - assert( - node is _AlienUpdatable, - 'Reactive node type must be signal or computed', - ); - return (node as _AlienUpdatable).update(); + set activeSub(alien_system.ReactiveNode? sub) { + alien.setActiveSub(sub); } - void startBatch() => ++batchDepth; - void endBatch() { - if ((--batchDepth) == 0) flush(); + alien_system.ReactiveNode? setCurrentSub(alien_system.ReactiveNode? sub) { + return alien.setActiveSub(sub); } - alien.ReactiveNode? setCurrentSub(alien.ReactiveNode? sub) { - final prevSub = activeSub; - activeSub = sub; - return prevSub; + void startBatch() => alien.startBatch(); + + void endBatch() => alien.endBatch(); + + void link(alien_system.ReactiveNode dep, alien_system.ReactiveNode sub) { + alien.link(dep, sub, alien.cycle); } 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!); + if ((computed.flags & alien_system.ReactiveFlags.pending) != + alien_system.ReactiveFlags.none && + computed.deps == null) { + computed.flags &= ~alien_system.ReactiveFlags.pending; } - return computed.value as T; + return computed.get(); } 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; + return signal.get(); } 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(); - } - } + signal.set(value); } - 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 stopEffect(alien_system.ReactiveNode effect) { + alien.stop(effect); } - 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 runEffect(_AlienEffect effect) { + alien.run(effect); } - 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 */); - } + void propagate(alien_system.Link link) { + alien.propagate(link, alien.runDepth > 0); } + + void flush() => alien.flush(); } diff --git a/packages/solidart/lib/src/core/read_signal.dart b/packages/solidart/lib/src/core/read_signal.dart index 9f1b9cec..c256d5da 100644 --- a/packages/solidart/lib/src/core/read_signal.dart +++ b/packages/solidart/lib/src/core/read_signal.dart @@ -260,7 +260,7 @@ class ReadableSignal implements ReadSignal { @override int get listenerCount => _subs.length; - final _subs = {}; + final _subs = {}; @override void dispose() { @@ -337,7 +337,8 @@ class ReadableSignal implements ReadSignal { /// use [reactiveSystem.setSignalValue] instead. void _reportChanged() { _internalSignal.forceDirty = true; - _internalSignal.flags = 17 /* Mutable | Dirty */; + _internalSignal.flags = + alien_system.ReactiveFlags.mutable | alien_system.ReactiveFlags.dirty; final subs = _internalSignal.subs; if (subs != null) { // coverage:ignore-start diff --git a/packages/solidart/pubspec.yaml b/packages/solidart/pubspec.yaml index 2b328347..7e1b0bcb 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.3.1 collection: ^1.18.0 meta: ^1.11.0 From 346d701781846c6a47bea6218d9c9f8dc2e9700a Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:42:29 +0800 Subject: [PATCH 02/25] refactor(flutter_solidart): use reactive sub helper --- .../flutter_solidart/lib/src/widgets/signal_builder.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 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..d64ef71a 100644 --- a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart +++ b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart @@ -83,9 +83,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 node = effect.subscriber; + final prevSub = reactiveSystem.setCurrentSub(node); try { final built = super.build(); @@ -101,7 +101,7 @@ You can disable this check by setting `SolidartConfig.assertSignalBuilderWithout return built; } finally { - reactiveSystem.activeSub = prevSub; + reactiveSystem.setCurrentSub(prevSub); } } } From e3e07cba200a3a725d94e41762178a667ace63be Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:49:33 +0800 Subject: [PATCH 03/25] test(flutter_solidart): stabilize ci widget taps --- packages/flutter_solidart/test/flutter_solidart_test.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/flutter_solidart/test/flutter_solidart_test.dart b/packages/flutter_solidart/test/flutter_solidart_test.dart index a7ec346a..1cb52593 100644 --- a/packages/flutter_solidart/test/flutter_solidart_test.dart +++ b/packages/flutter_solidart/test/flutter_solidart_test.dart @@ -554,6 +554,7 @@ void main() { await tester.pumpWidget( MaterialApp( + theme: ThemeData(splashFactory: NoSplash.splashFactory), home: Scaffold( body: ProviderScope( providers: [counterProvider], @@ -614,6 +615,7 @@ void main() { await tester.pumpWidget( MaterialApp( + theme: ThemeData(splashFactory: NoSplash.splashFactory), home: Scaffold( body: ProviderScope( providers: [ @@ -652,6 +654,7 @@ void main() { final counterProvider = Provider((_) => Signal(0)); await tester.pumpWidget( MaterialApp( + theme: ThemeData(splashFactory: NoSplash.splashFactory), home: Scaffold( body: ProviderScope( providers: [ @@ -686,10 +689,10 @@ void main() { }); testWidgets('(ArgProvider) Signal.updateValue method', (tester) async { - // ignore: avoid_types_on_closure_parameters final counterProvider = Provider.withArgument((_, int n) => Signal(n)); await tester.pumpWidget( MaterialApp( + theme: ThemeData(splashFactory: NoSplash.splashFactory), home: Scaffold( body: ProviderScope( providers: [ From 497aa4e5ec6e351ab8f53c6f3495175acf5397d9 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:56:55 +0800 Subject: [PATCH 04/25] test(solidart): cover alien adapter helpers --- .../lib/src/core/reactive_system.dart | 4 +- packages/solidart/test/solidart_test.dart | 79 ++++++++++++++++++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/solidart/lib/src/core/reactive_system.dart b/packages/solidart/lib/src/core/reactive_system.dart index 0006d78b..abb29126 100644 --- a/packages/solidart/lib/src/core/reactive_system.dart +++ b/packages/solidart/lib/src/core/reactive_system.dart @@ -91,8 +91,8 @@ class ReactiveSystem { alien.stop(effect); } - void runEffect(_AlienEffect effect) { - alien.run(effect); + void runEffect(alien_system.ReactiveNode effect) { + alien.run(effect as _AlienEffect); } void propagate(alien_system.Link link) { diff --git a/packages/solidart/test/solidart_test.dart b/packages/solidart/test/solidart_test.dart index 10698dc2..a1938590 100644 --- a/packages/solidart/test/solidart_test.dart +++ b/packages/solidart/test/solidart_test.dart @@ -1,4 +1,5 @@ -// ignore_for_file: cascade_invocations, unreachable_from_main +// ignore_for_file: cascade_invocations, invalid_use_of_protected_member +// ignore_for_file: unreachable_from_main import 'dart:async'; import 'dart:math'; @@ -2106,6 +2107,82 @@ void main() { b.value = 0; }); }); + + test('should dispose computed child effects before recomputing', () { + final source = Signal(true); + final child = Signal(0); + var childRuns = 0; + + final computed = Computed(() { + if (source.value) { + Effect(() { + child.value; + childRuns++; + }); + } + return source.value; + }); + + addTearDown(() { + computed.dispose(); + source.dispose(); + child.dispose(); + }); + + expect(computed.value, isTrue); + expect(childRuns, equals(1)); + + child.value++; + expect(childRuns, equals(2)); + + source.value = false; + expect(computed.value, isFalse); + + child.value++; + expect(childRuns, equals(2)); + }); + + test( + 'reactive system compatibility helpers delegate to alien runtime', + () { + final signal = Signal(0); + var effectRuns = 0; + final effect = Effect( + () { + signal.value; + effectRuns++; + }, + autorun: false, + autoDispose: false, + ); + + final previousSub = reactiveSystem.setCurrentSub(null); + addTearDown(() { + reactiveSystem.setCurrentSub(previousSub); + effect.dispose(); + signal.dispose(); + }); + + expect(reactiveSystem.batchDepth, equals(0)); + reactiveSystem.activeSub = effect.subscriber; + expect(reactiveSystem.activeSub, same(effect.subscriber)); + reactiveSystem.activeSub = null; + + reactiveSystem.runEffect(effect.subscriber); + effect.run(); + expect(effectRuns, greaterThanOrEqualTo(1)); + + final subs = effect.subscriber.deps!.dep.subs; + expect(subs, isNotNull); + reactiveSystem.propagate(subs!); + reactiveSystem.flush(); + + final runsAfterFlush = effectRuns; + reactiveSystem.stopEffect(effect.subscriber); + signal.value++; + expect(effectRuns, equals(runsAfterFlush)); + }, + ); }, timeout: const Timeout(Duration(seconds: 1)), ); From 194101b041d9806aa8b8bb69cbbfc73fb0cb08f3 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:09:02 +0800 Subject: [PATCH 05/25] test: satisfy ci analyzer rules --- .../test/flutter_solidart_test.dart | 30 ++------------ packages/solidart/analysis_options.yaml | 1 + packages/solidart/test/solidart_test.dart | 40 +++++-------------- 3 files changed, 16 insertions(+), 55 deletions(-) diff --git a/packages/flutter_solidart/test/flutter_solidart_test.dart b/packages/flutter_solidart/test/flutter_solidart_test.dart index 1cb52593..0bf01b35 100644 --- a/packages/flutter_solidart/test/flutter_solidart_test.dart +++ b/packages/flutter_solidart/test/flutter_solidart_test.dart @@ -1,4 +1,4 @@ -// ignore_for_file: document_ignores, unreachable_from_main, discarded_futures +// ignore_for_file: document_ignores, discarded_futures import 'dart:async'; @@ -7,30 +7,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_solidart/flutter_solidart.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; - -// Used in Solid providers tests -abstract class NameContainer { - const NameContainer(this.name); - - final String name; - - void dispose(); -} - -class MockNameContainer extends Mock implements NameContainer { - MockNameContainer(this.name); - - @override - final String name; -} - -@immutable -class NumberContainer { - const NumberContainer(this.number); - - final int number; -} void main() { testWidgets('(Provider) Not found signal throws an error', (tester) async { @@ -689,7 +665,9 @@ void main() { }); testWidgets('(ArgProvider) Signal.updateValue method', (tester) async { - final counterProvider = Provider.withArgument((_, int n) => Signal(n)); + final counterProvider = Provider.withArgument, int>( + (_, n) => Signal(n), + ); await tester.pumpWidget( MaterialApp( theme: ThemeData(splashFactory: NoSplash.splashFactory), diff --git a/packages/solidart/analysis_options.yaml b/packages/solidart/analysis_options.yaml index c8299567..642c1f5a 100644 --- a/packages/solidart/analysis_options.yaml +++ b/packages/solidart/analysis_options.yaml @@ -4,6 +4,7 @@ analyzer: discarded_futures: ignore document_ignores: ignore prefer_foreach: ignore + unreachable_from_main: ignore include: package:very_good_analysis/analysis_options.yaml linter: diff --git a/packages/solidart/test/solidart_test.dart b/packages/solidart/test/solidart_test.dart index a1938590..7d7611a4 100644 --- a/packages/solidart/test/solidart_test.dart +++ b/packages/solidart/test/solidart_test.dart @@ -1,5 +1,4 @@ // ignore_for_file: cascade_invocations, invalid_use_of_protected_member -// ignore_for_file: unreachable_from_main import 'dart:async'; import 'dart:math'; @@ -12,20 +11,6 @@ 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(); } @@ -66,11 +51,6 @@ class User { String toString() => 'User(id: $id)'; } -class SampleList { - SampleList(this.numbers); - final List numbers; -} - class MockSolidartObserver extends Mock implements SolidartObserver {} void main() { @@ -553,17 +533,19 @@ void main() { expect(doubled.untrackedValue, 10); }); - test('untrackedValue returns up-to-date value after dependency changes', - () { - final counter = Signal(5); - final doubled = Computed(() => counter.value * 2); + test( + 'untrackedValue returns up-to-date value after dependency changes', + () { + final counter = Signal(5); + final doubled = Computed(() => counter.value * 2); - doubled.hasValue; - expect(doubled.untrackedValue, 10); + doubled.hasValue; + expect(doubled.untrackedValue, 10); - counter.value = 20; - expect(doubled.untrackedValue, 40); - }); + counter.value = 20; + expect(doubled.untrackedValue, 40); + }, + ); test('untrackedValue asserts if accessed before computation', () { final counter = Signal(5); From 1457eeb9bc368946b562cf9775b8502717b90fa6 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:19:36 +0800 Subject: [PATCH 06/25] fix(solidart): preserve adapter comparison semantics --- packages/solidart/analysis_options.yaml | 1 - packages/solidart/lib/src/core/alien.dart | 6 +- packages/solidart/test/solidart_test.dart | 67 +++++++++++++++++++++-- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/packages/solidart/analysis_options.yaml b/packages/solidart/analysis_options.yaml index 642c1f5a..c8299567 100644 --- a/packages/solidart/analysis_options.yaml +++ b/packages/solidart/analysis_options.yaml @@ -4,7 +4,6 @@ analyzer: discarded_futures: ignore document_ignores: ignore prefer_foreach: ignore - unreachable_from_main: ignore include: package:very_good_analysis/analysis_options.yaml linter: diff --git a/packages/solidart/lib/src/core/alien.dart b/packages/solidart/lib/src/core/alien.dart index af50c88d..28fbf16e 100644 --- a/packages/solidart/lib/src/core/alien.dart +++ b/packages/solidart/lib/src/core/alien.dart @@ -28,7 +28,7 @@ class _AlienComputed extends alien.ComputedNode { ++alien.cycle; final oldValue = currentValue; currentValue = getter(oldValue); - return oldValue != currentValue; + return !parent._compare(oldValue, currentValue); } finally { alien.activeSub = prevSub; flags &= -5 /* ~ReactiveFlags.recursedCheck */; @@ -75,6 +75,10 @@ class _AlienSignal extends alien.SignalNode> { return true; } + if (previousValue is None || currentValue is None) { + return previousValue is! None || currentValue is! None; + } + if (!parent._compare( previousValue.safeUnwrap(), currentValue.safeUnwrap(), diff --git a/packages/solidart/test/solidart_test.dart b/packages/solidart/test/solidart_test.dart index 7d7611a4..dcff9b3f 100644 --- a/packages/solidart/test/solidart_test.dart +++ b/packages/solidart/test/solidart_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'dart:math'; +import 'package:alien_signals/system.dart' as alien_system; import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; import 'package:mockito/mockito.dart'; @@ -318,6 +319,25 @@ void main() { expect(signal.hasValue, true); }); + test('lazy nullable Signal notifies when initialized with null', () { + final signal = Signal.lazy(); + var runs = 0; + + final dispose = Effect(() { + signal.hasValue; + runs++; + }); + addTearDown(dispose); + + expect(runs, equals(1)); + expect(signal.hasValue, false); + + signal.value = null; + + expect(signal.hasValue, true); + expect(runs, equals(2)); + }); + test( '''lazy Signal trows StateError when accessing value before setting one''', () { @@ -438,7 +458,7 @@ void main() { signal1.value = 1; await pumpEventQueue(); - verify(cb(1)).called(1); + verify(cb.call(1)).called(1); signal1.value = 2; await pumpEventQueue(); verify(cb(2)).called(1); @@ -619,6 +639,30 @@ void main() { expect(doubleCount.value, null); }); + test('custom comparator suppresses equivalent computed updates', () { + final count = Signal(1); + final parity = Computed( + () => count.value, + equals: false, + comparator: (previous, next) => previous?.isEven == next?.isEven, + ); + var runs = 0; + + final dispose = Effect(() { + parity.value; + runs++; + }); + addTearDown(dispose); + + expect(runs, equals(1)); + + count.value = 3; + expect(runs, equals(1)); + + count.value = 4; + expect(runs, equals(2)); + }); + test('derived signal disposes', () async { final count = Signal(0); final doubleCount = Computed(() => count.value * 2); @@ -2150,14 +2194,27 @@ void main() { expect(reactiveSystem.activeSub, same(effect.subscriber)); reactiveSystem.activeSub = null; - reactiveSystem.runEffect(effect.subscriber); effect.run(); - expect(effectRuns, greaterThanOrEqualTo(1)); + expect(effectRuns, equals(1)); + + effect.subscriber.flags |= alien_system.ReactiveFlags.dirty; + reactiveSystem.runEffect(effect.subscriber); + expect(effectRuns, equals(2)); final subs = effect.subscriber.deps!.dep.subs; expect(subs, isNotNull); - reactiveSystem.propagate(subs!); - reactiveSystem.flush(); + + reactiveSystem.startBatch(); + try { + signal.value++; + reactiveSystem.propagate(subs!); + expect(effectRuns, equals(2)); + + reactiveSystem.flush(); + } finally { + reactiveSystem.endBatch(); + } + expect(effectRuns, equals(3)); final runsAfterFlush = effectRuns; reactiveSystem.stopEffect(effect.subscriber); From fd2b32270160dabada3335e7a2c39abdc61d9336 Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 25 Jun 2026 11:55:39 +0200 Subject: [PATCH 07/25] refactor(solidart): remove update() alias and clarify reactive system AP - Remove update() wrapper method; call didUpdate() directly on signal/computed nodes - Replace activeSub setter with explicit setCurrentSub() for save/restore semantics - Use named ReactiveFlags constants instead of magic bits for clarity - Add detailed comments on reactive system edge cases and unsafe internals --- packages/solidart/lib/src/core/alien.dart | 8 ++---- packages/solidart/lib/src/core/computed.dart | 2 +- .../lib/src/core/reactive_system.dart | 28 ++++++++++++++++--- .../solidart/lib/src/core/read_signal.dart | 2 +- packages/solidart/test/solidart_test.dart | 4 +-- 5 files changed, 30 insertions(+), 14 deletions(-) diff --git a/packages/solidart/lib/src/core/alien.dart b/packages/solidart/lib/src/core/alien.dart index 28fbf16e..0b20acc6 100644 --- a/packages/solidart/lib/src/core/alien.dart +++ b/packages/solidart/lib/src/core/alien.dart @@ -11,8 +11,6 @@ class _AlienComputed extends alien.ComputedNode { void dispose() => alien.stop(this); - bool update() => didUpdate(); - @override bool didUpdate() { if ((flags & _hasChildEffect) != alien_system.ReactiveFlags.none) { @@ -30,8 +28,8 @@ class _AlienComputed extends alien.ComputedNode { currentValue = getter(oldValue); return !parent._compare(oldValue, currentValue); } finally { - alien.activeSub = prevSub; - flags &= -5 /* ~ReactiveFlags.recursedCheck */; + alien.setActiveSub(prevSub); + flags &= ~alien_system.ReactiveFlags.recursedCheck; alien.purgeDeps(this); } } @@ -62,8 +60,6 @@ class _AlienSignal extends alien.SignalNode> { bool forceDirty = false; - bool update() => didUpdate(); - @override bool didUpdate() { final previousValue = currentValue; diff --git a/packages/solidart/lib/src/core/computed.dart b/packages/solidart/lib/src/core/computed.dart index 2cc93502..3325455e 100644 --- a/packages/solidart/lib/src/core/computed.dart +++ b/packages/solidart/lib/src/core/computed.dart @@ -260,7 +260,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/reactive_system.dart b/packages/solidart/lib/src/core/reactive_system.dart index abb29126..62e03a28 100644 --- a/packages/solidart/lib/src/core/reactive_system.dart +++ b/packages/solidart/lib/src/core/reactive_system.dart @@ -3,6 +3,12 @@ // Reactive flags map: https://github.com/medz/alien-signals-dart/blob/main/flags.md part of 'core.dart'; +// alien_signals' internal `hasChildEffect` flag (value 64) is deliberately +// hidden from its public barrel exports (`preset.dart` hides it). We redeclare +// it here so the reactive adapter can test and set this bit without reaching +// into unpublished internals. If the upstream value ever changes, the adjacent +// link in alien/src/preset.dart → `const hasChildEffect = 64` should be +// cross-checked. const _hasChildEffect = 64 as alien_system.ReactiveFlags; extension MayDisposeDependencies on alien_system.ReactiveNode { @@ -53,10 +59,8 @@ class ReactiveSystem { alien_system.ReactiveNode? get activeSub => alien.getActiveSub(); - set activeSub(alien_system.ReactiveNode? sub) { - alien.setActiveSub(sub); - } - + // Use setCurrentSub when you need save/restore (returns previous sub). + // The getter above is provided for read-only inspection. alien_system.ReactiveNode? setCurrentSub(alien_system.ReactiveNode? sub) { return alien.setActiveSub(sub); } @@ -70,6 +74,13 @@ class ReactiveSystem { } T getComputedValue(_AlienComputed computed) { + // After a dependency is disposed, `ReadableSignal.dispose` nulls this + // computed's `deps` but leaves the producer's `subs` link dangling, so a + // later write to the disposed signal can still mark an `autoDispose: false` + // computed `pending` while `deps == null`. Clear it here, otherwise + // upstream `ComputedNode.get()` takes the `checkDirty(deps!, …)` branch and + // null-asserts. (see test "Check Computed do not autoDisposes if no longer + // used") if ((computed.flags & alien_system.ReactiveFlags.pending) != alien_system.ReactiveFlags.none && computed.deps == null) { @@ -91,7 +102,16 @@ class ReactiveSystem { alien.stop(effect); } + // The parameter is `ReactiveNode` rather than `_AlienEffect` only because + // `_AlienEffect` is library-private and test callers receive it via the + // `Effect.subscriber` getter (typed as `ReactiveNode`). At runtime every + // legitimate caller passes an `_AlienEffect`; the assertion below catches + // misuse in debug mode. void runEffect(alien_system.ReactiveNode effect) { + assert( + effect is _AlienEffect, + 'runEffect must be called with an _AlienEffect', + ); alien.run(effect as _AlienEffect); } diff --git a/packages/solidart/lib/src/core/read_signal.dart b/packages/solidart/lib/src/core/read_signal.dart index c256d5da..233bca85 100644 --- a/packages/solidart/lib/src/core/read_signal.dart +++ b/packages/solidart/lib/src/core/read_signal.dart @@ -352,7 +352,7 @@ class ReadableSignal implements ReadSignal { /// Indicates if the signal should update its value. bool shouldUpdate() { - return _internalSignal.update(); + return _internalSignal.didUpdate(); } @override diff --git a/packages/solidart/test/solidart_test.dart b/packages/solidart/test/solidart_test.dart index dcff9b3f..585d4972 100644 --- a/packages/solidart/test/solidart_test.dart +++ b/packages/solidart/test/solidart_test.dart @@ -2190,9 +2190,9 @@ void main() { }); expect(reactiveSystem.batchDepth, equals(0)); - reactiveSystem.activeSub = effect.subscriber; + reactiveSystem.setCurrentSub(effect.subscriber); expect(reactiveSystem.activeSub, same(effect.subscriber)); - reactiveSystem.activeSub = null; + reactiveSystem.setCurrentSub(null); effect.run(); expect(effectRuns, equals(1)); From a5393ec7c2284061b335079e52e08b6f119ef70e Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 25 Jun 2026 12:18:07 +0200 Subject: [PATCH 08/25] test(solidart): tighten reactive-system helper assertions Assert setCurrentSub's save/restore return value, and pin each batch-helper's contribution (startBatch depth, batched no-flush, propagate idempotency, flush as the trigger, endBatch depth). Addresses CodeRabbit review feedback on PR #175. --- packages/solidart/test/solidart_test.dart | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/solidart/test/solidart_test.dart b/packages/solidart/test/solidart_test.dart index 585d4972..68b31578 100644 --- a/packages/solidart/test/solidart_test.dart +++ b/packages/solidart/test/solidart_test.dart @@ -2190,9 +2190,14 @@ void main() { }); expect(reactiveSystem.batchDepth, equals(0)); - reactiveSystem.setCurrentSub(effect.subscriber); + // setCurrentSub returns the previously-active sub (save/restore). + final prevFromEffect = reactiveSystem.setCurrentSub( + effect.subscriber, + ); + expect(prevFromEffect, isNull); expect(reactiveSystem.activeSub, same(effect.subscriber)); - reactiveSystem.setCurrentSub(null); + final prevFromNull = reactiveSystem.setCurrentSub(null); + expect(prevFromNull, same(effect.subscriber)); effect.run(); expect(effectRuns, equals(1)); @@ -2205,15 +2210,22 @@ void main() { expect(subs, isNotNull); reactiveSystem.startBatch(); + expect(reactiveSystem.batchDepth, equals(1)); try { signal.value++; + // Inside a batch the queued effect must not flush yet. + expect(effectRuns, equals(2)); + // propagate() is idempotent on an already-queued subscriber. reactiveSystem.propagate(subs!); expect(effectRuns, equals(2)); + // flush() is what actually drains the queue and runs the effect. reactiveSystem.flush(); + expect(effectRuns, equals(3)); } finally { reactiveSystem.endBatch(); } + expect(reactiveSystem.batchDepth, equals(0)); expect(effectRuns, equals(3)); final runsAfterFlush = effectRuns; From 1aad70ec2ed6fb795f513ac18e640e3f16ae7df7 Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 25 Jun 2026 13:03:26 +0200 Subject: [PATCH 09/25] fix(solidart): fully unlink subscribers on signal dispose ReadableSignal.dispose now unlinks each subscriber via alien.unlink (both sides of the dependency link) instead of a hand-rolled one-sided null-out. This removes a dangling subs link that let a write to a disposed signal propagate into an already-detached computed, leaving it pending with null deps. With the graph kept consistent, the defensive guard in getComputedValue is no longer needed and is removed. Adds a regression test for the post-dispose write, and strengthens the computed child-effect test to assert no stale children accumulate across recomputes. --- .../lib/src/core/reactive_system.dart | 13 ---- .../solidart/lib/src/core/read_signal.dart | 36 +++++------ packages/solidart/test/solidart_test.dart | 59 ++++++++++++++++--- 3 files changed, 65 insertions(+), 43 deletions(-) diff --git a/packages/solidart/lib/src/core/reactive_system.dart b/packages/solidart/lib/src/core/reactive_system.dart index 62e03a28..b4073a32 100644 --- a/packages/solidart/lib/src/core/reactive_system.dart +++ b/packages/solidart/lib/src/core/reactive_system.dart @@ -74,19 +74,6 @@ class ReactiveSystem { } T getComputedValue(_AlienComputed computed) { - // After a dependency is disposed, `ReadableSignal.dispose` nulls this - // computed's `deps` but leaves the producer's `subs` link dangling, so a - // later write to the disposed signal can still mark an `autoDispose: false` - // computed `pending` while `deps == null`. Clear it here, otherwise - // upstream `ComputedNode.get()` takes the `checkDirty(deps!, …)` branch and - // null-asserts. (see test "Check Computed do not autoDisposes if no longer - // used") - if ((computed.flags & alien_system.ReactiveFlags.pending) != - alien_system.ReactiveFlags.none && - computed.deps == null) { - computed.flags &= ~alien_system.ReactiveFlags.pending; - } - return computed.get(); } diff --git a/packages/solidart/lib/src/core/read_signal.dart b/packages/solidart/lib/src/core/read_signal.dart index 233bca85..40633c53 100644 --- a/packages/solidart/lib/src/core/read_signal.dart +++ b/packages/solidart/lib/src/core/read_signal.dart @@ -274,28 +274,20 @@ class ReadableSignal implements ReadSignal { }); if (SolidartConfig.autoDispose) { - for (final sub in _subs) { - if (sub is _AlienEffect) { - if (sub.deps?.dep == _internalSignal) { - sub.deps = null; - } - if (sub.depsTail?.dep == _internalSignal) { - sub.depsTail = null; - } - - sub.parent._mayDispose(); - } - 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(); - } + // Fully unlink every subscriber from this signal. `alien.unlink` removes + // each link from BOTH the subscriber's dependency list and this signal's + // subscriber list, so a later write to the disposed signal can no longer + // propagate into a subscriber whose deps were already torn down (which + // previously left a computed `pending` with `deps == null` and required a + // guard in `getComputedValue`). + var link = _internalSignal.subs; + while (link != null) { + final next = link.nextSub; + final sub = link.sub; + alien.unlink(link, sub); + if (sub is _AlienEffect) sub.parent._mayDispose(); + if (sub is _AlienComputed) sub.parent._mayDispose(); + link = next; } _subs.clear(); } diff --git a/packages/solidart/test/solidart_test.dart b/packages/solidart/test/solidart_test.dart index 68b31578..29900ce9 100644 --- a/packages/solidart/test/solidart_test.dart +++ b/packages/solidart/test/solidart_test.dart @@ -785,6 +785,25 @@ void main() { count.value = 2; expect(doubleCount.value, 2); }); + + test('disposing a source signal fully unlinks its subscribers', () { + // Regression: ReadableSignal.dispose must unlink subscribers on BOTH + // sides of each link, so a later write to the disposed signal cannot + // propagate into a computed whose dependency list was already cleared. + // Before the two-sided-unlink fix this left the computed `pending` with + // `deps == null` and crashed `getComputedValue` (it needed a guard). + final count = Signal(0); + final doubled = Computed(() => count.value * 2, autoDispose: false); + addTearDown(doubled.dispose); + + expect(doubled.value, 0); // establishes the count <-> doubled link + + count.dispose(); + // A post-dispose write must not throw and must not corrupt the + // computed. + count.value = 5; + expect(doubled.value, 0); + }); }, timeout: const Timeout(Duration(seconds: 1)), ); @@ -2134,19 +2153,28 @@ void main() { }); }); - test('should dispose computed child effects before recomputing', () { - final source = Signal(true); + test('computed child effects do not accumulate across recomputes', () { + // `source > 0` creates a child Effect during the computation. Every + // recompute must leave exactly one live child (the freshly-created + // one); stale children from previous runs must not keep reacting. + // (Note: this asserts the observable end-state. The internal ordering + // guarantee — disposal BEFORE the selector re-runs, via the + // `_hasChildEffect` flag — is not observable through run counts because + // `purgeDeps` + the `unwatched` callback also dispose stale children + // after a recompute.) + final source = Signal(1); final child = Signal(0); var childRuns = 0; final computed = Computed(() { - if (source.value) { + final s = source.value; + if (s > 0) { Effect(() { child.value; childRuns++; }); } - return source.value; + return s; }); addTearDown(() { @@ -2155,17 +2183,32 @@ void main() { child.dispose(); }); - expect(computed.value, isTrue); + expect(computed.value, 1); expect(childRuns, equals(1)); + // The single live child reacts to its own dependency. child.value++; expect(childRuns, equals(2)); - source.value = false; - expect(computed.value, isFalse); + // Recompute while still creating a child: the previous child must be + // disposed first, so only the freshly-created one survives. + source.value = 2; + expect(computed.value, 2); + expect(childRuns, equals(3)); + // If the old child were NOT disposed before the recompute, both would + // react here and childRuns would be 5. child.value++; - expect(childRuns, equals(2)); + expect(childRuns, equals(4)); + + // Recompute into the branch that creates no child: the live child from + // the previous run is disposed before the selector runs again, so a + // later write reaches nobody. + source.value = 0; + expect(computed.value, 0); + + child.value++; + expect(childRuns, equals(4)); }); test( From 67dc2fd3a8b0b36a4221260c29da084a6b961612 Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 25 Jun 2026 13:03:26 +0200 Subject: [PATCH 10/25] chore: changelog and version bump for alien_signals 2.3.1 upgrade solidart 2.9.0, flutter_solidart 2.7.5 (requires solidart ^2.9.0). Documents the alien_signals ^2.3.1 adapter upgrade, the lazy-nullable-Signal-null fix, and the signal-dispose unlink fix. --- packages/flutter_solidart/CHANGELOG.md | 5 +++++ packages/flutter_solidart/pubspec.yaml | 4 ++-- packages/solidart/CHANGELOG.md | 6 ++++++ packages/solidart/pubspec.yaml | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/flutter_solidart/CHANGELOG.md b/packages/flutter_solidart/CHANGELOG.md index ac29d637..c8c7418e 100644 --- a/packages/flutter_solidart/CHANGELOG.md +++ b/packages/flutter_solidart/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.7.5 + +- **CHORE**: Require `solidart: ^2.9.0` (the `alien_signals` 2.3.1 reactive adapter). +- **REFACTOR**: Route `SignalBuilder` through the reactive sub helper (`setCurrentSub`) instead of assigning `activeSub` directly. + ## 2.7.4 ### Changes from solidart diff --git a/packages/flutter_solidart/pubspec.yaml b/packages/flutter_solidart/pubspec.yaml index fefd855b..55a7bb96 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.4 +version: 2.7.5 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: ^2.8.6 + solidart: ^2.9.0 dev_dependencies: disco: ^1.0.0 diff --git a/packages/solidart/CHANGELOG.md b/packages/solidart/CHANGELOG.md index 699801f9..3519161e 100644 --- a/packages/solidart/CHANGELOG.md +++ b/packages/solidart/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.9.0 + +- **CHORE**: Upgrade `alien_signals` to `^2.3.1` and adapt the internal reactive adapter to its preset/system APIs. +- **FIX**: A lazy nullable `Signal` (e.g. `Signal.lazy()`) now notifies when it is first set to `null`; previously the `None` → `Some(null)` transition was treated as "no change". +- **FIX**: Disposing a signal now fully unlinks its subscribers on both sides of each dependency link, so a later write to the disposed signal can no longer propagate into an already-detached computed. + ## 2.8.6 - **FIX**: Prevent effect re-entrancy when a signal is written during the effect's first run. The write no longer re-enters the running effect mid-execution, which could throw `LateInitializationError`. diff --git a/packages/solidart/pubspec.yaml b/packages/solidart/pubspec.yaml index 7e1b0bcb..2af4b11d 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.6 +version: 2.9.0 repository: https://github.com/nank1ro/solidart documentation: https://solidart.mariuti.com topics: From 8066066130aed25f2fe6e4e34f2839c21e8f8eb5 Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 25 Jun 2026 13:10:50 +0200 Subject: [PATCH 11/25] chore(solidart): assert computed graph invariant in getComputedValue Debug-only sentinel: a computed must never be pending while detached (deps == null). Replaces the removed runtime guard with a documented invariant check that fails loudly in tests if a half-linked node ever reappears (e.g. a one-sided unlink), instead of crashing cryptically in upstream ComputedNode.get(). Stripped in release builds. --- .../solidart/lib/src/core/reactive_system.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/solidart/lib/src/core/reactive_system.dart b/packages/solidart/lib/src/core/reactive_system.dart index b4073a32..1705431a 100644 --- a/packages/solidart/lib/src/core/reactive_system.dart +++ b/packages/solidart/lib/src/core/reactive_system.dart @@ -74,6 +74,20 @@ class ReactiveSystem { } T getComputedValue(_AlienComputed computed) { + // Invariant: a computed is never `pending` while detached from the graph + // (`deps == null`). `pending` is only set by `propagate`, which reaches a + // node through the very dependency links that make `deps` non-null, and + // every teardown path (recompute / unwatch / stop / signal dispose) either + // clears `pending` or fully unlinks the node. If this ever fires, a code + // path is leaving a half-linked node (e.g. a one-sided unlink), which would + // otherwise crash in upstream `ComputedNode.get()` via `checkDirty(deps!)`. + assert( + computed.deps != null || + (computed.flags & alien_system.ReactiveFlags.pending) == + alien_system.ReactiveFlags.none, + 'computed is pending while detached (deps == null) — a subscriber link ' + 'was not fully unlinked', + ); return computed.get(); } From f3c049434822f35873b624845e239bc87098b6b2 Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 25 Jun 2026 13:21:30 +0200 Subject: [PATCH 12/25] test(solidart): cover manual auto-dispose source-dispose path Regression test for SolidartConfig.autoDispose = false: disposing a source signal (which skips the unlink path) then writing to it must keep the getComputedValue pending/deps invariant and not crash. Guards the universal validity of the debug sentinel. --- packages/solidart/test/solidart_test.dart | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/solidart/test/solidart_test.dart b/packages/solidart/test/solidart_test.dart index 29900ce9..9beeba1c 100644 --- a/packages/solidart/test/solidart_test.dart +++ b/packages/solidart/test/solidart_test.dart @@ -804,6 +804,32 @@ void main() { count.value = 5; expect(doubled.value, 0); }); + + test( + 'manual auto-dispose: writing to a disposed source keeps the ' + 'getComputedValue invariant', + () { + // With SolidartConfig.autoDispose disabled, ReadableSignal.dispose + // skips its subscriber-unlink path, leaving the dependency graph + // intact (both sides linked) rather than half-unlinked. A later write + // to the disposed signal must not crash or trip the pending/deps + // invariant asserted in getComputedValue. + final previousAutoDispose = SolidartConfig.autoDispose; + addTearDown(() => SolidartConfig.autoDispose = previousAutoDispose); + SolidartConfig.autoDispose = false; + + final count = Signal(1); + final doubled = Computed(() => count.value * 2); + addTearDown(doubled.dispose); + + expect(doubled.value, 2); + + count.dispose(); + count.value = 5; + // Must resolve without throwing or asserting. + expect(doubled.value, 10); + }, + ); }, timeout: const Timeout(Duration(seconds: 1)), ); From cd06344e9fd462d92005c8fde43ceddb30ca629d Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 25 Jun 2026 15:31:36 +0200 Subject: [PATCH 13/25] test(solidart): regression for premature computed auto-dispose (#162) A Computed with multiple source signals was prematurely auto-disposed when one source was disposed: the old one-sided unlink in ReadableSignal.dispose corrupted the computed's dependency list so _mayDispose saw no deps. Now fixed by the two-sided alien.unlink; this test pins it down (computed survives one source being disposed, keeps reacting to the other, and auto-disposes only once all sources are gone). Reproduces the issue apps worked around with autoDispose: false. --- packages/solidart/test/solidart_test.dart | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/solidart/test/solidart_test.dart b/packages/solidart/test/solidart_test.dart index 9beeba1c..5fc94926 100644 --- a/packages/solidart/test/solidart_test.dart +++ b/packages/solidart/test/solidart_test.dart @@ -764,6 +764,34 @@ void main() { expect(doubleCount.value, 2); }); + test( + 'computed with multiple sources is not prematurely auto-disposed ' + '(regression #162)', + () { + final a = Signal(1); + final b = Signal(2); + final sum = Computed(() => a.value + b.value); // autoDispose: true + + expect(sum.value, 3); + + // Disposing ONE source must not auto-dispose the computed — it still + // depends on `b`. Previously a one-sided unlink in the signal's + // dispose corrupted the computed's dependency list, so it was + // disposed prematurely (worked around in apps with autoDispose: + // false). + a.dispose(); + expect(sum.disposed, false); + + // ...and it must keep reacting to the still-live source. + b.value = 10; + expect(sum.value, 11); + + // Once every source is disposed, it finally auto-disposes. + b.dispose(); + expect(sum.disposed, true); + }, + ); + test('Check Computed do not autoDisposes if no longer used', () { final count = Signal(0); final doubleCount = Computed(() => count.value * 2, autoDispose: false); From cba48d0869f1037d23d94586d6c9e19a75ade9bc Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 25 Jun 2026 16:21:15 +0200 Subject: [PATCH 14/25] fix(solidart): Computed.listenerCount reports subscribers, not deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Computed.listenerCount returned _deps.length (the number of dependencies) instead of the number of subscribers, unlike ReadableSignal.listenerCount. This also fed a wrong check in Resource.dispose (`source.listenerCount == 0`), which decides whether to dispose a Computed passed as a Resource's `source` — so a Computed source could be skipped by that cleanup. RED test added (a 2-dependency computed reported 2 listeners with 0 observers). --- packages/solidart/CHANGELOG.md | 1 + packages/solidart/lib/src/core/computed.dart | 11 +++++++--- packages/solidart/test/solidart_test.dart | 23 ++++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/solidart/CHANGELOG.md b/packages/solidart/CHANGELOG.md index 3519161e..9c842afc 100644 --- a/packages/solidart/CHANGELOG.md +++ b/packages/solidart/CHANGELOG.md @@ -3,6 +3,7 @@ - **CHORE**: Upgrade `alien_signals` to `^2.3.1` and adapt the internal reactive adapter to its preset/system APIs. - **FIX**: A lazy nullable `Signal` (e.g. `Signal.lazy()`) now notifies when it is first set to `null`; previously the `None` → `Some(null)` transition was treated as "no change". - **FIX**: Disposing a signal now fully unlinks its subscribers on both sides of each dependency link, so a later write to the disposed signal can no longer propagate into an already-detached computed. +- **FIX**: `Computed.listenerCount` now reports the number of subscribers instead of the number of dependencies. This also corrects `Resource`'s source cleanup, which uses `listenerCount` to decide whether to dispose a `Computed` passed as its `source`. ## 2.8.6 diff --git a/packages/solidart/lib/src/core/computed.dart b/packages/solidart/lib/src/core/computed.dart index 3325455e..166072d2 100644 --- a/packages/solidart/lib/src/core/computed.dart +++ b/packages/solidart/lib/src/core/computed.dart @@ -230,10 +230,15 @@ class Computed extends ReadSignal { return _hasPreviousValue; } - // coverage:ignore-start @override - int get listenerCount => _deps.length; - // coverage:ignore-end + int get listenerCount { + // The number of subscribers observing this computed (not its dependencies). + var count = 0; + for (var link = _internalComputed.subs; link != null; link = link.nextSub) { + count++; + } + return count; + } @override void onDispose(VoidCallback cb) { diff --git a/packages/solidart/test/solidart_test.dart b/packages/solidart/test/solidart_test.dart index 5fc94926..94bbb939 100644 --- a/packages/solidart/test/solidart_test.dart +++ b/packages/solidart/test/solidart_test.dart @@ -792,6 +792,29 @@ void main() { }, ); + test('listenerCount counts subscribers, not dependencies', () { + final a = Signal(1); + final b = Signal(2); + // A computed of two signals has 0 listeners until something observes + // it — listenerCount must reflect subscribers, not its dependency + // count (it previously returned the number of dependencies). + final c = Computed(() => a.value + b.value, autoDispose: false); + addTearDown(() { + c.dispose(); + a.dispose(); + b.dispose(); + }); + c.value; // materialize (establish dependencies) + + expect(c.listenerCount, 0); + + final stop = Effect(() => c.value); + expect(c.listenerCount, 1); + + stop(); + expect(c.listenerCount, 0); + }); + test('Check Computed do not autoDisposes if no longer used', () { final count = Signal(0); final doubleCount = Computed(() => count.value * 2, autoDispose: false); From 09e244c24428d687d87feeb22560bada9c0f2b7e Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 25 Jun 2026 16:52:30 +0200 Subject: [PATCH 15/25] fix(solidart): fully unlink subscribers when disposing a Computed Computed.dispose() went through alien.stop(), which unlinks the node's deps but only the FIRST subscriber link, leaving any further subscribers holding a dangling link to a disposed computed (observable as listenerCount > 0 after dispose with 2+ subscribers). Now unlinks all subscribers, mirroring the signal-dispose fix. Also: extract a shared subscriberCount helper and drop the redundant _subs Set (its only remaining use was listenerCount; it was rebuilt on every hot read), and add a guard test asserting alien_signals' hidden hasChildEffect flag is still 64. --- packages/solidart/CHANGELOG.md | 1 + packages/solidart/lib/src/core/alien.dart | 12 +++++++++++- packages/solidart/lib/src/core/computed.dart | 9 +-------- .../lib/src/core/reactive_system.dart | 9 +++++++++ .../solidart/lib/src/core/read_signal.dart | 17 ++--------------- packages/solidart/test/alien_flags_test.dart | 14 ++++++++++++++ packages/solidart/test/solidart_test.dart | 19 +++++++++++++++++++ 7 files changed, 57 insertions(+), 24 deletions(-) create mode 100644 packages/solidart/test/alien_flags_test.dart diff --git a/packages/solidart/CHANGELOG.md b/packages/solidart/CHANGELOG.md index 9c842afc..9734daa3 100644 --- a/packages/solidart/CHANGELOG.md +++ b/packages/solidart/CHANGELOG.md @@ -4,6 +4,7 @@ - **FIX**: A lazy nullable `Signal` (e.g. `Signal.lazy()`) now notifies when it is first set to `null`; previously the `None` → `Some(null)` transition was treated as "no change". - **FIX**: Disposing a signal now fully unlinks its subscribers on both sides of each dependency link, so a later write to the disposed signal can no longer propagate into an already-detached computed. - **FIX**: `Computed.listenerCount` now reports the number of subscribers instead of the number of dependencies. This also corrects `Resource`'s source cleanup, which uses `listenerCount` to decide whether to dispose a `Computed` passed as its `source`. +- **FIX**: Disposing a `Computed` now unlinks all of its subscribers instead of only the first, so a disposed computed leaves no dangling links back to it. ## 2.8.6 diff --git a/packages/solidart/lib/src/core/alien.dart b/packages/solidart/lib/src/core/alien.dart index 0b20acc6..d4e75767 100644 --- a/packages/solidart/lib/src/core/alien.dart +++ b/packages/solidart/lib/src/core/alien.dart @@ -9,7 +9,17 @@ class _AlienComputed extends alien.ComputedNode { final Computed parent; - void dispose() => alien.stop(this); + void dispose() { + alien.stop(this); + // `alien.stop` unlinks this node's deps but only the first subscriber link + // (`unlink(subs, subs.sub)` once). Detach the remaining subscribers too, so + // a disposed computed leaves no dangling links back to it. + for (var link = subs; link != null;) { + final next = link.nextSub; + alien.unlink(link, link.sub); + link = next; + } + } @override bool didUpdate() { diff --git a/packages/solidart/lib/src/core/computed.dart b/packages/solidart/lib/src/core/computed.dart index 166072d2..74909bc2 100644 --- a/packages/solidart/lib/src/core/computed.dart +++ b/packages/solidart/lib/src/core/computed.dart @@ -231,14 +231,7 @@ class Computed extends ReadSignal { } @override - int get listenerCount { - // The number of subscribers observing this computed (not its dependencies). - var count = 0; - for (var link = _internalComputed.subs; link != null; link = link.nextSub) { - count++; - } - return count; - } + int get listenerCount => _internalComputed.subscriberCount; @override void onDispose(VoidCallback cb) { diff --git a/packages/solidart/lib/src/core/reactive_system.dart b/packages/solidart/lib/src/core/reactive_system.dart index 1705431a..2753c73f 100644 --- a/packages/solidart/lib/src/core/reactive_system.dart +++ b/packages/solidart/lib/src/core/reactive_system.dart @@ -21,6 +21,15 @@ extension MayDisposeDependencies on alien_system.ReactiveNode { return foundDeps; } + /// The number of subscribers currently observing this node. + int get subscriberCount { + var count = 0; + for (var link = subs; link != null; link = link.nextSub) { + count++; + } + return count; + } + void mayDisposeDependencies([Iterable? include]) { final dependencies = {...getDependencies(), ...?include}; for (final dep in dependencies) { diff --git a/packages/solidart/lib/src/core/read_signal.dart b/packages/solidart/lib/src/core/read_signal.dart index 40633c53..1db16ae8 100644 --- a/packages/solidart/lib/src/core/read_signal.dart +++ b/packages/solidart/lib/src/core/read_signal.dart @@ -137,17 +137,7 @@ class ReadableSignal implements ReadSignal { ); } _reportObserved(); - final value = reactiveSystem.getSignalValue(_internalSignal).unwrap(); - - if (autoDispose) { - _subs.clear(); - - var link = _internalSignal.subs; - for (; link != null; link = link.nextSub) { - _subs.add(link.sub); - } - } - return value; + return reactiveSystem.getSignalValue(_internalSignal).unwrap(); } late T _untrackedValue; @@ -258,9 +248,7 @@ class ReadableSignal implements ReadSignal { /// Returns the number of listeners listening to this signal. @override - int get listenerCount => _subs.length; - - final _subs = {}; + int get listenerCount => _internalSignal.subscriberCount; @override void dispose() { @@ -289,7 +277,6 @@ class ReadableSignal implements ReadSignal { if (sub is _AlienComputed) sub.parent._mayDispose(); link = next; } - _subs.clear(); } for (final cb in _onDisposeCallbacks) { diff --git a/packages/solidart/test/alien_flags_test.dart b/packages/solidart/test/alien_flags_test.dart new file mode 100644 index 00000000..29e9eb96 --- /dev/null +++ b/packages/solidart/test/alien_flags_test.dart @@ -0,0 +1,14 @@ +// Guards the assumption baked into `reactive_system.dart` that alien_signals' +// internal `hasChildEffect` flag is the bit `64`. That constant is hidden from +// alien_signals' public barrel, so solidart redeclares it. If upstream ever +// changes the value, child-effect disposal would break silently — this test +// fails loudly instead. +import 'package:alien_signals/src/preset.dart' as alien_preset; +import 'package:test/test.dart'; + +void main() { + test('alien_signals hasChildEffect flag is still 64', () { + // Must match `_hasChildEffect = 64` in reactive_system.dart. + expect(alien_preset.hasChildEffect, 64); + }); +} diff --git a/packages/solidart/test/solidart_test.dart b/packages/solidart/test/solidart_test.dart index 94bbb939..f63d8641 100644 --- a/packages/solidart/test/solidart_test.dart +++ b/packages/solidart/test/solidart_test.dart @@ -815,6 +815,25 @@ void main() { expect(c.listenerCount, 0); }); + test('disposing a computed unlinks all of its subscribers', () { + final s = Signal(0); + final c = Computed(() => s.value, autoDispose: false); + final e1 = Effect(() => c.value, autoDispose: false); + final e2 = Effect(() => c.value, autoDispose: false); + addTearDown(() { + e1.dispose(); + e2.dispose(); + s.dispose(); + }); + + expect(c.listenerCount, 2); + + c.dispose(); + // Disposing must remove every subscriber link, not just the first one, + // so no subscriber is left holding a link to a disposed computed. + expect(c.listenerCount, 0); + }); + test('Check Computed do not autoDisposes if no longer used', () { final count = Signal(0); final doubleCount = Computed(() => count.value * 2, autoDispose: false); From b18fee55bd0c6fdeb1a26afa14896c99450dbd79 Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 25 Jun 2026 17:04:34 +0200 Subject: [PATCH 16/25] fix(solidart): unlink subscribers on signal dispose regardless of autoDispose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReadableSignal.dispose gated its subscriber-unlink behind the global SolidartConfig.autoDispose, so with auto-dispose off a disposed signal kept all its links and still propagated writes to subscribers — inconsistent with Effect.dispose and Computed.dispose, which always tear down. dispose() now always unlinks (the per-subscriber _mayDispose still self-guards on each subscriber's own autoDispose flag). --- packages/solidart/CHANGELOG.md | 2 +- .../solidart/lib/src/core/read_signal.dart | 31 +++++++++---------- packages/solidart/test/solidart_test.dart | 19 ++++++------ 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/solidart/CHANGELOG.md b/packages/solidart/CHANGELOG.md index 9734daa3..9686d3ad 100644 --- a/packages/solidart/CHANGELOG.md +++ b/packages/solidart/CHANGELOG.md @@ -2,7 +2,7 @@ - **CHORE**: Upgrade `alien_signals` to `^2.3.1` and adapt the internal reactive adapter to its preset/system APIs. - **FIX**: A lazy nullable `Signal` (e.g. `Signal.lazy()`) now notifies when it is first set to `null`; previously the `None` → `Some(null)` transition was treated as "no change". -- **FIX**: Disposing a signal now fully unlinks its subscribers on both sides of each dependency link, so a later write to the disposed signal can no longer propagate into an already-detached computed. +- **FIX**: Disposing a signal now fully unlinks its subscribers on both sides of each dependency link — and does so regardless of `SolidartConfig.autoDispose` (a disposed signal is destroyed, like `Effect`/`Computed` disposal) — so a later write to the disposed signal can no longer propagate into an already-detached computed. - **FIX**: `Computed.listenerCount` now reports the number of subscribers instead of the number of dependencies. This also corrects `Resource`'s source cleanup, which uses `listenerCount` to decide whether to dispose a `Computed` passed as its `source`. - **FIX**: Disposing a `Computed` now unlinks all of its subscribers instead of only the first, so a disposed computed leaves no dangling links back to it. diff --git a/packages/solidart/lib/src/core/read_signal.dart b/packages/solidart/lib/src/core/read_signal.dart index 1db16ae8..6a637b1d 100644 --- a/packages/solidart/lib/src/core/read_signal.dart +++ b/packages/solidart/lib/src/core/read_signal.dart @@ -261,22 +261,21 @@ class ReadableSignal implements ReadSignal { reactiveSystem.getSignalValue(_internalSignal); }); - if (SolidartConfig.autoDispose) { - // Fully unlink every subscriber from this signal. `alien.unlink` removes - // each link from BOTH the subscriber's dependency list and this signal's - // subscriber list, so a later write to the disposed signal can no longer - // propagate into a subscriber whose deps were already torn down (which - // previously left a computed `pending` with `deps == null` and required a - // guard in `getComputedValue`). - var link = _internalSignal.subs; - while (link != null) { - final next = link.nextSub; - final sub = link.sub; - alien.unlink(link, sub); - if (sub is _AlienEffect) sub.parent._mayDispose(); - if (sub is _AlienComputed) sub.parent._mayDispose(); - link = next; - } + // Fully unlink every subscriber from this signal — dispose means destroy, + // so this runs regardless of `SolidartConfig.autoDispose` (matching + // Effect.dispose and Computed.dispose). `alien.unlink` removes each link + // from BOTH the subscriber's dependency list and this signal's subscriber + // list, so a later write to the disposed signal can no longer propagate + // into a subscriber whose deps were torn down. The per-subscriber + // `_mayDispose` self-guards on the subscriber's own autoDispose flag. + var link = _internalSignal.subs; + while (link != null) { + final next = link.nextSub; + final sub = link.sub; + alien.unlink(link, sub); + if (sub is _AlienEffect) sub.parent._mayDispose(); + if (sub is _AlienComputed) sub.parent._mayDispose(); + link = next; } for (final cb in _onDisposeCallbacks) { diff --git a/packages/solidart/test/solidart_test.dart b/packages/solidart/test/solidart_test.dart index f63d8641..28b36e6e 100644 --- a/packages/solidart/test/solidart_test.dart +++ b/packages/solidart/test/solidart_test.dart @@ -876,14 +876,12 @@ void main() { }); test( - 'manual auto-dispose: writing to a disposed source keeps the ' - 'getComputedValue invariant', + 'disposing a signal unlinks its subscribers even with auto-dispose off', () { - // With SolidartConfig.autoDispose disabled, ReadableSignal.dispose - // skips its subscriber-unlink path, leaving the dependency graph - // intact (both sides linked) rather than half-unlinked. A later write - // to the disposed signal must not crash or trip the pending/deps - // invariant asserted in getComputedValue. + // dispose() means destroy: it must unlink from every subscriber + // regardless of SolidartConfig.autoDispose (matching Effect.dispose + // and Computed.dispose, which never gate teardown on the global + // flag). final previousAutoDispose = SolidartConfig.autoDispose; addTearDown(() => SolidartConfig.autoDispose = previousAutoDispose); SolidartConfig.autoDispose = false; @@ -893,11 +891,14 @@ void main() { addTearDown(doubled.dispose); expect(doubled.value, 2); + expect(count.listenerCount, 1); // doubled subscribes to count count.dispose(); + // fully unlinked: nothing is left holding a link to the disposed + // signal, so a later write to it cannot reach the computed. + expect(count.listenerCount, 0); count.value = 5; - // Must resolve without throwing or asserting. - expect(doubled.value, 10); + expect(doubled.value, 2); }, ); }, From 373e93775305ed36f2018fad8ba5a103477c8c49 Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 25 Jun 2026 17:13:17 +0200 Subject: [PATCH 17/25] refactor(solidart): move reactive adapter out of the public API ReactiveSystem, reactiveSystem and the MayDisposeDependencies extension expose internal alien_signals types (ReactiveNode/Link) in their signatures, yet were exported from the public solidart.dart barrel. Hide them from the public barrel and expose them to sibling packages via a dedicated package:solidart/solidart_internal.dart. flutter_solidart's SignalBuilder now imports reactiveSystem from there. No public behaviour change for application code. --- packages/flutter_solidart/CHANGELOG.md | 1 + .../lib/src/widgets/signal_builder.dart | 2 ++ packages/solidart/CHANGELOG.md | 1 + packages/solidart/lib/solidart.dart | 13 ++++++++++++- packages/solidart/lib/solidart_internal.dart | 8 ++++++++ 5 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 packages/solidart/lib/solidart_internal.dart diff --git a/packages/flutter_solidart/CHANGELOG.md b/packages/flutter_solidart/CHANGELOG.md index c8c7418e..473fd077 100644 --- a/packages/flutter_solidart/CHANGELOG.md +++ b/packages/flutter_solidart/CHANGELOG.md @@ -2,6 +2,7 @@ - **CHORE**: Require `solidart: ^2.9.0` (the `alien_signals` 2.3.1 reactive adapter). - **REFACTOR**: Route `SignalBuilder` through the reactive sub helper (`setCurrentSub`) instead of assigning `activeSub` directly. +- **CHORE**: Consume solidart's `reactiveSystem` via `package:solidart/solidart_internal.dart` now that it is no longer part of solidart's public barrel. ## 2.7.4 diff --git a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart index d64ef71a..55854def 100644 --- a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart +++ b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart @@ -3,6 +3,8 @@ import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; import 'package:solidart/solidart.dart'; +// `reactiveSystem` is internal solidart API, not part of the public barrel. +import 'package:solidart/solidart_internal.dart'; /// {@template signalbuilder} /// Reacts to the signals automatically found in the [builder] function. diff --git a/packages/solidart/CHANGELOG.md b/packages/solidart/CHANGELOG.md index 9686d3ad..f702e958 100644 --- a/packages/solidart/CHANGELOG.md +++ b/packages/solidart/CHANGELOG.md @@ -5,6 +5,7 @@ - **FIX**: Disposing a signal now fully unlinks its subscribers on both sides of each dependency link — and does so regardless of `SolidartConfig.autoDispose` (a disposed signal is destroyed, like `Effect`/`Computed` disposal) — so a later write to the disposed signal can no longer propagate into an already-detached computed. - **FIX**: `Computed.listenerCount` now reports the number of subscribers instead of the number of dependencies. This also corrects `Resource`'s source cleanup, which uses `listenerCount` to decide whether to dispose a `Computed` passed as its `source`. - **FIX**: Disposing a `Computed` now unlinks all of its subscribers instead of only the first, so a disposed computed leaves no dangling links back to it. +- **REFACTOR**: `ReactiveSystem`, `reactiveSystem`, and the `MayDisposeDependencies` extension are no longer exported from the public `solidart.dart` barrel — they expose internal `alien_signals` types and are implementation detail. Sibling packages consume them via `package:solidart/solidart_internal.dart`. ## 2.8.6 diff --git a/packages/solidart/lib/solidart.dart b/packages/solidart/lib/solidart.dart index 32eaef69..618053b3 100644 --- a/packages/solidart/lib/solidart.dart +++ b/packages/solidart/lib/solidart.dart @@ -3,8 +3,19 @@ /// More dartdocs go here. library; +// `ReactiveSystem`/`reactiveSystem`/`MayDisposeDependencies` are the reactive +// adapter: they expose internal alien_signals types (ReactiveNode/Link) and are +// not public API. Sibling packages reach them via +// `package:solidart/solidart_internal.dart`. export 'src/core/core.dart' - hide ReactionErrorHandler, ReactionInterface, ReactiveName, ValueComparator; + hide + MayDisposeDependencies, + ReactionErrorHandler, + ReactionInterface, + ReactiveName, + ReactiveSystem, + ValueComparator, + reactiveSystem; export 'src/extensions/until.dart'; export 'src/utils.dart' show diff --git a/packages/solidart/lib/solidart_internal.dart b/packages/solidart/lib/solidart_internal.dart new file mode 100644 index 00000000..eac970de --- /dev/null +++ b/packages/solidart/lib/solidart_internal.dart @@ -0,0 +1,8 @@ +/// Internal API shared with sibling packages (e.g. `flutter_solidart`). +/// +/// This is **not** part of solidart's public API: the symbols here expose +/// internal `alien_signals` types and may change without a major version bump. +/// Application code must not import this library. +library; + +export 'src/core/core.dart' show ReactiveSystem, reactiveSystem; From 561da7c7ea40a4819bb1cb880c20294161d4236f Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 25 Jun 2026 17:36:22 +0200 Subject: [PATCH 18/25] fix(solidart): auto-dispose a computed's subscribers on dispose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disposing a Computed unlinked its subscribers but, unlike ReadableSignal.dispose, never offered them _mayDispose — so an autoDispose effect that only observed a disposed computed leaked. Extract the shared unlinkSubscribers helper (unlink each subscriber + offer _mayDispose) and use it from both ReadableSignal.dispose and _AlienComputed.dispose, removing the duplicated loop. RED-first regression test added. --- packages/solidart/CHANGELOG.md | 2 +- packages/solidart/lib/src/core/alien.dart | 12 ++++-------- .../solidart/lib/src/core/reactive_system.dart | 15 +++++++++++++++ .../solidart/lib/src/core/read_signal.dart | 18 ++++-------------- packages/solidart/test/solidart_test.dart | 18 ++++++++++++++++++ 5 files changed, 42 insertions(+), 23 deletions(-) diff --git a/packages/solidart/CHANGELOG.md b/packages/solidart/CHANGELOG.md index f702e958..75d2f61c 100644 --- a/packages/solidart/CHANGELOG.md +++ b/packages/solidart/CHANGELOG.md @@ -4,7 +4,7 @@ - **FIX**: A lazy nullable `Signal` (e.g. `Signal.lazy()`) now notifies when it is first set to `null`; previously the `None` → `Some(null)` transition was treated as "no change". - **FIX**: Disposing a signal now fully unlinks its subscribers on both sides of each dependency link — and does so regardless of `SolidartConfig.autoDispose` (a disposed signal is destroyed, like `Effect`/`Computed` disposal) — so a later write to the disposed signal can no longer propagate into an already-detached computed. - **FIX**: `Computed.listenerCount` now reports the number of subscribers instead of the number of dependencies. This also corrects `Resource`'s source cleanup, which uses `listenerCount` to decide whether to dispose a `Computed` passed as its `source`. -- **FIX**: Disposing a `Computed` now unlinks all of its subscribers instead of only the first, so a disposed computed leaves no dangling links back to it. +- **FIX**: Disposing a `Computed` now unlinks all of its subscribers instead of only the first (leaving no dangling links back to it) and offers each subscriber the chance to auto-dispose, matching signal disposal. - **REFACTOR**: `ReactiveSystem`, `reactiveSystem`, and the `MayDisposeDependencies` extension are no longer exported from the public `solidart.dart` barrel — they expose internal `alien_signals` types and are implementation detail. Sibling packages consume them via `package:solidart/solidart_internal.dart`. ## 2.8.6 diff --git a/packages/solidart/lib/src/core/alien.dart b/packages/solidart/lib/src/core/alien.dart index d4e75767..f112ef01 100644 --- a/packages/solidart/lib/src/core/alien.dart +++ b/packages/solidart/lib/src/core/alien.dart @@ -10,15 +10,11 @@ class _AlienComputed extends alien.ComputedNode { final Computed parent; void dispose() { + // `alien.stop` only unlinks the first subscriber, so unlink (and maybe + // auto-dispose) all of them first — mirroring ReadableSignal.dispose — then + // let `alien.stop` clear this node's deps and flags. + unlinkSubscribers(); alien.stop(this); - // `alien.stop` unlinks this node's deps but only the first subscriber link - // (`unlink(subs, subs.sub)` once). Detach the remaining subscribers too, so - // a disposed computed leaves no dangling links back to it. - for (var link = subs; link != null;) { - final next = link.nextSub; - alien.unlink(link, link.sub); - link = next; - } } @override diff --git a/packages/solidart/lib/src/core/reactive_system.dart b/packages/solidart/lib/src/core/reactive_system.dart index 2753c73f..b3874636 100644 --- a/packages/solidart/lib/src/core/reactive_system.dart +++ b/packages/solidart/lib/src/core/reactive_system.dart @@ -30,6 +30,21 @@ extension MayDisposeDependencies on alien_system.ReactiveNode { return count; } + /// Unlinks every subscriber from this node, offering each the same + /// `_mayDispose` chance a disposed dependency triggers. Used when a signal or + /// computed is disposed — `alien.stop` only unlinks the first subscriber. + void unlinkSubscribers() { + var link = subs; + while (link != null) { + final next = link.nextSub; + final sub = link.sub; + alien.unlink(link, sub); + if (sub is _AlienEffect) sub.parent._mayDispose(); + if (sub is _AlienComputed) sub.parent._mayDispose(); + link = next; + } + } + void mayDisposeDependencies([Iterable? include]) { final dependencies = {...getDependencies(), ...?include}; for (final dep in dependencies) { diff --git a/packages/solidart/lib/src/core/read_signal.dart b/packages/solidart/lib/src/core/read_signal.dart index 6a637b1d..84ba6e17 100644 --- a/packages/solidart/lib/src/core/read_signal.dart +++ b/packages/solidart/lib/src/core/read_signal.dart @@ -263,20 +263,10 @@ class ReadableSignal implements ReadSignal { // Fully unlink every subscriber from this signal — dispose means destroy, // so this runs regardless of `SolidartConfig.autoDispose` (matching - // Effect.dispose and Computed.dispose). `alien.unlink` removes each link - // from BOTH the subscriber's dependency list and this signal's subscriber - // list, so a later write to the disposed signal can no longer propagate - // into a subscriber whose deps were torn down. The per-subscriber - // `_mayDispose` self-guards on the subscriber's own autoDispose flag. - var link = _internalSignal.subs; - while (link != null) { - final next = link.nextSub; - final sub = link.sub; - alien.unlink(link, sub); - if (sub is _AlienEffect) sub.parent._mayDispose(); - if (sub is _AlienComputed) sub.parent._mayDispose(); - link = next; - } + // Effect.dispose and Computed.dispose). This removes each link from BOTH + // sides, so a later write to the disposed signal can no longer propagate + // into a subscriber whose deps were torn down. + _internalSignal.unlinkSubscribers(); for (final cb in _onDisposeCallbacks) { cb(); diff --git a/packages/solidart/test/solidart_test.dart b/packages/solidart/test/solidart_test.dart index 28b36e6e..8f95157c 100644 --- a/packages/solidart/test/solidart_test.dart +++ b/packages/solidart/test/solidart_test.dart @@ -815,6 +815,24 @@ void main() { expect(c.listenerCount, 0); }); + test('disposing a computed auto-disposes its subscriber effects', () { + // Mirrors signal disposal: a subscriber left with no dependencies must + // auto-dispose. Disposing a Computed should offer each subscriber the + // same `_mayDispose` chance that disposing a Signal does. + final s = Signal(0); + final c = Computed(() => s.value, autoDispose: false); + final effect = Effect(() => c.value); // only depends on c + addTearDown(() { + s.dispose(); + if (!effect.disposed) effect.dispose(); + }); + + expect(effect.disposed, isFalse); + + c.dispose(); + expect(effect.disposed, isTrue); + }); + test('disposing a computed unlinks all of its subscribers', () { final s = Signal(0); final c = Computed(() => s.value, autoDispose: false); From be8b5b6f55640a4c9c827f401a8c56fa2411f21f Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 25 Jun 2026 18:00:25 +0200 Subject: [PATCH 19/25] chore: 3.0.0-dev.1 prerelease across the workspace Bump solidart and flutter_solidart to 3.0.0-dev.1 and solidart_hooks to 4.0.0-dev.1 (requiring flutter_solidart ^3.0.0-dev.1). Update workspace example constraints to ^3.0.0-dev.1 so they build against the local prerelease, and add path dependency_overrides to the non-workspace solidart_lint example so it uses the latest local packages too. --- 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 | 2 +- packages/flutter_solidart/example/pubspec.yaml | 2 +- packages/flutter_solidart/pubspec.yaml | 4 ++-- packages/solidart/CHANGELOG.md | 2 +- packages/solidart/pubspec.yaml | 2 +- packages/solidart_hooks/CHANGELOG.md | 4 ++++ packages/solidart_hooks/pubspec.yaml | 4 ++-- packages/solidart_lint/example/pubspec.yaml | 11 ++++++++++- packages/solidart_lint/pubspec.yaml | 4 ++-- 15 files changed, 30 insertions(+), 17 deletions(-) diff --git a/examples/auth_flow/pubspec.yaml b/examples/auth_flow/pubspec.yaml index 92430094..b3281693 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 diff --git a/examples/counter/pubspec.yaml b/examples/counter/pubspec.yaml index 4830744a..f43f1fc1 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..0e00ad24 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..d6418696 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..99b4ef23 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..2ca968aa 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/CHANGELOG.md b/packages/flutter_solidart/CHANGELOG.md index 473fd077..6e96f233 100644 --- a/packages/flutter_solidart/CHANGELOG.md +++ b/packages/flutter_solidart/CHANGELOG.md @@ -1,4 +1,4 @@ -## 2.7.5 +## 3.0.0-dev.1 - **CHORE**: Require `solidart: ^2.9.0` (the `alien_signals` 2.3.1 reactive adapter). - **REFACTOR**: Route `SignalBuilder` through the reactive sub helper (`setCurrentSub`) instead of assigning `activeSub` directly. diff --git a/packages/flutter_solidart/example/pubspec.yaml b/packages/flutter_solidart/example/pubspec.yaml index 36fd7444..cb7942f5 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/pubspec.yaml b/packages/flutter_solidart/pubspec.yaml index 55a7bb96..c8755f84 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.5 +version: 3.0.0-dev.1 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: ^2.9.0 + solidart: ^3.0.0-dev.1 dev_dependencies: disco: ^1.0.0 diff --git a/packages/solidart/CHANGELOG.md b/packages/solidart/CHANGELOG.md index 75d2f61c..18f9b090 100644 --- a/packages/solidart/CHANGELOG.md +++ b/packages/solidart/CHANGELOG.md @@ -1,4 +1,4 @@ -## 2.9.0 +## 3.0.0-dev.1 - **CHORE**: Upgrade `alien_signals` to `^2.3.1` and adapt the internal reactive adapter to its preset/system APIs. - **FIX**: A lazy nullable `Signal` (e.g. `Signal.lazy()`) now notifies when it is first set to `null`; previously the `None` → `Some(null)` transition was treated as "no change". diff --git a/packages/solidart/pubspec.yaml b/packages/solidart/pubspec.yaml index 2af4b11d..76abe44c 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.9.0 +version: 3.0.0-dev.1 repository: https://github.com/nank1ro/solidart documentation: https://solidart.mariuti.com topics: diff --git a/packages/solidart_hooks/CHANGELOG.md b/packages/solidart_hooks/CHANGELOG.md index 2109ff49..21db0323 100644 --- a/packages/solidart_hooks/CHANGELOG.md +++ b/packages/solidart_hooks/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.0.0-dev.1 + +- **CHORE**: Require `flutter_solidart: ^3.0.0-dev.1` (the `alien_signals` 2.3.1 / solidart 3.0 line). + ## 3.1.4 ### Changes from solidart diff --git a/packages/solidart_hooks/pubspec.yaml b/packages/solidart_hooks/pubspec.yaml index 1b18ba58..2e516d8c 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.4 +version: 4.0.0-dev.1 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: ^2.7.4 + flutter_solidart: ^3.0.0-dev.1 dev_dependencies: flutter_test: diff --git a/packages/solidart_lint/example/pubspec.yaml b/packages/solidart_lint/example/pubspec.yaml index 9dbc2a75..638fdc80 100644 --- a/packages/solidart_lint/example/pubspec.yaml +++ b/packages/solidart_lint/example/pubspec.yaml @@ -31,7 +31,16 @@ dependencies: disco: ^1.0.3 flutter: sdk: flutter - flutter_solidart: ^2.1.0 + flutter_solidart: ^3.0.0-dev.1 + +# Not a workspace member (its package name "example" collides with +# flutter_solidart/example), so point its solidart packages at the latest local +# versions via overrides. +dependency_overrides: + solidart: + path: ../../solidart + flutter_solidart: + path: ../../flutter_solidart dev_dependencies: flutter_test: diff --git a/packages/solidart_lint/pubspec.yaml b/packages/solidart_lint/pubspec.yaml index 6b6f2d18..f002d786 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: ^2.0.0 - flutter_solidart: ^2.0.0 + solidart: ^3.0.0-dev.1 + flutter_solidart: ^3.0.0-dev.1 From 7f2ae12a862c76a077237dd30e06db41189bcc7a Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 25 Jun 2026 18:18:35 +0200 Subject: [PATCH 20/25] feat(solidart)!: auto-dispose is opt-in (default false) and consistent across types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: SolidartConfig.autoDispose now defaults to false — auto-dispose is opt-in from v3. Also make Effect honour its per-instance autoDispose flag (defaulted from SolidartConfig.autoDispose at creation) like Signal and Computed; previously Effect._mayDispose re-checked the global flag, so a per-instance autoDispose: true was ignored when the global was off. Auto-dispose widget tests now run with the default both ON and OFF, asserting entities dispose iff auto-dispose is enabled. --- packages/flutter_solidart/CHANGELOG.md | 3 +- .../test/flutter_solidart_test.dart | 148 ++++++++---------- packages/solidart/CHANGELOG.md | 2 + packages/solidart/lib/src/core/config.dart | 7 +- packages/solidart/lib/src/core/effect.dart | 16 +- packages/solidart/test/solidart_test.dart | 6 +- 6 files changed, 83 insertions(+), 99 deletions(-) diff --git a/packages/flutter_solidart/CHANGELOG.md b/packages/flutter_solidart/CHANGELOG.md index 6e96f233..f97e5c13 100644 --- a/packages/flutter_solidart/CHANGELOG.md +++ b/packages/flutter_solidart/CHANGELOG.md @@ -1,6 +1,7 @@ ## 3.0.0-dev.1 -- **CHORE**: Require `solidart: ^2.9.0` (the `alien_signals` 2.3.1 reactive adapter). +- **BREAKING**: Inherits solidart's auto-dispose change — auto-dispose is now opt-in (`SolidartConfig.autoDispose` defaults to `false`). +- **CHORE**: Require `solidart: ^3.0.0-dev.1` (the `alien_signals` 2.3.1 reactive adapter). - **REFACTOR**: Route `SignalBuilder` through the reactive sub helper (`setCurrentSub`) instead of assigning `activeSub` directly. - **CHORE**: Consume solidart's `reactiveSystem` via `package:solidart/solidart_internal.dart` now that it is no longer part of solidart's public barrel. diff --git a/packages/flutter_solidart/test/flutter_solidart_test.dart b/packages/flutter_solidart/test/flutter_solidart_test.dart index 0bf01b35..e75ef830 100644 --- a/packages/flutter_solidart/test/flutter_solidart_test.dart +++ b/packages/flutter_solidart/test/flutter_solidart_test.dart @@ -730,62 +730,55 @@ void main() { ); group('Automatic disposal', () { - testWidgets( - 'Signal autoDispose', - (tester) async { + // Run every case with the auto-dispose default ON and OFF: each reactive + // entity must dispose iff auto-dispose is enabled. + for (final autoDispose in [true, false]) { + testWidgets('Signal autoDispose=$autoDispose', (tester) async { + SolidartConfig.autoDispose = autoDispose; + addTearDown(() => SolidartConfig.autoDispose = false); final counter = Signal(0); await tester.pumpWidget( MaterialApp( home: Scaffold( body: SignalBuilder( - builder: (_, _) { - return Text(counter.value.toString()); - }, + builder: (_, _) => Text(counter.value.toString()), ), ), ), ); expect(counter.disposed, isFalse); await tester.pumpWidget(const SizedBox()); - expect(counter.disposed, isTrue); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); + expect(counter.disposed, autoDispose); + }, timeout: const Timeout(Duration(seconds: 1))); - testWidgets( - 'ReadSignal autoDispose', - (tester) async { + testWidgets('ReadSignal autoDispose=$autoDispose', (tester) async { + SolidartConfig.autoDispose = autoDispose; + addTearDown(() => SolidartConfig.autoDispose = false); final counter = Signal(0).toReadSignal(); await tester.pumpWidget( MaterialApp( home: Scaffold( body: SignalBuilder( - builder: (_, _) { - return Text(counter.value.toString()); - }, + builder: (_, _) => Text(counter.value.toString()), ), ), ), ); expect(counter.disposed, isFalse); await tester.pumpWidget(const SizedBox()); - expect(counter.disposed, isTrue); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); + expect(counter.disposed, autoDispose); + }, timeout: const Timeout(Duration(seconds: 1))); - testWidgets( - 'Computed autoDispose', - (tester) async { + testWidgets('Computed autoDispose=$autoDispose', (tester) async { + SolidartConfig.autoDispose = autoDispose; + addTearDown(() => SolidartConfig.autoDispose = false); final counter = Signal(0); final doubleCounter = Computed(() => counter.value * 2); await tester.pumpWidget( MaterialApp( home: Scaffold( body: SignalBuilder( - builder: (_, _) { - return Text(doubleCounter.value.toString()); - }, + builder: (_, _) => Text(doubleCounter.value.toString()), ), ), ), @@ -793,24 +786,20 @@ void main() { expect(counter.disposed, isFalse); expect(doubleCounter.disposed, isFalse); await tester.pumpWidget(const SizedBox()); - expect(counter.disposed, isTrue); - expect(doubleCounter.disposed, isTrue); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); + expect(counter.disposed, autoDispose); + expect(doubleCounter.disposed, autoDispose); + }, timeout: const Timeout(Duration(seconds: 1))); - testWidgets( - 'Effect autoDispose', - (tester) async { + testWidgets('Effect autoDispose=$autoDispose', (tester) async { + SolidartConfig.autoDispose = autoDispose; + addTearDown(() => SolidartConfig.autoDispose = false); final counter = Signal(0); final effect = Effect(() => counter.value); await tester.pumpWidget( MaterialApp( home: Scaffold( body: SignalBuilder( - builder: (_, _) { - return Text(counter.value.toString()); - }, + builder: (_, _) => Text(counter.value.toString()), ), ), ), @@ -819,65 +808,60 @@ void main() { expect(effect.disposed, isFalse); await tester.pumpWidget(const SizedBox()); counter.dispose(); - expect(effect.disposed, isTrue); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); + expect(effect.disposed, autoDispose); + }, timeout: const Timeout(Duration(seconds: 1))); - testWidgets( - 'Resource autoDispose', - (tester) async { + testWidgets('Resource autoDispose=$autoDispose', (tester) async { + SolidartConfig.autoDispose = autoDispose; + addTearDown(() => SolidartConfig.autoDispose = false); final r = Resource(() => Future.value(0)); await tester.pumpWidget( MaterialApp( home: Scaffold( body: SignalBuilder( - builder: (_, _) { - return Text(r.state.toString()); - }, + builder: (_, _) => Text(r.state.toString()), ), ), ), ); expect(r.disposed, isFalse); await tester.pumpWidget(const SizedBox()); - expect(r.disposed, isTrue); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - }); - - testWidgets( - 'Effect with multiple dependencies autoDispose', - (tester) async { - final counter = Signal(0); - final doubleCounter = Computed(() => counter.value * 2); - final effect = Effect(() { - counter.value; - doubleCounter.value; - }); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SignalBuilder( - builder: (_, _) { - return Text(counter.value.toString()); - }, + expect(r.disposed, autoDispose); + }, timeout: const Timeout(Duration(seconds: 1))); + + testWidgets( + 'Effect with multiple dependencies autoDispose=$autoDispose', + (tester) async { + SolidartConfig.autoDispose = autoDispose; + addTearDown(() => SolidartConfig.autoDispose = false); + final counter = Signal(0); + final doubleCounter = Computed(() => counter.value * 2); + final effect = Effect(() { + counter.value; + doubleCounter.value; + }); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SignalBuilder( + builder: (_, _) => Text(counter.value.toString()), + ), + ), ), - ), - ), + ); + expect(counter.disposed, isFalse); + expect(doubleCounter.disposed, isFalse); + expect(effect.disposed, isFalse); + await tester.pumpWidget(const SizedBox()); + effect(); // explicit dispose + expect(effect.disposed, isTrue); + expect(counter.disposed, autoDispose); + expect(doubleCounter.disposed, autoDispose); + }, + timeout: const Timeout(Duration(seconds: 1)), ); - expect(counter.disposed, isFalse); - expect(doubleCounter.disposed, isFalse); - expect(effect.disposed, isFalse); - await tester.pumpWidget(const SizedBox()); - effect(); - expect(effect.disposed, isTrue); - expect(counter.disposed, isTrue); - expect(doubleCounter.disposed, isTrue); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); + } + }); testWidgets('SignalBuilder without dependencies throws an error', ( tester, diff --git a/packages/solidart/CHANGELOG.md b/packages/solidart/CHANGELOG.md index 18f9b090..38029c14 100644 --- a/packages/solidart/CHANGELOG.md +++ b/packages/solidart/CHANGELOG.md @@ -1,5 +1,7 @@ ## 3.0.0-dev.1 +- **BREAKING**: `SolidartConfig.autoDispose` now defaults to `false` — auto-dispose is opt-in from v3. Enable it globally (`SolidartConfig.autoDispose = true`) or per `Signal`/`Computed`/`Effect` via the `autoDispose` parameter. +- **FIX**: `Effect` now honours its per-instance `autoDispose` flag (defaulted from `SolidartConfig.autoDispose` at creation), consistent with `Signal` and `Computed`. Previously an effect only auto-disposed when the global `SolidartConfig.autoDispose` was enabled, ignoring a per-instance `autoDispose: true`. - **CHORE**: Upgrade `alien_signals` to `^2.3.1` and adapt the internal reactive adapter to its preset/system APIs. - **FIX**: A lazy nullable `Signal` (e.g. `Signal.lazy()`) now notifies when it is first set to `null`; previously the `None` → `Some(null)` transition was treated as "no change". - **FIX**: Disposing a signal now fully unlinks its subscribers on both sides of each dependency link — and does so regardless of `SolidartConfig.autoDispose` (a disposed signal is destroyed, like `Effect`/`Computed` disposal) — so a later write to the disposed signal can no longer propagate into an already-detached computed. diff --git a/packages/solidart/lib/src/core/config.dart b/packages/solidart/lib/src/core/config.dart index 18275f1b..466af627 100644 --- a/packages/solidart/lib/src/core/config.dart +++ b/packages/solidart/lib/src/core/config.dart @@ -8,9 +8,10 @@ abstract class SolidartConfig { /// 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 auto disposal of the reactive system. Defaults to + /// `false` since v3 — auto-dispose is opt-in. Set it to `true` (globally, or + /// per signal/computed/effect via the `autoDispose` parameter) to enable it. + static bool autoDispose = false; /// Whether to enable the DevTools extension, defaults to false. static bool devToolsEnabled = false; diff --git a/packages/solidart/lib/src/core/effect.dart b/packages/solidart/lib/src/core/effect.dart index ab240f5a..47dd3ff4 100644 --- a/packages/solidart/lib/src/core/effect.dart +++ b/packages/solidart/lib/src/core/effect.dart @@ -198,9 +198,7 @@ class Effect implements ReactionInterface { reactiveSystem.endBatch(); // ignore: cascade_invocations reactiveSystem.setCurrentSub(prevSub); - if (SolidartConfig.autoDispose) { - _mayDispose(); - } + _mayDispose(); } } @@ -232,13 +230,11 @@ class Effect implements ReactionInterface { @override void _mayDispose() { - if (_disposed) return; - - if (SolidartConfig.autoDispose) { - if (!autoDispose || _disposed) return; - if (subscriber.deps?.dep == null) { - dispose(); - } + // Gate on the per-instance `autoDispose` (defaulted from + // SolidartConfig.autoDispose at creation), consistent with Signal/Computed. + if (_disposed || !autoDispose) return; + if (subscriber.deps?.dep == null) { + dispose(); } } } diff --git a/packages/solidart/test/solidart_test.dart b/packages/solidart/test/solidart_test.dart index 8f95157c..dbdc1000 100644 --- a/packages/solidart/test/solidart_test.dart +++ b/packages/solidart/test/solidart_test.dart @@ -393,7 +393,7 @@ void main() { test("Check signal disposed isn't tracked by Computed", () { final count = Signal(1); - final doubleCount = Computed(() => count.value * 2); + final doubleCount = Computed(() => count.value * 2, autoDispose: true); expect(doubleCount.value, 2); expect(count.disposed, false); @@ -770,7 +770,7 @@ void main() { () { final a = Signal(1); final b = Signal(2); - final sum = Computed(() => a.value + b.value); // autoDispose: true + final sum = Computed(() => a.value + b.value, autoDispose: true); expect(sum.value, 3); @@ -821,7 +821,7 @@ void main() { // same `_mayDispose` chance that disposing a Signal does. final s = Signal(0); final c = Computed(() => s.value, autoDispose: false); - final effect = Effect(() => c.value); // only depends on c + final effect = Effect(() => c.value, autoDispose: true); // only on c addTearDown(() { s.dispose(); if (!effect.disposed) effect.dispose(); From 65d16a88e9d1d0253ab9ac23652320c747dcf153 Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 25 Jun 2026 21:59:41 +0200 Subject: [PATCH 21/25] chore(solidart_lint): 3.1.0-dev.1 requiring the solidart 3.0 line solidart_lint now depends on solidart/flutter_solidart ^3.0.0-dev.1; bump to its own dev prerelease so the workspace is on a coherent 3.0.0-dev.1 release. --- packages/solidart_lint/CHANGELOG.md | 4 ++++ packages/solidart_lint/pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/solidart_lint/CHANGELOG.md b/packages/solidart_lint/CHANGELOG.md index 07be8eef..b48c365c 100644 --- a/packages/solidart_lint/CHANGELOG.md +++ b/packages/solidart_lint/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.1.0-dev.1 + +- **CHORE**: Require `solidart: ^3.0.0-dev.1` and `flutter_solidart: ^3.0.0-dev.1` (the `alien_signals` 2.3.1 / solidart 3.0 line). + ## 3.0.1 - Update README.md diff --git a/packages/solidart_lint/pubspec.yaml b/packages/solidart_lint/pubspec.yaml index f002d786..4b841925 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.1.0-dev.1 repository: https://github.com/nank1ro/solidart documentation: https://solidart.mariuti.com topics: From 40892ce92675ade037277c241b12ff0c0f231a7a Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Thu, 25 Jun 2026 22:31:19 +0200 Subject: [PATCH 22/25] ci(solidart): silence remove_deprecations_in_breaking_versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumping to the 3.0 major triggers this lint for Resource's deprecated value/previousState/untrackedValue/on/maybeOn aliases. They are load-bearing (route through `state`, which triggers the resolution that `until`/`untilReady` rely on), so they can't be dropped yet — ignore the lint for now. --- packages/solidart/analysis_options.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/solidart/analysis_options.yaml b/packages/solidart/analysis_options.yaml index c8299567..4a4ad0a4 100644 --- a/packages/solidart/analysis_options.yaml +++ b/packages/solidart/analysis_options.yaml @@ -4,6 +4,10 @@ analyzer: discarded_futures: ignore document_ignores: ignore prefer_foreach: ignore + # Resource's deprecated value/previousState/on/maybeOn aliases are + # load-bearing (they route through `state`, which triggers resolution that + # `until`/`untilReady` depend on), so they can't be removed in 3.0 yet. + remove_deprecations_in_breaking_versions: ignore include: package:very_good_analysis/analysis_options.yaml linter: From 545dbb200ba4c1318487e255840c48a97fc9f7b9 Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Tue, 30 Jun 2026 13:26:19 +0200 Subject: [PATCH 23/25] test(solidart): cover Resource source auto-disposal With the new opt-in auto-dispose default, no existing test exercised the source-disposal branch of Resource.dispose (an autoDispose source with no remaining listeners). Add one, restoring 100% coverage. --- packages/solidart/test/solidart_test.dart | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/solidart/test/solidart_test.dart b/packages/solidart/test/solidart_test.dart index dbdc1000..7e9d8e7b 100644 --- a/packages/solidart/test/solidart_test.dart +++ b/packages/solidart/test/solidart_test.dart @@ -954,6 +954,19 @@ void main() { group( 'Resource tests', () { + test( + 'disposing a Resource disposes an auto-dispose source with no ' + 'remaining listeners', + () { + // Covers the source-disposal branch of Resource.dispose: an + // `autoDispose` source left without listeners is disposed too. + final source = Signal(1, autoDispose: true); + final r = Resource(() async => 'x', source: source); + r.dispose(); + expect(source.disposed, isTrue); + }, + ); + test('check Resource with stream', () async { final streamController = StreamController(); addTearDown(streamController.close); From b8e12ccb1e69911dfb70f9fd7a067ef345c56b92 Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Tue, 30 Jun 2026 13:26:20 +0200 Subject: [PATCH 24/25] chore(solidart): ignore remove_deprecations_in_breaking_versions per line Replace the project-wide lint suppression with per-line // ignore comments on Resource's load-bearing deprecated aliases (value/previousState/untrackedValue/on/maybeOn), which can't be removed for 3.0 yet. --- packages/solidart/analysis_options.yaml | 4 ---- packages/solidart/lib/src/core/resource.dart | 11 +++++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/solidart/analysis_options.yaml b/packages/solidart/analysis_options.yaml index 4a4ad0a4..c8299567 100644 --- a/packages/solidart/analysis_options.yaml +++ b/packages/solidart/analysis_options.yaml @@ -4,10 +4,6 @@ analyzer: discarded_futures: ignore document_ignores: ignore prefer_foreach: ignore - # Resource's deprecated value/previousState/on/maybeOn aliases are - # load-bearing (they route through `state`, which triggers resolution that - # `until`/`untilReady` depend on), so they can't be removed in 3.0 yet. - remove_deprecations_in_breaking_versions: ignore include: package:very_good_analysis/analysis_options.yaml linter: diff --git a/packages/solidart/lib/src/core/resource.dart b/packages/solidart/lib/src/core/resource.dart index d0a84c91..b1bab7bc 100644 --- a/packages/solidart/lib/src/core/resource.dart +++ b/packages/solidart/lib/src/core/resource.dart @@ -224,26 +224,35 @@ class Resource extends Signal> { set state(ResourceState state) => super.value = state; // coverage:ignore-start + // These deprecated aliases are load-bearing (they route through `state`, + // which triggers the resolution `until`/`untilReady` rely on), so they can't + // be removed for the 3.0 breaking version yet. + // ignore: remove_deprecations_in_breaking_versions @Deprecated('Use state instead') @override ResourceState get value => state; + // ignore: remove_deprecations_in_breaking_versions @Deprecated('Use state instead') @override set value(ResourceState value) => state = value; + // ignore: remove_deprecations_in_breaking_versions @Deprecated('Use previousState instead') @override ResourceState? get previousValue => previousState; + // ignore: remove_deprecations_in_breaking_versions @Deprecated('Use untrackedState instead') @override ResourceState get untrackedValue => untrackedState; + // ignore: remove_deprecations_in_breaking_versions @Deprecated('Use untrackedPreviousState instead') @override ResourceState? get untrackedPreviousValue => untrackedPreviousState; + // ignore: remove_deprecations_in_breaking_versions @Deprecated('Use update instead') @override ResourceState updateValue( @@ -748,6 +757,7 @@ extension ResourceExtensions on ResourceState { /// Performs an action based on the state of the [ResourceState]. /// /// All cases are required. + // ignore: remove_deprecations_in_breaking_versions @Deprecated('Use when instead') R on({ required R Function(T data) ready, @@ -774,6 +784,7 @@ extension ResourceExtensions on ResourceState { /// Performs an action based on the state of the [ResourceState], or call /// [orElse] if the current state is not considered. + // ignore: remove_deprecations_in_breaking_versions @Deprecated('Use maybeWhen instead') R maybeOn({ required R Function() orElse, From 890ded9e6b3f7187e9e5e313ec1e548ae9b2ac62 Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Tue, 30 Jun 2026 13:26:20 +0200 Subject: [PATCH 25/25] chore: make solidart_lint example a workspace member Rename the example package (example -> solidart_lint_example) so it no longer collides with flutter_solidart/example, add it to the workspace, and drop the path dependency_overrides. Bump its flutter_lints to ^6.0.0 to match the workspace. No dependency_overrides remain in the repo. --- packages/solidart_lint/example/pubspec.yaml | 15 ++++----------- pubspec.yaml | 1 + 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/solidart_lint/example/pubspec.yaml b/packages/solidart_lint/example/pubspec.yaml index 638fdc80..e6f806bc 100644 --- a/packages/solidart_lint/example/pubspec.yaml +++ b/packages/solidart_lint/example/pubspec.yaml @@ -1,4 +1,4 @@ -name: example +name: solidart_lint_example description: A new Flutter project. # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. @@ -21,6 +21,8 @@ version: 1.0.0+1 environment: sdk: ^3.10.0 +resolution: workspace + # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions # consider running `flutter pub upgrade --major-versions`. Alternatively, @@ -33,15 +35,6 @@ dependencies: sdk: flutter flutter_solidart: ^3.0.0-dev.1 -# Not a workspace member (its package name "example" collides with -# flutter_solidart/example), so point its solidart packages at the latest local -# versions via overrides. -dependency_overrides: - solidart: - path: ../../solidart - flutter_solidart: - path: ../../flutter_solidart - dev_dependencies: flutter_test: sdk: flutter @@ -51,7 +44,7 @@ dev_dependencies: # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/pubspec.yaml b/pubspec.yaml index 917c4c9a..ad4f237c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,7 @@ workspace: - packages/solidart - packages/solidart_devtools_extension - packages/solidart_lint + - packages/solidart_lint/example - packages/solidart_hooks - packages/solidart_hooks/example - examples/counter