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 ac29d637..f97e5c13 100644 --- a/packages/flutter_solidart/CHANGELOG.md +++ b/packages/flutter_solidart/CHANGELOG.md @@ -1,3 +1,10 @@ +## 3.0.0-dev.1 + +- **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. + ## 2.7.4 ### Changes from solidart 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/lib/src/widgets/signal_builder.dart b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart index 559d2b74..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. @@ -83,9 +85,9 @@ class _SignalBuilderElement extends StatelessElement { @override Widget build() { - final prevSub = reactiveSystem.activeSub; // ignore: invalid_use_of_protected_member - final node = reactiveSystem.activeSub = effect.subscriber; + final node = effect.subscriber; + final prevSub = reactiveSystem.setCurrentSub(node); try { final built = super.build(); @@ -101,7 +103,7 @@ You can disable this check by setting `SolidartConfig.assertSignalBuilderWithout return built; } finally { - reactiveSystem.activeSub = prevSub; + reactiveSystem.setCurrentSub(prevSub); } } } diff --git a/packages/flutter_solidart/pubspec.yaml b/packages/flutter_solidart/pubspec.yaml index fefd855b..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.4 +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.8.6 + solidart: ^3.0.0-dev.1 dev_dependencies: disco: ^1.0.0 diff --git a/packages/flutter_solidart/test/flutter_solidart_test.dart b/packages/flutter_solidart/test/flutter_solidart_test.dart index a7ec346a..e75ef830 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 { @@ -554,6 +530,7 @@ void main() { await tester.pumpWidget( MaterialApp( + theme: ThemeData(splashFactory: NoSplash.splashFactory), home: Scaffold( body: ProviderScope( providers: [counterProvider], @@ -614,6 +591,7 @@ void main() { await tester.pumpWidget( MaterialApp( + theme: ThemeData(splashFactory: NoSplash.splashFactory), home: Scaffold( body: ProviderScope( providers: [ @@ -652,6 +630,7 @@ void main() { final counterProvider = Provider((_) => Signal(0)); await tester.pumpWidget( MaterialApp( + theme: ThemeData(splashFactory: NoSplash.splashFactory), home: Scaffold( body: ProviderScope( providers: [ @@ -686,10 +665,12 @@ void main() { }); testWidgets('(ArgProvider) Signal.updateValue method', (tester) async { - // ignore: avoid_types_on_closure_parameters - 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), home: Scaffold( body: ProviderScope( providers: [ @@ -749,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()), ), ), ), @@ -812,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()), ), ), ), @@ -838,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 699801f9..38029c14 100644 --- a/packages/solidart/CHANGELOG.md +++ b/packages/solidart/CHANGELOG.md @@ -1,3 +1,14 @@ +## 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. +- **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 (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 - **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/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; diff --git a/packages/solidart/lib/src/core/alien.dart b/packages/solidart/lib/src/core/alien.dart index d6417dfa..f112ef01 100644 --- a/packages/solidart/lib/src/core/alien.dart +++ b/packages/solidart/lib/src/core/alien.dart @@ -1,74 +1,93 @@ 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() => reactiveSystem.stopEffect(this); + 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); + } @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 !parent._compare(oldValue, currentValue); } finally { - reactiveSystem - ..setCurrentSub(prevSub) - ..endTracking(this); + alien.setActiveSub(prevSub); + flags &= ~alien_system.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; @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 (previousValue is None || currentValue is None) { + return previousValue is! None || currentValue is! None; + } + + 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..74909bc2 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() { @@ -230,10 +230,8 @@ class Computed extends ReadSignal { return _hasPreviousValue; } - // coverage:ignore-start @override - int get listenerCount => _deps.length; - // coverage:ignore-end + int get listenerCount => _internalComputed.subscriberCount; @override void onDispose(VoidCallback cb) { @@ -260,7 +258,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/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/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..47dd3ff4 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(); @@ -197,15 +198,13 @@ class Effect implements ReactionInterface { reactiveSystem.endBatch(); // ignore: cascade_invocations reactiveSystem.setCurrentSub(prevSub); - if (SolidartConfig.autoDispose) { - _mayDispose(); - } + _mayDispose(); } } /// 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()); @@ -231,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/lib/src/core/reactive_system.dart b/packages/solidart/lib/src/core/reactive_system.dart index 25d88943..b3874636 100644 --- a/packages/solidart/lib/src/core/reactive_system.dart +++ b/packages/solidart/lib/src/core/reactive_system.dart @@ -3,17 +3,49 @@ // 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() { +// 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 { + 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]) { + /// 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; + } + + /// 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) { switch (dep) { @@ -46,158 +78,71 @@ 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(); + // 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); } - void startBatch() => ++batchDepth; - void endBatch() { - if ((--batchDepth) == 0) flush(); - } + void startBatch() => alien.startBatch(); - alien.ReactiveNode? setCurrentSub(alien.ReactiveNode? sub) { - final prevSub = activeSub; - activeSub = sub; - return prevSub; + 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!); - } - - return computed.value as T; + // 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(); } 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; - } + // 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); } - 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..84ba6e17 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() { @@ -273,32 +261,12 @@ class ReadableSignal implements ReadSignal { reactiveSystem.getSignalValue(_internalSignal); }); - 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(); - } - } - _subs.clear(); - } + // Fully unlink every subscriber from this signal — dispose means destroy, + // so this runs regardless of `SolidartConfig.autoDispose` (matching + // 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(); @@ -337,7 +305,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 @@ -351,7 +320,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/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, diff --git a/packages/solidart/pubspec.yaml b/packages/solidart/pubspec.yaml index 2b328347..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.8.6 +version: 3.0.0-dev.1 repository: https://github.com/nank1ro/solidart documentation: https://solidart.mariuti.com topics: @@ -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 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 10698dc2..7e9d8e7b 100644 --- a/packages/solidart/test/solidart_test.dart +++ b/packages/solidart/test/solidart_test.dart @@ -1,8 +1,9 @@ -// ignore_for_file: cascade_invocations, unreachable_from_main +// ignore_for_file: cascade_invocations, invalid_use_of_protected_member 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'; @@ -11,20 +12,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(); } @@ -65,11 +52,6 @@ class User { String toString() => 'User(id: $id)'; } -class SampleList { - SampleList(this.numbers); - final List numbers; -} - class MockSolidartObserver extends Mock implements SolidartObserver {} void main() { @@ -337,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''', () { @@ -392,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); @@ -457,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); @@ -552,17 +553,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); @@ -636,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); @@ -737,6 +764,94 @@ 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('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('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, autoDispose: true); // only 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); + 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); @@ -758,6 +873,52 @@ 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); + }); + + test( + 'disposing a signal unlinks its subscribers even with auto-dispose off', + () { + // 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; + + final count = Signal(1); + final doubled = Computed(() => count.value * 2); + 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; + expect(doubled.value, 2); + }, + ); }, timeout: const Timeout(Duration(seconds: 1)), ); @@ -793,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); @@ -2106,6 +2280,131 @@ void main() { b.value = 0; }); }); + + 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(() { + final s = source.value; + if (s > 0) { + Effect(() { + child.value; + childRuns++; + }); + } + return s; + }); + + addTearDown(() { + computed.dispose(); + source.dispose(); + child.dispose(); + }); + + expect(computed.value, 1); + expect(childRuns, equals(1)); + + // The single live child reacts to its own dependency. + child.value++; + expect(childRuns, equals(2)); + + // 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(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( + '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)); + // setCurrentSub returns the previously-active sub (save/restore). + final prevFromEffect = reactiveSystem.setCurrentSub( + effect.subscriber, + ); + expect(prevFromEffect, isNull); + expect(reactiveSystem.activeSub, same(effect.subscriber)); + final prevFromNull = reactiveSystem.setCurrentSub(null); + expect(prevFromNull, same(effect.subscriber)); + + effect.run(); + 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.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; + reactiveSystem.stopEffect(effect.subscriber); + signal.value++; + expect(effectRuns, equals(runsAfterFlush)); + }, + ); }, timeout: const Timeout(Duration(seconds: 1)), ); 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/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/example/pubspec.yaml b/packages/solidart_lint/example/pubspec.yaml index 9dbc2a75..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, @@ -31,7 +33,7 @@ dependencies: disco: ^1.0.3 flutter: sdk: flutter - flutter_solidart: ^2.1.0 + flutter_solidart: ^3.0.0-dev.1 dev_dependencies: flutter_test: @@ -42,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/packages/solidart_lint/pubspec.yaml b/packages/solidart_lint/pubspec.yaml index 6b6f2d18..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: @@ -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 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