diff --git a/README.md b/README.md index 864d881d..7598e4aa 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ To change the value, you can use: // Set the value to 2 counter.value = 2; // Update the value based on the current value -counter.updateValue((value) => value * 2); +counter.value *= 2; ``` ### Effect @@ -177,8 +177,8 @@ SignalBuilder( ) ``` -The `on` method forces you to handle all the states of a Resource (_ready_, _error_ and _loading_). -The are also other convenience methods to handle only specific states. +The `when` method forces you to handle all the states of a Resource (_ready_, _error_ and _loading_). +There are also convenience helpers like `maybeWhen`, `asReady`, and `asError`. ### Dependency Injection diff --git a/benchmark.dart b/benchmark.dart index 4d5289f4..3574917e 100644 --- a/benchmark.dart +++ b/benchmark.dart @@ -39,7 +39,6 @@ void main() { solidart.SolidartConfig.devToolsEnabled = false; solidart.SolidartConfig.trackPreviousValue = false; solidart.SolidartConfig.autoDispose = false; - solidart.SolidartConfig.equals = true; const framework = SolidartReactiveFramework(); runFrameworkBench(framework); } diff --git a/docs-v2/src/content/docs/migration.mdx b/docs-v2/src/content/docs/migration.mdx new file mode 100644 index 00000000..fb0a12da --- /dev/null +++ b/docs-v2/src/content/docs/migration.mdx @@ -0,0 +1,169 @@ +--- +title: Migration (v2 -> v3) +description: Upgrade guide for solidart v3 +--- + +This guide is generated by comparing **v2** with **v3**. It reflects the actual +public API changes in +`solidart`, `flutter_solidart`, and `solidart_hooks`. + +## 1) Update package versions (upgrade together) + +```yaml +# pubspec.yaml + +dependencies: + solidart: 3.0.0-dev.0 + flutter_solidart: 3.0.0-dev.0 + solidart_hooks: 3.2.0-dev.0 + +dev_dependencies: + solidart_lint: 3.0.3 +``` + +## 2) Entry points and exports + +### solidart + +**v2 (main) exported:** + +- `src/core/core.dart` +- `src/extensions/until.dart` +- `src/utils.dart` (Debouncer / SolidartException, etc.) + +**v3 (current) exported:** + +- `Signal`, `Computed`, `Effect`, `Resource`, `ListSignal`, `MapSignal`, `SetSignal` +- `ReadonlySignal`, `SolidartConfig`, `SolidartObserver` +- `ObserveSignal`, `UntilSignal` +- `batch`, `untracked` +- `DisposeObservation`, `ObserveCallback`, `ValueComparator` + +**New advanced entry point:** + +```dart +import 'package:solidart/advanced.dart'; +``` + +Exports `Configuration`, `Disposable`, `DisposableMixin`, `Identifier`, +`Option`, `Some`, `None` (previously in core/utils). + +## 3) Core API changes (main -> current) + +### 3.1 Types and naming + +| v2 (main) | v3 (current) | Notes | +| ------------------------------------- | --------------------------------- | ---------------------------------------------- | +| `SignalBase` | `ReadonlySignal` / `Signal` | `SignalBase` removed | +| `ReadSignal` / `ReadableSignal` | `ReadonlySignal` | ReadSignal types removed | +| `toReadSignal()` | `toReadonly()` | Rename | +| `disposed` | `isDisposed` | Rename | +| `hasValue` | `LazySignal.isInitialized` | Normal `Signal` is always initialized | +| `hasPreviousValue` | Removed | Use `previousValue` / `untrackedPreviousValue` | +| `name` | `identifier.name` | `name` field removed from public API | + +### 3.2 Signal updates + +**Removed:** `updateValue` / `setValue` public API. + +Use `.value` assignment instead. + +```dart +// v2 +counter.updateValue((v) => v + 1); + +// v3 +counter.value += 1; +``` + +**Comparator change:** + +- v2 had `equals` (bool) + `comparator` +- v3 keeps only a comparator: + +```dart +final s = Signal(0, equals: (a, b) => a == b); +``` + +### 3.3 Effect + +**v2:** `Effect(callback, delay:, onError:, autorun:)` and `effect()` to dispose. +**v3:** `delay`, `onError`, `autorun` removed. Dispose via `effect.dispose()`. + +```dart +// v2 +final dispose = Effect(() { ... }, delay: ..., onError: ...); +dispose(); + +// v3 +final effect = Effect(() { ... }); +effect.dispose(); +``` + +If you need delayed/debounced effects, use `Timer`/debounce in user code. + +### 3.4 Resource + +- `source` now accepts `ReadonlySignal` instead of `SignalBase`. +- `update` / `updateValue` removed → set `state` directly. +- `ResourceExtensions` replaced by `ResourceStateExtensions`. +- `on()` is removed (was deprecated in v2). +- `resolve()` is now public (v2 had private `_resolve()`). + +```dart +// v2 +resource.update((state) => state); + +// v3 +resource.state = ResourceState.ready(value); +``` + +### 3.5 Utils removal + +`solidart.dart` no longer exports: + +- `Debouncer` / `DebounceOperation` +- `SolidartException`, `SolidartReactionException`, `SolidartCaughtException` +- `createDelayedScheduler` + +If you relied on them, reimplement in your app or wrap them in a utility layer. + +## 4) Flutter integration (flutter_solidart) + +- `ReadableSignal` removed; use `ReadonlySignal`. +- `ValueNotifierSignalMixin` is no longer exported. + +Extensions changed: + +- v2: `SignalBase.toValueNotifier()` and `ValueNotifier.toSignal()` +- v3: `ReadonlySignal.toValueNotifier()` and `ValueListenable.toSignal()` + +## 5) Hooks (solidart_hooks) + +- `ReadSignal` → `ReadonlySignal` +- `equals/comparator` replaced by `equals: ValueComparator` + +Lifecycle change: + +- v2: dispose on unmount only if `autoDispose` was true +- v3: hook-created signals are **always** disposed on unmount + +Use `useExistingSignal(...)` to bind external signals without disposing them. + +## 6) Behavioral differences + +- `SolidartConfig.autoDispose` default changed **true → false** +- `SolidartConfig.equals` removed +- `previousValue` updates only after a tracked read +- `LazySignal` throws if read before first assignment (check `isInitialized`) + +## 7) Quick migration checklist + +- [ ] Upgrade versions in all solidart ecosystem packages +- [ ] Replace `ReadSignal`/`SignalBase` with `ReadonlySignal` +- [ ] Replace `toReadSignal()` with `toReadonly()` +- [ ] Remove `updateValue` usages (use `.value = ...`) +- [ ] Update Effect usage (no `delay/onError`, dispose via `.dispose()`) +- [ ] Update Resource state handling (`state =`, `when/maybeWhen`) +- [ ] Update Flutter extensions (`toValueNotifier`/`toSignal`) +- [ ] Adjust hooks disposal expectations diff --git a/examples/auth_flow/.metadata b/examples/auth_flow/.metadata index d5973ee1..7e9cd504 100644 --- a/examples/auth_flow/.metadata +++ b/examples/auth_flow/.metadata @@ -15,7 +15,7 @@ migration: - platform: root create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - - platform: macos + - platform: web create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 diff --git a/examples/auth_flow/lib/domain/user.dart b/examples/auth_flow/lib/domain/user.dart index 46f4d755..1eb2d622 100644 --- a/examples/auth_flow/lib/domain/user.dart +++ b/examples/auth_flow/lib/domain/user.dart @@ -8,8 +8,11 @@ class User { final String name; final String email; - factory User.fromMap(Map map) => - User(id: map['id'] as String, name: map['name'] as String, email: map['email'] as String); + factory User.fromMap(Map map) => User( + id: map['id'] as String, + name: map['name'] as String, + email: map['email'] as String, + ); Map toMap() => {'id': id, 'name': name, 'email': email}; } diff --git a/examples/auth_flow/lib/main.dart b/examples/auth_flow/lib/main.dart index 3fe4f614..a6d4245a 100644 --- a/examples/auth_flow/lib/main.dart +++ b/examples/auth_flow/lib/main.dart @@ -9,7 +9,9 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await initLocalStorage(); - runApp(ProviderScope(providers: [AuthNotifier.provider], child: const MyApp())); + runApp( + ProviderScope(providers: [AuthNotifier.provider], child: const MyApp()), + ); } class MyApp extends StatefulWidget { @@ -39,7 +41,9 @@ class _MyAppState extends State { return MaterialApp.router( title: 'Auth Demo - GoRouter', debugShowCheckedModeBanner: false, - theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple)), + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + ), routerConfig: router, ); } diff --git a/examples/auth_flow/lib/routes/app_router.dart b/examples/auth_flow/lib/routes/app_router.dart index 489ad514..58aceffd 100644 --- a/examples/auth_flow/lib/routes/app_router.dart +++ b/examples/auth_flow/lib/routes/app_router.dart @@ -2,6 +2,7 @@ import 'package:auth_flow/notifiers/auth_notifier.dart'; import 'package:auth_flow/ui/home_page.dart'; import 'package:auth_flow/ui/login_page.dart'; import 'package:auth_flow/ui/profile_page.dart'; +import 'package:flutter_solidart/flutter_solidart.dart'; import 'package:go_router/go_router.dart'; class AppRouter { @@ -10,7 +11,7 @@ class AppRouter { final AuthNotifier authNotifier; late final router = GoRouter( - refreshListenable: authNotifier.isLoggedIn, + refreshListenable: authNotifier.isLoggedIn.toValueNotifier(), redirect: (context, state) { final isLoggedIn = authNotifier.isLoggedIn.value; if (!isLoggedIn && state.matchedLocation != '/login') { @@ -25,7 +26,9 @@ class AppRouter { GoRoute( path: '/', builder: (_, _) => const HomePage(title: 'Home'), - routes: [GoRoute(path: 'profile', builder: (_, _) => const ProfilePage())], + routes: [ + GoRoute(path: 'profile', builder: (_, _) => const ProfilePage()), + ], ), GoRoute(path: '/login', builder: (_, _) => const LoginPage()), ], diff --git a/examples/auth_flow/lib/ui/login_page.dart b/examples/auth_flow/lib/ui/login_page.dart index aba7ae49..a89e10d7 100644 --- a/examples/auth_flow/lib/ui/login_page.dart +++ b/examples/auth_flow/lib/ui/login_page.dart @@ -16,7 +16,13 @@ class LoginPage extends StatelessWidget { ElevatedButton( onPressed: () { final controller = AuthNotifier.provider.of(context); - controller.login(User(id: '1', name: 'John Doe', email: 'john.doe@example.com')); + controller.login( + User( + id: '1', + name: 'John Doe', + email: 'john.doe@example.com', + ), + ); }, child: Text('Login'), ), diff --git a/examples/auth_flow/pubspec.yaml b/examples/auth_flow/pubspec.yaml index 92430094..460b48ec 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.0 go_router: ^17.0.0 localstorage: ^6.0.0 @@ -23,4 +23,3 @@ dev_dependencies: flutter: uses-material-design: true - \ No newline at end of file diff --git a/examples/counter/.metadata b/examples/counter/.metadata index d52ff02e..7e9cd504 100644 --- a/examples/counter/.metadata +++ b/examples/counter/.metadata @@ -1,11 +1,11 @@ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # -# This file should be version controlled. +# This file should be version controlled and should not be manually edited. version: - revision: 135454af32477f815a7525073027a3ff9eff1bfd - channel: stable + revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407" + channel: "stable" project_type: app @@ -13,20 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 135454af32477f815a7525073027a3ff9eff1bfd - base_revision: 135454af32477f815a7525073027a3ff9eff1bfd - - platform: android - create_revision: 135454af32477f815a7525073027a3ff9eff1bfd - base_revision: 135454af32477f815a7525073027a3ff9eff1bfd - - platform: ios - create_revision: 135454af32477f815a7525073027a3ff9eff1bfd - base_revision: 135454af32477f815a7525073027a3ff9eff1bfd - - platform: macos - create_revision: 135454af32477f815a7525073027a3ff9eff1bfd - base_revision: 135454af32477f815a7525073027a3ff9eff1bfd + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - platform: web - create_revision: 135454af32477f815a7525073027a3ff9eff1bfd - base_revision: 135454af32477f815a7525073027a3ff9eff1bfd + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 # User provided section diff --git a/examples/counter/pubspec.yaml b/examples/counter/pubspec.yaml index 4830744a..54419794 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.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/examples/github_search/.metadata b/examples/github_search/.metadata index 7073f9fa..7e9cd504 100644 --- a/examples/github_search/.metadata +++ b/examples/github_search/.metadata @@ -1,11 +1,11 @@ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # -# This file should be version controlled. +# This file should be version controlled and should not be manually edited. version: - revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 - channel: stable + revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407" + channel: "stable" project_type: app @@ -13,20 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 - base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 - - platform: android - create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 - base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 - - platform: ios - create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 - base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 - - platform: macos - create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 - base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - platform: web - create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 - base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 # User provided section diff --git a/examples/github_search/pubspec.yaml b/examples/github_search/pubspec.yaml index 7d812a49..a5a8b7a8 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.0 json_annotation: ^4.8.1 equatable: ^2.0.5 http: ^1.3.0 diff --git a/examples/infinite_scroll/.metadata b/examples/infinite_scroll/.metadata index e9009f25..7e9cd504 100644 --- a/examples/infinite_scroll/.metadata +++ b/examples/infinite_scroll/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "f5a8537f90d143abd5bb2f658fa69c388da9677b" + revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407" channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b - base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b - - platform: macos - create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b - base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + - platform: web + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 # User provided section diff --git a/examples/infinite_scroll/pubspec.yaml b/examples/infinite_scroll/pubspec.yaml index 4207ba68..1223d4f0 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.0 http: ^1.6.0 dev_dependencies: diff --git a/examples/todos/.metadata b/examples/todos/.metadata index e5df1621..7e9cd504 100644 --- a/examples/todos/.metadata +++ b/examples/todos/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "09de023485e95e6d1225c2baa44b8feb85e0d45f" + revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407" channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 09de023485e95e6d1225c2baa44b8feb85e0d45f - base_revision: 09de023485e95e6d1225c2baa44b8feb85e0d45f + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - platform: web - create_revision: 09de023485e95e6d1225c2baa44b8feb85e0d45f - base_revision: 09de023485e95e6d1225c2baa44b8feb85e0d45f + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 # User provided section diff --git a/examples/todos/lib/controllers/todos.dart b/examples/todos/lib/controllers/todos.dart index 0bd5f103..f81fae7f 100644 --- a/examples/todos/lib/controllers/todos.dart +++ b/examples/todos/lib/controllers/todos.dart @@ -12,7 +12,7 @@ final todosControllerProvider = Provider( /// - `add`: Add a todo in the list of [todos] /// - `remove`: Removes a todo with the given id from the list of [todos] /// - `toggle`: Toggles a todo with the given id -/// The list of todos exposed is a [ReadSignal] so the user cannot mutate +/// The list of todos exposed is a [ReadonlySignal] so the user cannot mutate /// the signal without using this controller. @immutable class TodosController { diff --git a/examples/todos/lib/widgets/todos_list.dart b/examples/todos/lib/widgets/todos_list.dart index c33d7ab2..cc7cf911 100644 --- a/examples/todos/lib/widgets/todos_list.dart +++ b/examples/todos/lib/widgets/todos_list.dart @@ -22,7 +22,7 @@ class _TodoListState extends State { late final todosController = todosControllerProvider.of(context); // Given a [filter] return the correct list of todos - ReadSignal> mapFilterToTodosList(TodosFilter filter) { + ReadonlySignal> mapFilterToTodosList(TodosFilter filter) { switch (filter) { case TodosFilter.all: return todosController.todos; diff --git a/examples/todos/lib/widgets/toolbar.dart b/examples/todos/lib/widgets/toolbar.dart index 5047bcd7..c8b4889e 100644 --- a/examples/todos/lib/widgets/toolbar.dart +++ b/examples/todos/lib/widgets/toolbar.dart @@ -17,12 +17,12 @@ class _ToolbarState extends State { /// All the derived signals, they will react only when the `length` property /// changes - late final allTodosCount = Computed(() => todosController.todos().length); + late final allTodosCount = Computed(() => todosController.todos.length); late final incompleteTodosCount = Computed( - () => todosController.incompleteTodos().length, + () => todosController.incompleteTodos.value.length, ); late final completedTodosCount = Computed( - () => todosController.completedTodos().length, + () => todosController.completedTodos.value.length, ); @override @@ -34,7 +34,7 @@ class _ToolbarState extends State { } /// Maps the given [filter] to the correct list of todos - ReadSignal mapFilterToTodosList(TodosFilter filter) { + ReadonlySignal mapFilterToTodosList(TodosFilter filter) { switch (filter) { case TodosFilter.all: return allTodosCount; diff --git a/examples/todos/pubspec.yaml b/examples/todos/pubspec.yaml index e106719f..c65d2cd3 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.0 uuid: ^4.5.1 dev_dependencies: diff --git a/examples/toggle_theme/pubspec.yaml b/examples/toggle_theme/pubspec.yaml index 381ef608..f0938d23 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.0 dev_dependencies: flutter_test: diff --git a/packages/flutter_solidart/CHANGELOG.md b/packages/flutter_solidart/CHANGELOG.md index 6e3fb21b..fda672c8 100644 --- a/packages/flutter_solidart/CHANGELOG.md +++ b/packages/flutter_solidart/CHANGELOG.md @@ -1,3 +1,31 @@ +## 3.0.0-dev.0 (Unreleased) + +### Signals + +- **BREAKING**: Flutter `Signal`, `Computed`, `Resource`, and collection wrappers now implement `ValueListenable` only (the `ValueNotifier` mixins are removed). +- **REMOVED**: `ValueNotifierSignalMixin` and `ValueListenableSignalMixin` exports. +- **BREAKING**: `Signal.toReadSignal()` is replaced by `toReadonly()` and `ReadableSignal` is removed in favor of `ReadonlySignal`. +- **REMOVED**: `Signal.updateValue` and `ToggleBoolSignal`. +- **ADDED**: `LazySignal` wrapper returned by `Signal.lazy`. + +### Resource + +- **BREAKING**: Resource state helpers align with v3 (`ResourceStateExtensions` with `when`/`maybeWhen`; `on`/`maybeOn` removed). +- **ADDED**: `Resource.resolve()` is now public (via core). + +### Widgets + +- **REFACTOR**: `SignalBuilder` reworked for v3 dependency tracking. +- **REFACTOR**: `Show` simplified to a `StatelessWidget` backed by `SignalBuilder`. + +### Interop + +- **BREAKING**: `SignalBase.toValueNotifier()` becomes `ReadonlySignal.toValueNotifier()`, and `ValueNotifier.toSignal()` becomes `ValueListenable.toSignal()`. + +### Dependencies + +- **CHORE**: Bump `solidart` to `3.0.0-dev.0`. + ## 2.7.2 ### Changes from solidart @@ -87,8 +115,8 @@ - **REFACTOR**: Update `alien_signals` dependency from `^0.2.1` to `^0.4.3` with significant performance improvements (thanks to @medz). - **REFACTOR**: Replace custom reactive node implementations with `alien.ReactiveNode` for better compatibility and performance (thanks to @medz). - **REFACTOR**: Simplify signal, computed and effect implementations by leveraging new `alien_signals` API (thanks to @medz). -- **PERFORMANCE**: Improve performance by removing redundant tracking operations in the reactive system (thanks to @medz). -- **FIX**: Add proper cleanup for disposed nodes to prevent memory leaks (thanks to @medz). +- **PERFORMANCE**: Improve performance by removing redundant tracking operations in the reactive system (thanks to @medz). +- **FIX**: Add proper cleanup for disposed nodes to prevent memory leaks (thanks to @medz). - **FIX**: Fix potential memory leaks in auto-dispose scenarios (thanks to @medz). - **FIX**: Clear queued flag when running effects in `ReactiveSystem` to ensure proper effect execution (thanks to @medz). - **CHORE**: Reorder dev_dependencies in pubspec.yaml for improved organization and readability (thanks to @medz). @@ -127,8 +155,8 @@ ### Changes from solidart -- *CHORE*: Remove deprecated `createSignal`, `createComputed`, `createEffect` and `createResource` helpers. -- *CHORE*: Remove `SignalOptions` and `ResourceOptions` classes. +- _CHORE_: Remove deprecated `createSignal`, `createComputed`, `createEffect` and `createResource` helpers. +- _CHORE_: Remove `SignalOptions` and `ResourceOptions` classes. ## 2.0.0-dev.1 @@ -197,7 +225,7 @@ Update solidart version ### Changes from solidart - **FEAT**: Add 3 new signals: `ListSignal`, `SetSignal` and `MapSignal`. Now you can easily be notified of every change of a list, set or map. - _Before_: + _Before_: ```dart final list = Signal([1, 2]); @@ -242,6 +270,7 @@ The core of the library has been rewritten in order to support automatic depende - The `Show` widget now takes a functions that returns a `bool`. You can easily convert any type to `bool`, for example: + ```dart final count = createSignal(0); @@ -254,11 +283,13 @@ The core of the library has been rewritten in order to support automatic depende ); } ``` + - Converting a `ValueNotifier` into a `Signal` now uses the `equals` comparator to keep the consistency. - Rename `resource` parameter of `ResourceWidgetBuilder` into `resourceState`. (thanks to @manuel-plavsic) - **FEAT** Allow multiple providers of the same type by specifying an `id`entifier. ### Provider declaration: + ```dart SolidProvider( create: () => const NumberProvider(1), @@ -271,6 +302,7 @@ The core of the library has been rewritten in order to support automatic depende ``` ### Access a specific provider + ```dart final numberProvider1 = context.get(1); final numberProvider2 = context.get(2); @@ -298,6 +330,7 @@ The core of the library has been rewritten in order to support automatic depende ], ), ``` + - **FEAT** You can access a specific `Signal` without specifing an `id`entifier, for example: ```dart // to get the signal @@ -372,11 +405,11 @@ The core of the library has been rewritten in order to support automatic depende - **CHORE**: Move `refreshing` from `ResourceWidgetBuilder` into the `ResourceState`. (thanks to @manuel-plavsic) - **FEAT**: Add `hasPreviousValue` getter to `ReadSignal`. (thanks to @manuel-plavsic) - **FEAT** Before, only the `fetcher` reacted to the `source`. -Now also the `stream` reacts to the `source` changes by subscribing again to the stream. -In addition, the `stream` parameter of the Resource has been changed from `Stream` into a `Stream Function()` in order to be able to listen to a new stream if it changed. + Now also the `stream` reacts to the `source` changes by subscribing again to the stream. + In addition, the `stream` parameter of the Resource has been changed from `Stream` into a `Stream Function()` in order to be able to listen to a new stream if it changed. - **FEAT**: Add the `select` method on the `Resource` class. -The `select` function allows filtering the `Resource`'s data by reading only the properties that you care about. -The advantage is that you keep handling the loading and error states. + The `select` function allows filtering the `Resource`'s data by reading only the properties that you care about. + The advantage is that you keep handling the loading and error states. - **FEAT**: Make the `Resource` to auto-resolve when accessing its `state`. - **CHORE**: The `refetch` method of a `Resource` has been renamed to `refresh`. - **FEAT**: You can decide whether to use `createSignal()` or directly the `Signal()` constructor, now the're equivalent. The same applies to all the other `create` functions. @@ -389,8 +422,8 @@ The advantage is that you keep handling the loading and error states. ### Changes from solidart - **FEAT**: Add the select method on the Resource class. -The select function allows filtering the Resource's data by reading only the properties that you care about. -The advantage is that you keep handling the loading and error states. + The select function allows filtering the Resource's data by reading only the properties that you care about. + The advantage is that you keep handling the loading and error states. - **FEAT**: Make the Resource to auto-resolve when accessing its state ## 1.0.0-dev902 @@ -414,6 +447,7 @@ The advantage is that you keep handling the loading and error states. - **FEAT** Allow multiple providers of the same type by specifying an `id`entifier. ### Provider declaration: + ```dart SolidProvider( create: () => const NumberProvider(1), @@ -426,6 +460,7 @@ The advantage is that you keep handling the loading and error states. ``` ### Access a specific provider + ```dart final numberProvider1 = context.get(1); final numberProvider2 = context.get(2); @@ -468,8 +503,8 @@ The advantage is that you keep handling the loading and error states. ### Changes from solidart - **FEAT** Before, only the `fetcher` reacted to the `source`. -Now also the `stream` reacts to the `source` changes by subscribing again to the stream. -In addition, the `stream` parameter of the Resource has been changed from `Stream` into a `Stream Function()` in order to be able to listen to a new stream if it changed + Now also the `stream` reacts to the `source` changes by subscribing again to the stream. + In addition, the `stream` parameter of the Resource has been changed from `Stream` into a `Stream Function()` in order to be able to listen to a new stream if it changed ## 1.0.0-dev6 diff --git a/packages/flutter_solidart/README.md b/packages/flutter_solidart/README.md index 414ccaf1..45292e9b 100644 --- a/packages/flutter_solidart/README.md +++ b/packages/flutter_solidart/README.md @@ -53,7 +53,7 @@ To change the value, you can use: // Set the value to 2 counter.value = 2; // Update the value based on the current value -counter.updateValue((value) => value * 2); +counter.value = counter.value * 2; ``` ### Effect @@ -65,9 +65,11 @@ The effect automatically subscribes to any signal and reruns when any of them ch So let's create an Effect that reruns whenever `counter` changes: ```dart -final disposeFn = Effect(() { +final effect = Effect(() { print("The count is now ${counter.value}"); }); +// Later... +effect.dispose(); ``` ### Computed @@ -81,11 +83,11 @@ A `Computed` automatically subscribes to any signal provided and reruns when any final name = Signal('John'); final lastName = Signal('Doe'); final fullName = Computed(() => '${name.value} ${lastName.value}'); -print(fullName()); // prints "John Doe" +print(fullName.value); // prints "John Doe" // Update the name -name.set('Jane'); -print(fullName()); // prints "Jane Doe" +name.value = 'Jane'; +print(fullName.value); // prints "Jane Doe" ``` ### Resource @@ -123,7 +125,8 @@ If you're using `SignalBuilder` you can react to the state of the resource: ```dart SignalBuilder( builder: (_, __) { - return user.state.when( + final userState = user.state; + return userState.when( ready: (data) { return Column( mainAxisSize: MainAxisSize.min, @@ -166,7 +169,7 @@ SignalBuilder( ) ``` -The `on` method forces you to handle all the states of a Resource (_ready_, _error_ and _loading_). +The `when` method forces you to handle all the states of a Resource (_ready_, _error_ and _loading_). The are also other convenience methods to handle only specific states. ### Dependency Injection diff --git a/packages/flutter_solidart/example/lib/main.dart b/packages/flutter_solidart/example/lib/main.dart index c3cefefc..ef2f8165 100644 --- a/packages/flutter_solidart/example/lib/main.dart +++ b/packages/flutter_solidart/example/lib/main.dart @@ -17,20 +17,22 @@ import 'package:flutter_solidart/flutter_solidart.dart'; /// class Logger implements SolidartObserver { @override - void didCreateSignal(SignalBase signal) { - final value = signal.hasValue ? signal.value : 'undefined'; - dev.log('didCreateSignal(name: ${signal.name}, value: $value)'); + void didCreateSignal(ReadonlySignal signal) { + final value = signal is Signal && !signal.isInitialized + ? 'uninitialized' + : signal.value; + dev.log('didCreateSignal(name: ${signal.identifier.name}, value: $value)'); } @override - void didDisposeSignal(SignalBase signal) { - dev.log('didDisposeSignal(name: ${signal.name})'); + void didDisposeSignal(ReadonlySignal signal) { + dev.log('didDisposeSignal(name: ${signal.identifier.name})'); } @override - void didUpdateSignal(SignalBase signal) { + void didUpdateSignal(ReadonlySignal signal) { dev.log( - 'didUpdateSignal(name: ${signal.name}, previousValue: ${signal.previousValue}, value: ${signal.value})', + 'didUpdateSignal(name: ${signal.identifier.name}, previousValue: ${signal.previousValue}, value: ${signal.value})', ); } } diff --git a/packages/flutter_solidart/example/lib/pages/effects.dart b/packages/flutter_solidart/example/lib/pages/effects.dart index ce1a523f..8a146eda 100644 --- a/packages/flutter_solidart/example/lib/pages/effects.dart +++ b/packages/flutter_solidart/example/lib/pages/effects.dart @@ -10,12 +10,12 @@ class EffectsPage extends StatefulWidget { class _EffectsPageState extends State { final count = Signal(0, name: 'count'); - late final DisposeEffect disposeEffect; + late final Effect effect; @override void initState() { super.initState(); - disposeEffect = Effect(() { + effect = Effect(() { // ignore: avoid_print print("The count is now ${count.value}"); }); @@ -23,7 +23,7 @@ class _EffectsPageState extends State { @override void dispose() { - disposeEffect(); + effect.dispose(); super.dispose(); } diff --git a/packages/flutter_solidart/example/lib/pages/lazy_counter.dart b/packages/flutter_solidart/example/lib/pages/lazy_counter.dart index 0748aa3c..0fe2447f 100644 --- a/packages/flutter_solidart/example/lib/pages/lazy_counter.dart +++ b/packages/flutter_solidart/example/lib/pages/lazy_counter.dart @@ -17,11 +17,9 @@ class _LazyCounterPageState extends State { appBar: AppBar(title: const Text('Lazy Counter')), body: Center( child: SignalBuilder( - builder: (_, _) { - return switch (counter.hasValue) { - true => Text('Counter: ${counter.value}'), - false => const Text('Counter: not initialized'), - }; + builder: (_, _) => switch (counter.isInitialized) { + true => Text('Counter: ${counter.value}'), + _ => const Text('Counter: not initialized'), }, ), ), @@ -32,7 +30,12 @@ class _LazyCounterPageState extends State { heroTag: "subtract hero", child: const Icon(Icons.remove), onPressed: () { - counter.hasValue ? counter.value -= 1 : counter.value = 0; + if (!counter.isInitialized) { + counter.value = 0; + return; + } + + counter.value -= 1; }, ), const SizedBox(width: 8), @@ -40,7 +43,12 @@ class _LazyCounterPageState extends State { heroTag: "add hero", child: const Icon(Icons.add), onPressed: () { - counter.hasValue ? counter.value += 1 : counter.value = 0; + if (!counter.isInitialized) { + counter.value = 0; + return; + } + + counter.value += 1; }, ), ], diff --git a/packages/flutter_solidart/example/lib/pages/list_signal.dart b/packages/flutter_solidart/example/lib/pages/list_signal.dart index c87901f1..edb15829 100644 --- a/packages/flutter_solidart/example/lib/pages/list_signal.dart +++ b/packages/flutter_solidart/example/lib/pages/list_signal.dart @@ -19,7 +19,7 @@ class _ListSignalPageState extends State { void initState() { super.initState(); items.observe((previousValue, value) { - print("Items changed: $previousValue -> $value"); + print('Items changed: $previousValue -> $value'); }); } diff --git a/packages/flutter_solidart/example/lib/pages/map_signal.dart b/packages/flutter_solidart/example/lib/pages/map_signal.dart index ac2d0bd0..17acca54 100644 --- a/packages/flutter_solidart/example/lib/pages/map_signal.dart +++ b/packages/flutter_solidart/example/lib/pages/map_signal.dart @@ -21,7 +21,7 @@ class _MapSignalPageState extends State { void initState() { super.initState(); items.observe((previousValue, value) { - print("Items changed: $previousValue -> $value"); + print('Items changed: $previousValue -> $value'); }); } @@ -41,7 +41,7 @@ class _MapSignalPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('SetSignal')), + appBar: AppBar(title: const Text('MapSignal')), body: Padding( padding: const EdgeInsets.all(24), child: Column( diff --git a/packages/flutter_solidart/example/lib/pages/set_signal.dart b/packages/flutter_solidart/example/lib/pages/set_signal.dart index 12a30118..4e16bb55 100644 --- a/packages/flutter_solidart/example/lib/pages/set_signal.dart +++ b/packages/flutter_solidart/example/lib/pages/set_signal.dart @@ -19,7 +19,7 @@ class _SetSignalPageState extends State { void initState() { super.initState(); items.observe((previousValue, value) { - print("Items changed: $previousValue -> $value"); + print('Items changed: $previousValue -> $value'); }); } diff --git a/packages/flutter_solidart/example/lib/pages/show.dart b/packages/flutter_solidart/example/lib/pages/show.dart index ff486d3b..888c75bd 100644 --- a/packages/flutter_solidart/example/lib/pages/show.dart +++ b/packages/flutter_solidart/example/lib/pages/show.dart @@ -20,7 +20,7 @@ class _ShowPageState extends State { actions: [ TextButton( style: TextButton.styleFrom(foregroundColor: Colors.white), - onPressed: loggedIn.toggle, + onPressed: () => loggedIn.value = !loggedIn.value, child: Show( when: () => loggedIn.value, builder: (_) => const Text('LOGIN'), diff --git a/packages/flutter_solidart/example/pubspec.yaml b/packages/flutter_solidart/example/pubspec.yaml index 36fd7444..463a7bf3 100644 --- a/packages/flutter_solidart/example/pubspec.yaml +++ b/packages/flutter_solidart/example/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: flutter: sdk: flutter http: ^1.3.0 - flutter_solidart: ^2.0.0 + flutter_solidart: 3.0.0-dev.0 dev_dependencies: flutter_test: diff --git a/packages/flutter_solidart/lib/flutter_solidart.dart b/packages/flutter_solidart/lib/flutter_solidart.dart index a85edcf6..131cd0e2 100644 --- a/packages/flutter_solidart/lib/flutter_solidart.dart +++ b/packages/flutter_solidart/lib/flutter_solidart.dart @@ -5,23 +5,16 @@ library; export 'package:solidart/solidart.dart' hide Computed, + LazySignal, ListSignal, MapSignal, - ReadableSignal, Resource, SetSignal, - Signal, - ToggleBoolSignal; + Signal; export 'src/core/computed.dart'; -export 'src/core/list_signal.dart'; -export 'src/core/map_signal.dart'; -export 'src/core/readable_signal.dart'; export 'src/core/resource.dart'; -export 'src/core/set_signal.dart'; export 'src/core/signal.dart'; -export 'src/core/value_listenable_signal_mixin.dart'; -export 'src/core/value_notifier_signal_mixin.dart'; export 'src/utils/extensions.dart'; export 'src/widgets/show.dart'; export 'src/widgets/signal_builder.dart'; diff --git a/packages/flutter_solidart/lib/src/core/computed.dart b/packages/flutter_solidart/lib/src/core/computed.dart index 8ee331b6..295b0739 100644 --- a/packages/flutter_solidart/lib/src/core/computed.dart +++ b/packages/flutter_solidart/lib/src/core/computed.dart @@ -1,18 +1,16 @@ -// coverage:ignore-file +import 'package:flutter/foundation.dart'; import 'package:flutter_solidart/src/core/value_listenable_signal_mixin.dart'; -import 'package:solidart/solidart.dart' as solidart; +import 'package:solidart/solidart.dart' as core; -/// {@macro computed} -class Computed extends solidart.Computed - with ValueListenableSignalMixin { - /// {@macro computed} +/// A Solidart [core.Computed] that is also a Flutter [ValueListenable]. +class Computed extends core.Computed with SignalValueListenableMixin { + /// Creates a new [Computed] and exposes it as a [ValueListenable]. Computed( - super.selector, { + super.getter, { super.equals, - super.name, super.autoDispose, - super.comparator, - super.trackInDevTools, + super.name, super.trackPreviousValue, + super.trackInDevTools, }); } diff --git a/packages/flutter_solidart/lib/src/core/list_signal.dart b/packages/flutter_solidart/lib/src/core/list_signal.dart deleted file mode 100644 index b6f24964..00000000 --- a/packages/flutter_solidart/lib/src/core/list_signal.dart +++ /dev/null @@ -1,18 +0,0 @@ -// coverage:ignore-file -import 'package:flutter_solidart/src/core/value_notifier_signal_mixin.dart'; -import 'package:solidart/solidart.dart' as solidart; - -/// {@macro list-signal} -class ListSignal extends solidart.ListSignal - with ValueNotifierSignalMixin> { - /// {@macro list-signal} - ListSignal( - super.initialValue, { - super.equals, - super.name, - super.autoDispose, - super.comparator, - super.trackInDevTools, - super.trackPreviousValue, - }); -} diff --git a/packages/flutter_solidart/lib/src/core/map_signal.dart b/packages/flutter_solidart/lib/src/core/map_signal.dart deleted file mode 100644 index f854a0cd..00000000 --- a/packages/flutter_solidart/lib/src/core/map_signal.dart +++ /dev/null @@ -1,18 +0,0 @@ -// coverage:ignore-file -import 'package:flutter_solidart/src/core/value_notifier_signal_mixin.dart'; -import 'package:solidart/solidart.dart' as solidart; - -/// {@macro map-signal} -class MapSignal extends solidart.MapSignal - with ValueNotifierSignalMixin> { - /// {@macro map-signal} - MapSignal( - super.initialValue, { - super.equals, - super.name, - super.autoDispose, - super.comparator, - super.trackInDevTools, - super.trackPreviousValue, - }); -} diff --git a/packages/flutter_solidart/lib/src/core/readable_signal.dart b/packages/flutter_solidart/lib/src/core/readable_signal.dart deleted file mode 100644 index 4e7ae4b2..00000000 --- a/packages/flutter_solidart/lib/src/core/readable_signal.dart +++ /dev/null @@ -1,28 +0,0 @@ -// coverage:ignore-file -import 'package:flutter_solidart/src/core/value_listenable_signal_mixin.dart'; -import 'package:solidart/solidart.dart' as solidart; - -/// {@macro readsignal} -class ReadableSignal extends solidart.ReadableSignal - with ValueListenableSignalMixin { - /// {@macro readsignal} - ReadableSignal( - super.initialValue, { - super.equals, - super.name, - super.autoDispose, - super.comparator, - super.trackInDevTools, - super.trackPreviousValue, - }); - - /// {@macro readsignal} - ReadableSignal.lazy({ - super.equals, - super.name, - super.autoDispose, - super.comparator, - super.trackInDevTools, - super.trackPreviousValue, - }) : super.lazy(); -} diff --git a/packages/flutter_solidart/lib/src/core/resource.dart b/packages/flutter_solidart/lib/src/core/resource.dart index d47fde87..72034ab9 100644 --- a/packages/flutter_solidart/lib/src/core/resource.dart +++ b/packages/flutter_solidart/lib/src/core/resource.dart @@ -1,35 +1,35 @@ -// coverage:ignore-file -import 'package:flutter_solidart/src/core/value_notifier_signal_mixin.dart'; -import 'package:solidart/solidart.dart' as solidart; +import 'package:flutter/foundation.dart'; +import 'package:flutter_solidart/src/core/value_listenable_signal_mixin.dart'; +import 'package:solidart/solidart.dart' as core; -/// {@macro resource} -class Resource extends solidart.Resource - with ValueNotifierSignalMixin> { - /// {@macro resource} +/// A Solidart [core.Resource] that is also a Flutter [ValueListenable]. +class Resource extends core.Resource + with SignalValueListenableMixin> { + /// Creates a new [Resource] and exposes it as a [ValueListenable]. Resource( super.fetcher, { - super.equals, - super.name, - super.autoDispose, + super.source, super.lazy, - super.trackInDevTools, super.useRefreshing, - super.debounceDelay, - super.source, super.trackPreviousState, + super.debounceDelay, + super.autoDispose, + super.name, + super.trackInDevTools, + super.equals, }); - /// {@macro resource} + /// Creates a stream-based [Resource] and exposes it as a [ValueListenable]. Resource.stream( super.stream, { - super.equals, - super.name, - super.autoDispose, + super.source, super.lazy, - super.trackInDevTools, super.useRefreshing, - super.debounceDelay, - super.source, super.trackPreviousState, + super.debounceDelay, + super.autoDispose, + super.name, + super.trackInDevTools, + super.equals, }) : super.stream(); } diff --git a/packages/flutter_solidart/lib/src/core/set_signal.dart b/packages/flutter_solidart/lib/src/core/set_signal.dart deleted file mode 100644 index 6de44e1e..00000000 --- a/packages/flutter_solidart/lib/src/core/set_signal.dart +++ /dev/null @@ -1,18 +0,0 @@ -// coverage:ignore-file -import 'package:flutter_solidart/src/core/value_notifier_signal_mixin.dart'; -import 'package:solidart/solidart.dart' as solidart; - -/// {@macro set-signal} -class SetSignal extends solidart.SetSignal - with ValueNotifierSignalMixin> { - /// {@macro set-signal} - SetSignal( - super.initialValue, { - super.equals, - super.name, - super.autoDispose, - super.comparator, - super.trackInDevTools, - super.trackPreviousValue, - }); -} diff --git a/packages/flutter_solidart/lib/src/core/signal.dart b/packages/flutter_solidart/lib/src/core/signal.dart index a7904e17..ea908a6f 100644 --- a/packages/flutter_solidart/lib/src/core/signal.dart +++ b/packages/flutter_solidart/lib/src/core/signal.dart @@ -1,48 +1,81 @@ -// coverage:ignore-file -import 'package:flutter_solidart/src/core/readable_signal.dart'; -import 'package:flutter_solidart/src/core/value_notifier_signal_mixin.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_solidart/src/core/value_listenable_signal_mixin.dart'; +import 'package:solidart/solidart.dart' as core; -/// Adds the [toggle] method to boolean signals -extension ToggleBoolSignal on Signal { - /// Toggles the signal boolean value. - void toggle() => value = !value; -} - -/// {@macro signal} -class Signal extends ReadableSignal with ValueNotifierSignalMixin { - /// {@macro signal} +/// A Solidart [core.Signal] that is also a Flutter [ValueListenable]. +class Signal extends core.Signal with SignalValueListenableMixin { + /// Creates a new [Signal] and exposes it as a [ValueListenable]. Signal( super.initialValue, { - super.equals, - super.name, super.autoDispose, - super.comparator, - super.trackInDevTools, + super.name, + super.equals, super.trackPreviousValue, + super.trackInDevTools, }); - /// {@macro signal} - Signal.lazy({ - super.equals, - super.name, + /// Creates a lazy [Signal] and exposes it as a [ValueListenable]. + factory Signal.lazy({ + bool? autoDispose, + String? name, + core.ValueComparator equals, + bool? trackPreviousValue, + bool? trackInDevTools, + }) = LazySignal; +} + +/// A lazy [Signal] that is also a Flutter [ValueListenable]. +class LazySignal extends core.LazySignal + with SignalValueListenableMixin + implements Signal { + /// Creates a lazy [Signal] and exposes it as a [ValueListenable]. + LazySignal({ super.autoDispose, - super.comparator, - super.trackInDevTools, + super.name, + super.equals, super.trackPreviousValue, - }) : super.lazy(); + super.trackInDevTools, + }); +} - /// {@macro set-signal-value} - @override - set value(T newValue) { - setValue(newValue); - } +/// A Solidart [core.ListSignal] that is also a Flutter [ValueListenable]. +class ListSignal extends core.ListSignal + with SignalValueListenableMixin> { + /// Creates a new [ListSignal] and exposes it as a [ValueListenable]. + ListSignal( + super.initialValue, { + super.autoDispose, + super.name, + super.equals, + super.trackPreviousValue, + super.trackInDevTools, + }); +} - /// Calls a function with the current value and assigns the result as the - /// new value. - T updateValue(T Function(T value) callback) => - value = callback(untrackedValue); +/// A Solidart [core.SetSignal] that is also a Flutter [ValueListenable]. +class SetSignal extends core.SetSignal + with SignalValueListenableMixin> { + /// Creates a new [SetSignal] and exposes it as a [ValueListenable]. + SetSignal( + super.initialValue, { + super.autoDispose, + super.name, + super.equals, + super.trackPreviousValue, + super.trackInDevTools, + }); +} - /// Converts this [Signal] into a [ReadableSignal] - /// Use this method to remove the visility to the value setter. - ReadableSignal toReadSignal() => this; +/// A Solidart [core.MapSignal] that is also a Flutter [ValueListenable]. +class MapSignal extends core.MapSignal + with SignalValueListenableMixin> { + /// Creates a new [MapSignal] and exposes it as a [ValueListenable]. + MapSignal( + super.initialValue, { + super.autoDispose, + super.name, + super.equals, + super.trackPreviousValue, + super.trackInDevTools, + }); } diff --git a/packages/flutter_solidart/lib/src/core/value_listenable_signal_mixin.dart b/packages/flutter_solidart/lib/src/core/value_listenable_signal_mixin.dart index 09121250..9167f172 100644 --- a/packages/flutter_solidart/lib/src/core/value_listenable_signal_mixin.dart +++ b/packages/flutter_solidart/lib/src/core/value_listenable_signal_mixin.dart @@ -1,36 +1,55 @@ -// coverage:ignore-file import 'package:flutter/foundation.dart'; -import 'package:solidart/solidart.dart' as solidart; +import 'package:solidart/solidart.dart' as core; -/// [ValueNotifier] implementation for [solidart.Signal] -mixin ValueListenableSignalMixin on solidart.ReadSignal +/// Adds Flutter [ValueListenable] behavior to a Solidart [core.ReadonlySignal]. +mixin SignalValueListenableMixin on core.ReadonlySignal implements ValueListenable { - final _listeners = {}; + final List _listeners = []; - /// If true, the callback will be run when the listener is added - bool get fireImmediately => false; + core.Effect? _effect; + bool _skipped = false; + bool _disposeAttached = false; - @override - void addListener(VoidCallback listener) { - _listeners.putIfAbsent(listener, () { - return observe((_, _) { - listener(); - }, fireImmediately: fireImmediately); - }); + void _ensureEffect() { + if (_effect != null) return; + _skipped = false; + _effect = core.Effect( + () { + value; + if (!_skipped) { + _skipped = true; + return; + } + if (_listeners.isEmpty) return; + for (final callback in List.from(_listeners)) { + callback(); + } + }, + autoDispose: false, + detach: true, + ); + if (!_disposeAttached) { + _disposeAttached = true; + onDispose(() { + _effect?.dispose(); + _effect = null; + _listeners.clear(); + }); + } } @override - void removeListener(VoidCallback listener) { - final cleanup = _listeners.remove(listener); - cleanup?.call(); + void addListener(VoidCallback listener) { + _listeners.add(listener); + _ensureEffect(); } @override - void dispose() { - super.dispose(); - for (final cleanup in _listeners.values) { - cleanup(); + void removeListener(VoidCallback listener) { + _listeners.remove(listener); + if (_listeners.isEmpty) { + _effect?.dispose(); + _effect = null; } - _listeners.clear(); } } diff --git a/packages/flutter_solidart/lib/src/core/value_notifier_signal_mixin.dart b/packages/flutter_solidart/lib/src/core/value_notifier_signal_mixin.dart deleted file mode 100644 index 1899c14b..00000000 --- a/packages/flutter_solidart/lib/src/core/value_notifier_signal_mixin.dart +++ /dev/null @@ -1,46 +0,0 @@ -// coverage:ignore-file -import 'package:flutter/widgets.dart'; -import 'package:solidart/solidart.dart' as solidart; - -/// [ValueNotifier] implementation for [solidart.Signal] -mixin ValueNotifierSignalMixin on solidart.ReadableSignal - implements ValueNotifier { - final _listeners = {}; - - /// If true, the callback will be run when the listener is added - bool get fireImmediately => false; - - @override - void addListener(VoidCallback listener) { - _listeners.putIfAbsent(listener, () { - return observe((_, _) { - listener(); - }, fireImmediately: fireImmediately); - }); - } - - @override - void removeListener(VoidCallback listener) { - final cleanup = _listeners.remove(listener); - cleanup?.call(); - } - - @override - bool get hasListeners => _listeners.isNotEmpty; - - @override - void notifyListeners() { - for (final listener in _listeners.keys) { - listener(); - } - } - - @override - void dispose() { - super.dispose(); - for (final cleanup in _listeners.values) { - cleanup(); - } - _listeners.clear(); - } -} diff --git a/packages/flutter_solidart/lib/src/utils/extensions.dart b/packages/flutter_solidart/lib/src/utils/extensions.dart index 800a02a2..1b1217c3 100644 --- a/packages/flutter_solidart/lib/src/utils/extensions.dart +++ b/packages/flutter_solidart/lib/src/utils/extensions.dart @@ -1,45 +1,73 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_solidart/src/core/signal.dart'; +import 'package:solidart/solidart.dart' as core; -/// {@template signal-to-value-notifier} -/// Converts a [SignalBase] into a [ValueNotifier]; +/// {@template readonly-signal-to-value-notifier} +/// Converts a [core.ReadonlySignal] into a [ValueNotifier]. +/// +/// The returned notifier stays in sync with the signal and disposes its +/// internal effect when the notifier or the signal is disposed. /// {@endtemplate} -extension SignalToValueNotifier on SignalBase { - /// {@macro signal-to-value-notifier} - ValueNotifier toValueNotifier() { - final notifier = ValueNotifier(value); - final unobserve = Effect(() => notifier.value = value); - onDispose(unobserve.call); - return notifier; +extension ReadonlySignalToValueNotifier on core.ReadonlySignal { + /// {@macro readonly-signal-to-value-notifier} + ValueNotifier toValueNotifier() => _SignalValueNotifier(this); +} + +class _SignalValueNotifier extends ValueNotifier { + _SignalValueNotifier(this._signal) : super(_readValue(_signal)) { + _effect = core.Effect( + () => value = _readValue(_signal), + autoDispose: false, + detach: true, + ); + _signal.onDispose(_effect.dispose); + } + + final core.ReadonlySignal _signal; + late final core.Effect _effect; + + @override + void dispose() { + _effect.dispose(); + super.dispose(); } } -/// {@template value-notifier-to-signal} -/// Converts a [ValueNotifier] into a [Signal]; +T _readValue(core.ReadonlySignal signal) { + if (signal is core.Resource) { + return (signal as core.Resource).state as T; + } + return signal.value; +} + +/// {@template value-listenable-to-signal} +/// Converts a [ValueListenable] into a [Signal] that mirrors its value. +/// +/// Updates flow from the [ValueListenable] into the signal. Disposing the +/// signal removes the listener. /// {@endtemplate} -extension ValueNotifierToSignal on ValueNotifier { - /// {@macro value-notifier-to-signal} +extension ValueListenableToSignal on ValueListenable { + /// {@macro value-listenable-to-signal} Signal toSignal({ - /// {macro SignalBase.name} String? name, - - /// {macro SignalBase.autoDispose} bool? autoDispose, - - /// {macro SignalBase.trackInDevTools} + bool? trackPreviousValue, bool? trackInDevTools, + core.ValueComparator equals = identical, }) { final signal = Signal( value, - equals: true, name: name, autoDispose: autoDispose, + trackPreviousValue: trackPreviousValue, trackInDevTools: trackInDevTools, + equals: equals, ); - void setValue() => signal.value = value; - addListener(setValue); - signal.onDispose(() => removeListener(setValue)); + void sync() => signal.value = value; + addListener(sync); + signal.onDispose(() => removeListener(sync)); + return signal; } } diff --git a/packages/flutter_solidart/lib/src/widgets/show.dart b/packages/flutter_solidart/lib/src/widgets/show.dart index 36366976..3f5e1ebc 100644 --- a/packages/flutter_solidart/lib/src/widgets/show.dart +++ b/packages/flutter_solidart/lib/src/widgets/show.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_solidart/src/widgets/signal_builder.dart'; -import 'package:solidart/solidart.dart'; /// {@template show} /// Conditionally render its [builder] or an optional [fallback] component @@ -20,7 +19,7 @@ import 'package:solidart/solidart.dart'; /// Widget build(BuildContext context) { /// return SignalBuilder( /// builder: (context, child) { -/// if (loggedIn()) return const Text('Logged in'); +/// if (loggedIn.value) return const Text('Logged in'); /// return const Text('Logged out'); /// }, /// ); @@ -34,7 +33,7 @@ import 'package:solidart/solidart.dart'; /// @override /// Widget build(BuildContext context) { /// return Show( -/// when: loggedIn, +/// when: () => loggedIn.value, /// builder: (context) => const Text('Logged In'), /// fallback: (context) => const Text('Logged out'), /// ); @@ -54,14 +53,14 @@ import 'package:solidart/solidart.dart'; /// @override /// Widget build(BuildContext context) { /// return Show( -/// when: () => count() > 5, +/// when: () => count.value > 5, /// builder: (context) => const Text('Count is greater than 5'), /// fallback: (context) => const Text('Count is lower than 6'), /// ); /// } /// ``` /// {@endtemplate} -class Show extends StatefulWidget { +class Show extends StatelessWidget { /// {@macro show} const Show({ super.key, @@ -84,38 +83,15 @@ class Show extends StatefulWidget { /// `false` final WidgetBuilder? fallback; - @override - State> createState() => _ShowState(); -} - -class _ShowState extends State> { - final show = ValueNotifier(false); - late final DisposeEffect disposeEffect; - - @override - void initState() { - super.initState(); - disposeEffect = Effect(() { - show.value = widget.when(); - }).call; - } - - @override - void dispose() { - show.dispose(); - disposeEffect(); - super.dispose(); - } - @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: show, - builder: (context, condition, _) { + return SignalBuilder( + builder: (context, _) { + final condition = when(); if (!condition) { - return widget.fallback?.call(context) ?? const SizedBox.shrink(); + return fallback?.call(context) ?? const SizedBox.shrink(); } - return widget.builder(context); + return builder(context); }, ); } diff --git a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart index 559d2b74..5a0d8601 100644 --- a/packages/flutter_solidart/lib/src/widgets/signal_builder.dart +++ b/packages/flutter_solidart/lib/src/widgets/signal_builder.dart @@ -1,7 +1,7 @@ -// ignore_for_file: document_ignores - import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; +import 'package:solidart/deps/preset.dart' as preset; +import 'package:solidart/deps/system.dart' as system; import 'package:solidart/solidart.dart'; /// {@template signalbuilder} @@ -47,8 +47,8 @@ class SignalBuilder extends StatelessWidget { /// /// This argument is optional and can be null if the entire widget subtree /// the [builder] builds depends on the value of the signals. - /// If you have a widget in the subtree that do not depend on the values of - /// the signals, use this argument, because it won't be rebuilded. + /// If you have a widget in the subtree that does not depend on the values of + /// the signals, use this argument, because it won't be rebuilt. /// {@endtemplate} final Widget? child; @@ -63,45 +63,64 @@ class SignalBuilder extends StatelessWidget { class _SignalBuilderElement extends StatelessElement { _SignalBuilderElement(SignalBuilder super.widget); - late final effect = Effect( - scheduler, - detach: true, + late final Effect _effect = Effect.manual( + _runEffect, autoDispose: false, - autorun: false, + detach: true, ); - void scheduler() { - if (dirty) return; + bool _isBuilding = false; + system.Link? _depsHead; + system.Link? _depsTail; + + void _runEffect() { + _effect.deps = _depsHead; + _effect.depsTail = _depsTail; + if (!mounted) { + return; + } + if (_isBuilding || dirty) { + return; + } markNeedsBuild(); } @override void unmount() { - effect.dispose(); + _effect.deps = _depsHead; + _effect.depsTail = _depsTail; + _effect.dispose(); super.unmount(); } @override Widget build() { - final prevSub = reactiveSystem.activeSub; - // ignore: invalid_use_of_protected_member - final node = reactiveSystem.activeSub = effect.subscriber; - + _isBuilding = true; + final prevDetach = SolidartConfig.detachEffects; + final prevSub = preset.setActiveSub(_effect); + _effect.depsTail = null; + _effect.flags = + system.ReactiveFlags.watching | system.ReactiveFlags.recursedCheck; + preset.cycle++; try { + SolidartConfig.detachEffects = true; final built = super.build(); if (SolidartConfig.assertSignalBuilderWithoutDependencies) { - assert(node.deps != null, ''' + assert(_effect.depsTail != null, ''' SignalBuilder must detect at least one Signal, Computed, or Resource during the build. -This may mean your reactive values were disposed. -You can disable this check by setting `SolidartConfig.assertSignalBuilderWithoutDependencies = false` before `runApp()`' +This may mean your reactive values were disposed. +You can disable this check by setting `SolidartConfig.assertSignalBuilderWithoutDependencies = false` before `runApp()`. '''); } - // ignore: invalid_use_of_internal_member - effect.setDependencies(node); - return built; } finally { - reactiveSystem.activeSub = prevSub; + preset.purgeDeps(_effect); + _depsHead = _effect.deps; + _depsTail = _effect.depsTail; + SolidartConfig.detachEffects = prevDetach; + preset.setActiveSub(prevSub); + _effect.flags &= ~system.ReactiveFlags.recursedCheck; + _isBuilding = false; } } } diff --git a/packages/flutter_solidart/pubspec.yaml b/packages/flutter_solidart/pubspec.yaml index 96bd8db7..5a83bc50 100644 --- a/packages/flutter_solidart/pubspec.yaml +++ b/packages/flutter_solidart/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_solidart description: A simple State Management solution for Flutter applications inspired by SolidJS -version: 2.7.2 +version: 3.0.0-dev.0 repository: https://github.com/nank1ro/solidart documentation: https://solidart.mariuti.com topics: @@ -18,7 +18,7 @@ dependencies: flutter: sdk: flutter meta: ^1.11.0 - solidart: ^2.8.2 + solidart: 3.0.0-dev.0 dev_dependencies: disco: ^1.0.0 diff --git a/packages/flutter_solidart/test/flutter_solidart_test.dart b/packages/flutter_solidart/test/flutter_solidart_test.dart index 71403e10..47b5b933 100644 --- a/packages/flutter_solidart/test/flutter_solidart_test.dart +++ b/packages/flutter_solidart/test/flutter_solidart_test.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:disco/disco.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_solidart/flutter_solidart.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -33,6 +32,33 @@ class NumberContainer { } void main() { + test( + 'Signals, Computed, Resources, and collections ' + 'implement Listenable', + () { + final signal = Signal(0); + final computed = Computed(() => signal.value * 2); + final resource = Resource(() async => 1); + final listSignal = ListSignal([1, 2, 3]); + final setSignal = SetSignal({1, 2, 3}); + final mapSignal = MapSignal({'a': 1}); + + expect(signal, isA()); + expect(computed, isA()); + expect(resource, isA()); + expect(listSignal, isA()); + expect(setSignal, isA()); + expect(mapSignal, isA()); + + signal.dispose(); + computed.dispose(); + resource.dispose(); + listSignal.dispose(); + setSignal.dispose(); + mapSignal.dispose(); + }, + ); + testWidgets('(Provider) Not found signal throws an error', (tester) async { final counterProvider = Provider((_) => Signal(0)); final invalidCounterProvider = Provider((_) => Signal(0)); @@ -61,13 +87,15 @@ void main() { ), ); }); - testWidgets('Show widget works properly', (tester) async { + testWidgets('Show widget toggles branches and rebuilds on changes', ( + tester, + ) async { final s = Signal(true); await tester.pumpWidget( MaterialApp( home: Scaffold( body: Show( - when: () => s.value, + when: s, builder: (context) => const Text('Builder'), fallback: (context) => const Text('Fallback'), ), @@ -81,10 +109,43 @@ void main() { expect(fallbackFinder, findsNothing); s.value = false; - await tester.pumpAndSettle(); + await tester.pump(); expect(builderFinder, findsNothing); expect(fallbackFinder, findsOneWidget); + + s.value = true; + await tester.pump(); + + expect(builderFinder, findsOneWidget); + expect(fallbackFinder, findsNothing); + }); + + testWidgets('Show widget cleans up subscriptions on unmount', (tester) async { + final previousAutoDispose = SolidartConfig.autoDispose; + SolidartConfig.autoDispose = true; + addTearDown(() => SolidartConfig.autoDispose = previousAutoDispose); + + final s = Signal(true, autoDispose: true); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Show( + when: s, + builder: (context) => const Text('Builder'), + fallback: (context) => const Text('Fallback'), + ), + ), + ), + ); + + expect(s.isDisposed, isFalse); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + + expect(s.isDisposed, isTrue); }); testWidgets('SignalBuilder widget works properly in ResourceReady state', ( @@ -420,12 +481,12 @@ void main() { expect(counterFinder(2), findsOneWidget); }); - testWidgets('(Provider) SignalBuilder works properly (1 ReadSignal)', ( + testWidgets('(Provider) SignalBuilder works properly (1 ReadonlySignal)', ( tester, ) async { final s = Signal(0); - final counterProvider = Provider((_) => s.toReadSignal()); + final counterProvider = Provider((_) => s.toReadonly()); await tester.pumpWidget( MaterialApp( @@ -648,7 +709,7 @@ void main() { }); }); - testWidgets('(Provider) Signal.updateValue method', (tester) async { + testWidgets('(Provider) Signal update via value assignment', (tester) async { final counterProvider = Provider((_) => Signal(0)); await tester.pumpWidget( MaterialApp( @@ -665,7 +726,7 @@ void main() { Text('${counter.value}'), ElevatedButton( onPressed: () { - counter.updateValue((value) => value + 1); + counter.value = counter.value + 1; }, child: const Text('add'), ), @@ -685,7 +746,9 @@ void main() { expect(find.text('1'), findsOneWidget); }); - testWidgets('(ArgProvider) Signal.updateValue method', (tester) async { + testWidgets('(ArgProvider) Signal update via value assignment', ( + tester, + ) async { final counterProvider = Provider.withArgument((_, int n) => Signal(n)); await tester.pumpWidget( MaterialApp( @@ -702,7 +765,7 @@ void main() { Text('${counter.value}'), ElevatedButton( onPressed: () { - counter.updateValue((value) => value + 1); + counter.value = counter.value + 1; }, child: const Text('add'), ), @@ -748,6 +811,14 @@ void main() { ); group('Automatic disposal', () { + setUp(() { + SolidartConfig.autoDispose = true; + }); + + tearDown(() { + SolidartConfig.autoDispose = false; + }); + testWidgets( 'Signal autoDispose', (tester) async { @@ -763,17 +834,17 @@ void main() { ), ), ); - expect(counter.disposed, isFalse); + expect(counter.isDisposed, isFalse); await tester.pumpWidget(const SizedBox()); - expect(counter.disposed, isTrue); + expect(counter.isDisposed, isTrue); }, timeout: const Timeout(Duration(seconds: 1)), ); testWidgets( - 'ReadSignal autoDispose', + 'ReadonlySignal autoDispose', (tester) async { - final counter = Signal(0).toReadSignal(); + final counter = Signal(0).toReadonly(); await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -785,9 +856,9 @@ void main() { ), ), ); - expect(counter.disposed, isFalse); + expect(counter.isDisposed, isFalse); await tester.pumpWidget(const SizedBox()); - expect(counter.disposed, isTrue); + expect(counter.isDisposed, isTrue); }, timeout: const Timeout(Duration(seconds: 1)), ); @@ -808,11 +879,11 @@ void main() { ), ), ); - expect(counter.disposed, isFalse); - expect(doubleCounter.disposed, isFalse); + expect(counter.isDisposed, isFalse); + expect(doubleCounter.isDisposed, isFalse); await tester.pumpWidget(const SizedBox()); - expect(counter.disposed, isTrue); - expect(doubleCounter.disposed, isTrue); + expect(counter.isDisposed, isTrue); + expect(doubleCounter.isDisposed, isTrue); }, timeout: const Timeout(Duration(seconds: 1)), ); @@ -833,11 +904,11 @@ void main() { ), ), ); - expect(counter.disposed, isFalse); - expect(effect.disposed, isFalse); + expect(counter.isDisposed, isFalse); + expect(effect.isDisposed, isFalse); await tester.pumpWidget(const SizedBox()); counter.dispose(); - expect(effect.disposed, isTrue); + expect(effect.isDisposed, isTrue); }, timeout: const Timeout(Duration(seconds: 1)), ); @@ -857,9 +928,9 @@ void main() { ), ), ); - expect(r.disposed, isFalse); + expect(r.isDisposed, isFalse); await tester.pumpWidget(const SizedBox()); - expect(r.disposed, isTrue); + expect(r.isDisposed, isTrue); }, timeout: const Timeout(Duration(seconds: 1)), ); @@ -868,6 +939,8 @@ void main() { testWidgets( 'Effect with multiple dependencies autoDispose', (tester) async { + SolidartConfig.autoDispose = true; + addTearDown(() => SolidartConfig.autoDispose = false); final counter = Signal(0); final doubleCounter = Computed(() => counter.value * 2); final effect = Effect(() { @@ -885,116 +958,70 @@ void main() { ), ), ); - expect(counter.disposed, isFalse); - expect(doubleCounter.disposed, isFalse); - expect(effect.disposed, isFalse); + expect(counter.isDisposed, isFalse); + expect(doubleCounter.isDisposed, isFalse); + expect(effect.isDisposed, isFalse); await tester.pumpWidget(const SizedBox()); - effect(); - expect(effect.disposed, isTrue); - expect(counter.disposed, isTrue); - expect(doubleCounter.disposed, isTrue); + effect.dispose(); + expect(effect.isDisposed, isTrue); + expect(counter.isDisposed, isTrue); + expect(doubleCounter.isDisposed, isTrue); }, timeout: const Timeout(Duration(seconds: 1)), ); - testWidgets('SignalBuilder without dependencies throws an error', ( - tester, - ) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SignalBuilder( - builder: (_, _) { - return const Text('No dependencies here'); - }, - ), - ), - ), - ); - await tester.pumpAndSettle(); - expect( - tester.takeException(), - isAssertionError.having( - (error) => error.message, - ''' -SignalBuilder must detect at least one Signal, Computed, or Resource during the build. -''', - contains( - '''SignalBuilder must detect at least one Signal, Computed, or Resource during the build.''', - ), - ), - ); - }); - - test('Signal is a ValueNotifier', () { + test('Signal to ValueNotifier notifies listeners', () { final signal = Signal(0); - expect(signal, isA>()); - expect(signal.value, 0); + final notifier = signal.toValueNotifier(); var notifiedValue = -1; void listener() { - notifiedValue = signal.value; + notifiedValue = notifier.value; } - signal.addListener(listener); + notifier.addListener(listener); signal.value = 1; expect(notifiedValue, 1); - signal.removeListener(listener); + notifier.removeListener(listener); signal.value = 2; expect(notifiedValue, 1); // Not updated since listener was removed + notifier.dispose(); }); - test('Resource is a ValueNotifier', () { - final r = Resource(() => Future.value(0)); - expect(r, isA>>()); - expect(r.state, isA>()); - var notifiedState = r.state; + test('Resource to ValueNotifier updates when ready', () { + final resource = Resource(() => Future.value(0)); + final notifier = resource.toValueNotifier(); + expect(notifier.value, isA>()); + var notifiedState = notifier.value; + void listener() { - notifiedState = r.state; + notifiedState = notifier.value; } - r.addListener(listener); + notifier.addListener(listener); // Wait for the resource to load return Future.delayed(const Duration(milliseconds: 10), () { expect(notifiedState, isA>()); - r.removeListener(listener); - r.refresh(); - expect( - notifiedState, - isA>(), - ); // Not updated since listener was removed + notifier.removeListener(listener); + notifier.dispose(); }); }); - test('ReadableSignal is a ValueListenable', () { - final signal = Signal(0).toReadSignal(); - expect(signal, isA>()); - expect(signal.value, 0); - var notifiedValue = -1; - void listener() { - notifiedValue = signal.value; - } - - signal.addListener(listener); - signal.dispose(); // Dispose before changing value to test cleanup - expect(notifiedValue, -1); // Not updated since value didn't change - signal.removeListener(listener); - }); - - test('Computed is a ValueListenable', () { + test('ReadonlySignal to ValueNotifier works for Computed', () { final baseSignal = Signal(1); final computed = Computed(() => baseSignal.value * 2); - expect(computed, isA>()); - expect(computed.value, 2); + final notifier = computed.toValueNotifier(); + expect(notifier.value, 2); var notifiedValue = -1; void listener() { - notifiedValue = computed.value; + notifiedValue = notifier.value; } - computed.addListener(listener); + notifier.addListener(listener); baseSignal.value = 2; expect(notifiedValue, 4); - computed.removeListener(listener); + notifier.removeListener(listener); baseSignal.value = 3; expect(notifiedValue, 4); // Not updated since listener was removed + notifier.dispose(); }); } diff --git a/packages/flutter_solidart/test/signal_builder_exceptions_test.dart b/packages/flutter_solidart/test/signal_builder_exceptions_test.dart new file mode 100644 index 00000000..b76a6442 --- /dev/null +++ b/packages/flutter_solidart/test/signal_builder_exceptions_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:solidart/deps/preset.dart' as preset; + +class _ThrowAfterBuildSignalBuilder extends SignalBuilder { + const _ThrowAfterBuildSignalBuilder({required this.signal}) + : super(builder: _noopBuilder); + + final Signal signal; + + static Widget _noopBuilder(BuildContext context, Widget? child) { + return child ?? const SizedBox(); + } + + @override + Widget build(BuildContext context) { + signal.value; + super.build(context); + throw StateError('boom'); + } +} + +void main() { + testWidgets('SignalBuilder restores context when builder throws', ( + tester, + ) async { + final prevDetach = SolidartConfig.detachEffects; + SolidartConfig.detachEffects = false; + addTearDown(() => SolidartConfig.detachEffects = prevDetach); + + final signal = Signal(0); + await tester.pumpWidget( + MaterialApp( + home: SignalBuilder( + builder: (context, child) { + signal.value; + throw StateError('boom'); + }, + ), + ), + ); + + final exception = tester.takeException(); + expect(exception, isA()); + expect(SolidartConfig.detachEffects, isFalse); + expect(preset.getActiveSub(), isNull); + }); + + testWidgets( + 'SignalBuilder cleans up dependencies when build throws', + (tester) async { + final prevAutoDispose = SolidartConfig.autoDispose; + SolidartConfig.autoDispose = true; + addTearDown(() => SolidartConfig.autoDispose = prevAutoDispose); + + final signal = Signal(0); + await tester.pumpWidget( + MaterialApp( + home: _ThrowAfterBuildSignalBuilder(signal: signal), + ), + ); + + final exception = tester.takeException(); + expect(exception, isA()); + + await tester.pumpWidget(const SizedBox()); + expect(signal.isDisposed, isTrue); + }, + timeout: const Timeout(Duration(seconds: 1)), + ); + + testWidgets( + 'SignalBuilder.build asserts without dependencies ' + '(covers _isBuilding guard)', + (tester) async { + final previousAssert = + SolidartConfig.assertSignalBuilderWithoutDependencies; + SolidartConfig.assertSignalBuilderWithoutDependencies = true; + addTearDown( + () => SolidartConfig.assertSignalBuilderWithoutDependencies = + previousAssert, + ); + + await tester.pumpWidget( + MaterialApp( + home: SignalBuilder( + builder: (context, child) => const SizedBox(), + ), + ), + ); + + final exception = tester.takeException(); + expect(exception, isA()); + }, + ); +} diff --git a/packages/flutter_solidart/test/value_listenable_signal_mixin_test.dart b/packages/flutter_solidart/test/value_listenable_signal_mixin_test.dart new file mode 100644 index 00000000..77e14d4e --- /dev/null +++ b/packages/flutter_solidart/test/value_listenable_signal_mixin_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Signal listenable notifies listeners and stops after removal', () { + final signal = Signal(0); + var calls = 0; + + void listener() => calls++; + + signal.addListener(listener); + expect(calls, 0); + + signal.value = 1; + expect(calls, 1); + + signal.value = 2; + expect(calls, 2); + + signal.removeListener(listener); + signal.value = 3; + expect(calls, 2); + + signal.dispose(); + }); + + test('Signal listenable disposes effect on signal disposal', () { + final signal = Signal(0); + var calls = 0; + + void listener() => calls++; + + signal.addListener(listener); + signal.value = 1; + expect(calls, 1); + + signal.dispose(); + }); + + test('LazySignal wrapper is constructed', () { + final lazy = LazySignal(); + expect(lazy.isInitialized, isFalse); + + lazy.value = 10; + expect(lazy.value, 10); + + lazy.dispose(); + }); + + test('Resource.stream wrapper constructs and disposes', () { + final resource = Resource.stream( + () => Stream.value(1), + ); + + expect(resource, isA()); + resource.dispose(); + }); +} diff --git a/packages/solidart/CHANGELOG.md b/packages/solidart/CHANGELOG.md index 030a48f0..64832dc1 100644 --- a/packages/solidart/CHANGELOG.md +++ b/packages/solidart/CHANGELOG.md @@ -1,3 +1,39 @@ +## 3.0.0-dev.0 (Unreleased) + +### Signals + +- **BREAKING**: Signals now use the v3 surface from `solidart.dart`; read-only usage is through `ReadonlySignal`. +- **BREAKING**: `Signal.toReadSignal()` renamed to `toReadonly()`. +- **REMOVED**: `SignalBase`, `ReadSignal`/`ReadableSignal`, `Signal.setValue`/`updateValue`, `Signal.hasValue`/`hasPreviousValue`, `Signal.listenerCount`, and `ToggleBoolSignal`. +- **ADDED**: `ReadonlySignal` and `ObserveSignal.observe` for any `ReadonlySignal` (signals, computeds, resources). +- **ADDED**: `ReadonlySignal.until` for awaiting signal conditions (signals, computeds, resources). +- **ADDED**: `LazySignal` type with `Signal.lazy` returning it and `isInitialized` support. + +### Computed + +- **BREAKING**: Computeds are part of the v3 surface and use the `ReadonlySignal` API. + +### Effect + +- **BREAKING**: Effect API simplified by removing `autorun`, `delay`, and `onError`; use `Effect.manual()` and `run()` to control startup, and call `dispose()` (no callable effect). + +### Resource + +- **BREAKING**: `ResourceExtensions` renamed to `ResourceStateExtensions` (`on`/`maybeOn` removed). +- **REMOVED**: `Resource.update`. +- **ADDED**: public `Resource.resolve()` and `Resource.untilReady()`. + +### Core / Shared + +- **BREAKING**: Replace the v2 public surface with the v3 API exported from `solidart.dart` and `advanced.dart`. +- **BREAKING**: `SolidartConfig.equals` is removed; update skipping now uses the per-instance `equals` comparator (defaults to `identical`). +- **BREAKING**: Default auto-disposal is now opt-in; `SolidartConfig.autoDispose` defaults to `false`. +- **BREAKING**: Named identifiers now live on `identifier`/`identifier.name`, and `SolidartObserver` receives `ReadonlySignal` instances. +- **REMOVED**: `Debouncer`/`DebounceOperation`, `FutureOrThenExtension`, and Solidart exception types (`SolidartException`, `SolidartReactionException`, `SolidartCaughtException`). +- **ADDED**: `Disposable`/`DisposableMixin`, `Identifier`, `Configuration`, `Option`/`Some`/`None`. +- **REFACTOR**: Collection signals are reimplemented on the v3 core with copy-on-write updates and standard `ListMixin`/`SetMixin`/`MapMixin` APIs. +- **CHORE**: Upgrade `alien_signals` to `^2.1.1` and add `fake_async` for tests. + ## 2.8.3 - **FIX**: Handle race conditions in Resource that caused multiple calls to `resolve`. @@ -71,8 +107,8 @@ - **REFACTOR**: Update `alien_signals` dependency from `^0.2.1` to `^0.4.3` with significant performance improvements (thanks to @medz). - **REFACTOR**: Replace custom reactive node implementations with `alien.ReactiveNode` for better compatibility and performance (thanks to @medz). - **REFACTOR**: Simplify signal, computed and effect implementations by leveraging new `alien_signals` API (thanks to @medz). -- **PERFORMANCE**: Improve performance by removing redundant tracking operations in the reactive system (thanks to @medz). -- **FIX**: Add proper cleanup for disposed nodes to prevent memory leaks (thanks to @medz). +- **PERFORMANCE**: Improve performance by removing redundant tracking operations in the reactive system (thanks to @medz). +- **FIX**: Add proper cleanup for disposed nodes to prevent memory leaks (thanks to @medz). - **FIX**: Fix potential memory leaks in auto-dispose scenarios (thanks to @medz). - **FIX**: Clear queued flag when running effects in `ReactiveSystem` to ensure proper effect execution (thanks to @medz). - **CHORE**: Reorder dev_dependencies in pubspec.yaml for improved organization and readability (thanks to @medz). @@ -166,7 +202,7 @@ ## 1.2.0 - **FEAT**: Add 3 new signals: `ListSignal`, `SetSignal` and `MapSignal`. Now you can easily be notified of every change of a list, set or map. - _Before_: + _Before_: ```dart final list = Signal([1, 2]); @@ -277,11 +313,11 @@ The core of the library has been rewritten in order to support automatic depende - **CHORE**: Move `refreshing` from `ResourceWidgetBuilder` into the `ResourceState`. (thanks to @manuel-plavsic) - **FEAT**: Add `hasPreviousValue` getter to `ReadSignal`. (thanks to @manuel-plavsic) - **FEAT** Before, only the `fetcher` reacted to the `source`. -Now also the `stream` reacts to the `source` changes by subscribing again to the stream. -In addition, the `stream` parameter of the Resource has been changed from `Stream` into a `Stream Function()` in order to be able to listen to a new stream if it changed. + Now also the `stream` reacts to the `source` changes by subscribing again to the stream. + In addition, the `stream` parameter of the Resource has been changed from `Stream` into a `Stream Function()` in order to be able to listen to a new stream if it changed. - **FEAT**: Add the `select` method on the `Resource` class. -The `select` function allows filtering the `Resource`'s data by reading only the properties that you care about. -The advantage is that you keep handling the loading and error states. + The `select` function allows filtering the `Resource`'s data by reading only the properties that you care about. + The advantage is that you keep handling the loading and error states. - **FEAT**: Make the `Resource` to auto-resolve when accessing its `state`. - **CHORE**: The `refetch` method of a `Resource` has been renamed to `refresh`. - **FEAT**: You can decide whether to use `createSignal()` or directly the `Signal()` constructor, now the're equivalent. The same applies to all the other `create` functions. @@ -289,8 +325,8 @@ The advantage is that you keep handling the loading and error states. ## 1.0.0-dev8 - **FEAT**: Add the select method on the Resource class. -The select function allows filtering the Resource's data by reading only the properties that you care about. -The advantage is that you keep handling the loading and error states. + The select function allows filtering the Resource's data by reading only the properties that you care about. + The advantage is that you keep handling the loading and error states. - **FEAT**: Make the Resource to auto-resolve when accessing its state ## 1.0.0-dev7 @@ -300,8 +336,8 @@ The advantage is that you keep handling the loading and error states. ## 1.0.0-dev6 - **FEAT** Before, only the `fetcher` reacted to the `source`. -Now also the `stream` reacts to the `source` changes by subscribing again to the stream. -In addition, the `stream` parameter of the Resource has been changed from `Stream` into a `Stream Function()` in order to be able to listen to a new stream if it changed + Now also the `stream` reacts to the `source` changes by subscribing again to the stream. + In addition, the `stream` parameter of the Resource has been changed from `Stream` into a `Stream Function()` in order to be able to listen to a new stream if it changed ## 1.0.0-dev5 diff --git a/packages/solidart/lib/advanced.dart b/packages/solidart/lib/advanced.dart new file mode 100644 index 00000000..39793641 --- /dev/null +++ b/packages/solidart/lib/advanced.dart @@ -0,0 +1,9 @@ +export 'src/solidart.dart' + show + Configuration, + Disposable, + DisposableMixin, + Identifier, + None, + Option, + Some; diff --git a/packages/solidart/lib/deps/preset.dart b/packages/solidart/lib/deps/preset.dart new file mode 100644 index 00000000..484f2f55 --- /dev/null +++ b/packages/solidart/lib/deps/preset.dart @@ -0,0 +1 @@ +export 'package:alien_signals/preset.dart'; diff --git a/packages/solidart/lib/deps/system.dart b/packages/solidart/lib/deps/system.dart new file mode 100644 index 00000000..2b6b1a29 --- /dev/null +++ b/packages/solidart/lib/deps/system.dart @@ -0,0 +1 @@ +export 'package:alien_signals/system.dart'; diff --git a/packages/solidart/lib/solidart.dart b/packages/solidart/lib/solidart.dart index 32eaef69..62284eb0 100644 --- a/packages/solidart/lib/solidart.dart +++ b/packages/solidart/lib/solidart.dart @@ -1,15 +1,25 @@ -/// Support for doing something awesome. -/// -/// More dartdocs go here. -library; - -export 'src/core/core.dart' - hide ReactionErrorHandler, ReactionInterface, ReactiveName, ValueComparator; -export 'src/extensions/until.dart'; -export 'src/utils.dart' +export 'src/solidart.dart' show - DebounceOperation, - Debouncer, - SolidartCaughtException, - SolidartException, - SolidartReactionException; + Computed, + DisposeObservation, + Effect, + LazySignal, + ListSignal, + MapSignal, + ObserveCallback, + ObserveSignal, + ReadonlySignal, + Resource, + ResourceError, + ResourceLoading, + ResourceReady, + ResourceState, + ResourceStateExtensions, + SetSignal, + Signal, + SolidartConfig, + SolidartObserver, + UntilSignal, + ValueComparator, + batch, + untracked; diff --git a/packages/solidart/lib/src/core/alien.dart b/packages/solidart/lib/src/core/alien.dart deleted file mode 100644 index d6417dfa..00000000 --- a/packages/solidart/lib/src/core/alien.dart +++ /dev/null @@ -1,74 +0,0 @@ -part of 'core.dart'; - -class _AlienComputed extends alien.ReactiveNode implements _AlienUpdatable { - _AlienComputed(this.parent, this.getter) - : super(flags: 17 /* Mutable | Dirty */); - - final Computed parent; - final T Function(T? oldValue) getter; - - T? value; - - void dispose() => reactiveSystem.stopEffect(this); - - @override - bool update() { - final prevSub = reactiveSystem.setCurrentSub(this); - reactiveSystem.startTracking(this); - try { - final oldValue = value; - return oldValue != (value = getter(oldValue)); - } finally { - reactiveSystem - ..setCurrentSub(prevSub) - ..endTracking(this); - } - } -} - -class _AlienEffect extends alien.ReactiveNode { - _AlienEffect(this.parent, this.run, {bool? detach}) - : detach = detach ?? SolidartConfig.detachEffects, - super(flags: 2 /* Watching */); - - _AlienEffect? nextEffect; - - final bool detach; - final Effect parent; - final void Function() run; - - void dispose() => reactiveSystem.stopEffect(this); -} - -class _AlienSignal extends alien.ReactiveNode implements _AlienUpdatable { - _AlienSignal(this.parent, this.value) - : previousValue = value, - super(flags: 1 /* Mutable */); - - final SignalBase parent; - - Option previousValue; - Option value; - - bool forceDirty = false; - - @override - bool update() { - flags = 1 /* Mutable */; - if (forceDirty) { - forceDirty = false; - return true; - } - if (!parent._compare(previousValue.safeUnwrap(), value.safeUnwrap())) { - previousValue = value; - return true; - } - - return false; - } -} - -// ignore: one_member_abstracts -abstract interface class _AlienUpdatable { - bool update(); -} diff --git a/packages/solidart/lib/src/core/batch.dart b/packages/solidart/lib/src/core/batch.dart index 3c874676..bc2aa7ed 100644 --- a/packages/solidart/lib/src/core/batch.dart +++ b/packages/solidart/lib/src/core/batch.dart @@ -1,30 +1,25 @@ -part of 'core.dart'; +part of '../solidart.dart'; -/// Execute a callback that will not side-effect until its top-most batch is -/// completed. +/// Batches signal updates and flushes once at the end. /// -/// Example: -/// ```dart -/// final x = Signal(10); -/// final y = Signal(20); +/// Nested batches are supported; the final flush happens when the outermost +/// batch completes. /// -/// Effect(() => print('x = ${x.value}, y = ${y.value}')); -/// // The Effect above prints 'x = 10, y = 20' +/// ```dart +/// final a = Signal(1); +/// final b = Signal(2); +/// Effect(() => print('sum: ${a.value + b.value}')); /// /// batch(() { -/// x.value++; -/// y.value++; +/// a.value = 3; +/// b.value = 4; /// }); -/// // The Effect above prints 'x = 11, y = 21' /// ``` -/// As you can see, the effect is not executed until the batch is completed. -/// So when `x` changes, the effect is paused and you never see it printing: -/// "x = 11, y = 20". T batch(T Function() fn) { - reactiveSystem.startBatch(); + preset.startBatch(); try { return fn(); } finally { - reactiveSystem.endBatch(); + preset.endBatch(); } } diff --git a/packages/solidart/lib/src/core/collections/list.dart b/packages/solidart/lib/src/core/collections/list.dart deleted file mode 100644 index 66d3ae96..00000000 --- a/packages/solidart/lib/src/core/collections/list.dart +++ /dev/null @@ -1,912 +0,0 @@ -part of '../core.dart'; - -/// {@template list-signal} -/// `ListSignal` makes easier interacting with lists in a reactive context. -/// -/// ```dart -/// final list = ListSignal([1]); -/// -/// Effect((_) { -/// print(list.first); -/// }); // prints 1 -/// -/// list[0] = 100; // the effect prints 100 -/// ``` -/// {@endtemplate} -class ListSignal extends Signal> with ListMixin { - /// {@macro list-signal} - ListSignal( - Iterable initialValue, { - - /// {@macro SignalBase.name} - super.name, - - /// {@macro SignalBase.equals} - super.equals, - - /// {@macro SignalBase.autoDispose} - super.autoDispose, - - /// {@macro SignalBase.trackInDevTools} - super.trackInDevTools, - - /// {@macro SignalBase.comparator} - super.comparator = identical, - - /// {@macro SignalBase.trackPreviousValue} - super.trackPreviousValue, - }) : super(initialValue.toList()); - - @override - List setValue(List newValue) { - if (_compare(_untrackedValue, newValue)) { - return newValue; - } - _setPreviousValue(List.of(_untrackedValue)); - _untrackedValue = _value = newValue; - _notifyChanged(); - return newValue; - } - - @override - List updateValue(List Function(List value) callback) { - return setValue(callback(List.of(_untrackedValue))); - } - - @override - bool _compare(List? oldValue, List? newValue) { - // skip if the value are equals - if (equals) { - return ListEquality().equals(oldValue, newValue); - } - - // return the [comparator] result - return comparator(oldValue, newValue); - } - - /// The number of objects in this list. - /// - /// The valid indices for a list are `0` through `length - 1`. - /// ```dart - /// final numbers = [1, 2, 3]; - /// print(numbers.length); // 3 - /// ``` - @override - int get length { - return _value.length; - } - - /// Setting the `length` changes the number of elements in the list. - /// - /// The list must be growable. - /// If [newLength] is greater than current length, - /// new entries are initialized to `null`, - /// so [newLength] must not be greater than the current length - /// if the element type [E] is non-nullable. - /// - /// ```dart - /// final maybeNumbers = [1, null, 3]; - /// maybeNumbers.length = 5; - /// print(maybeNumbers); // [1, null, 3, null, null] - /// maybeNumbers.length = 2; - /// print(maybeNumbers); // [1, null] - /// - /// final numbers = [1, 2, 3]; - /// numbers.length = 1; - /// print(numbers); // [1] - /// numbers.length = 5; // Throws, cannot add `null`s. - /// ``` - @override - set length(int newLength) { - if (newLength == _untrackedValue.length) return; - _value.length = newLength; - _notifyChanged(); - } - - /// Returns the [index]th element. - /// - /// The [index] must be non-negative and less than [length]. - /// Index zero represents the first element (so `iterable.elementAt(0)` is - /// equivalent to `iterable.first`). - /// - /// May iterate through the elements in iteration order, ignoring the - /// first [index] elements and then returning the next. - /// Some iterables may have a more efficient way to find the element. - /// - /// Example: - /// ```dart - /// final numbers = [1, 2, 3, 5, 6, 7]; - /// final elementAt = numbers.elementAt(4); // 6 - /// ``` - @override - E elementAt(int index) { - return _value.elementAt(index); - } - - /// Returns the concatenation of this list and [other]. - /// - /// Returns a new list containing the elements of this list followed by - /// the elements of [other]. - /// - /// The default behavior is to return a normal growable list. - /// Some list types may choose to return a list of the same type as themselves - /// (see Uint8List.+); - @override - List operator +(List other) { - return _value + other; - } - - /// The object at the given [index] in the list. - /// - /// The [index] must be a valid index of this list, - /// which means that `index` must be non-negative and - /// less than [length]. - @override - E operator [](int index) { - return _value[index]; - } - - /// Sets the value at the given [index] in the list to [value]. - /// - /// The [index] must be a valid index of this list, - /// which means that `index` must be non-negative and - /// less than [length]. - @override - void operator []=(int index, E value) { - final oldValue = _untrackedValue[index]; - - if (oldValue != value) { - _setPreviousValue(List.of(_untrackedValue)); - _value[index] = value; - _notifyChanged(); - } - } - - /// Adds [value] to the end of this list, - /// extending the length by one. - /// - /// The list must be growable. - /// - /// ```dart - /// final numbers = [1, 2, 3]; - /// numbers.add(4); - /// print(numbers); // [1, 2, 3, 4] - /// ``` - @override - void add(E element) { - _setPreviousValue(List.of(_untrackedValue)); - _value.add(element); - _notifyChanged(); - } - - /// Appends all objects of [iterable] to the end of this list. - /// - /// Extends the length of the list by the number of objects in [iterable]. - /// The list must be growable. - /// - /// ```dart - /// final numbers = [1, 2, 3]; - /// numbers.addAll([4, 5, 6]); - /// print(numbers); // [1, 2, 3, 4, 5, 6] - /// ``` - @override - void addAll(Iterable iterable) { - if (iterable.isNotEmpty) { - _setPreviousValue(List.of(_untrackedValue)); - _value.addAll(iterable); - _notifyChanged(); - } - } - - /// A new `Iterator` that allows iterating the elements of this `Iterable`. - /// - /// Iterable classes may specify the iteration order of their elements - /// (for example [List] always iterate in index order), - /// or they may leave it unspecified (for example a hash-based [Set] - /// may iterate in any order). - /// - /// Each time `iterator` is read, it returns a new iterator, - /// which can be used to iterate through all the elements again. - /// The iterators of the same iterable can be stepped through independently, - /// but should return the same elements in the same order, - /// as long as the underlying collection isn't changed. - /// - /// Modifying the collection may cause new iterators to produce - /// different elements, and may change the order of existing elements. - /// A [List] specifies its iteration order precisely, - /// so modifying the list changes the iteration order predictably. - /// A hash-based [Set] may change its iteration order completely - /// when adding a new element to the set. - /// - /// Modifying the underlying collection after creating the new iterator - /// may cause an error the next time [Iterator.moveNext] is called - /// on that iterator. - /// Any *modifiable* iterable class should specify which operations will - /// break iteration. - @override - Iterator get iterator { - return _value.iterator; - } - - /// The last index in the list that satisfies the provided [test]. - /// - /// Searches the list from index [start] to 0. - /// The first time an object `o` is encountered so that `test(o)` is true, - /// the index of `o` is returned. - /// If [start] is omitted, it defaults to the [length] of the list. - /// - /// ```dart - /// final notes = ['do', 're', 'mi', 're']; - /// final first = notes.lastIndexWhere((note) => note.startsWith('r')); // 3 - /// final second = notes.lastIndexWhere((note) => note.startsWith('r'), - /// 2); // 1 - /// ``` - /// - /// Returns -1 if element is not found. - /// ```dart - /// final notes = ['do', 're', 'mi', 're']; - /// final index = notes.lastIndexWhere((note) => note.startsWith('k')); - /// print(index); // -1 - /// ``` - @override - int lastIndexWhere(bool Function(E element) test, [int? start]) { - return _value.lastIndexWhere(test, start); - } - - /// The last element that satisfies the given predicate [test]. - /// - /// An iterable that can access its elements directly may check its - /// elements in any order (for example a list starts by checking the - /// last element and then moves towards the start of the list). - /// The default implementation iterates elements in iteration order, - /// checks `test(element)` for each, - /// and finally returns that last one that matched. - /// - /// Example: - /// ```dart - /// final numbers = [1, 2, 3, 5, 6, 7]; - /// var result = numbers.lastWhere((element) => element < 5); // 3 - /// result = numbers.lastWhere((element) => element > 5); // 7 - /// result = numbers.lastWhere((element) => element > 10, - /// orElse: () => -1); // -1 - /// ``` - /// - /// If no element satisfies [test], the result of invoking the [orElse] - /// function is returned. - /// If [orElse] is omitted, it defaults to throwing a [StateError]. - @override - E lastWhere(bool Function(E element) test, {E Function()? orElse}) { - return _value.lastWhere(test, orElse: orElse); - } - - /// The first element that satisfies the given predicate [test]. - /// - /// Iterates through elements and returns the first to satisfy [test]. - /// - /// Example: - /// ```dart - /// final numbers = [1, 2, 3, 5, 6, 7]; - /// var result = numbers.firstWhere((element) => element < 5); // 1 - /// result = numbers.firstWhere((element) => element > 5); // 6 - /// result = - /// numbers.firstWhere((element) => element > 10, orElse: () => -1); // -1 - /// ``` - /// - /// If no element satisfies [test], the result of invoking the [orElse] - /// function is returned. - /// If [orElse] is omitted, it defaults to throwing a [StateError]. - /// Stops iterating on the first matching element. - @override - E firstWhere(bool Function(E element) test, {E Function()? orElse}) { - return _value.firstWhere(test, orElse: orElse); - } - - /// The single element that satisfies [test]. - /// - /// Checks elements to see if `test(element)` returns true. - /// If exactly one element satisfies [test], that element is returned. - /// If more than one matching element is found, throws [StateError]. - /// If no matching element is found, returns the result of [orElse]. - /// If [orElse] is omitted, it defaults to throwing a [StateError]. - /// - /// Example: - /// ```dart - /// final numbers = [2, 2, 10]; - /// var result = numbers.singleWhere((element) => element > 5); // 10 - /// ``` - /// When no matching element is found, the result of calling [orElse] is - /// returned instead. - /// ```dart continued - /// result = numbers.singleWhere((element) => element == 1, - /// orElse: () => -1); // -1 - /// ``` - /// There must not be more than one matching element. - /// ```dart continued - /// result = numbers.singleWhere((element) => element == 2); // Throws Error. - /// ``` - @override - E singleWhere(bool Function(E element) test, {E Function()? orElse}) { - return _value.singleWhere(test, orElse: orElse); - } - - /// Checks that this iterable has only one element, and returns that element. - /// - /// Throws a [StateError] if `this` is empty or has more than one element. - /// This operation will not iterate past the second element. - @override - E get single { - return _value.single; - } - - /// The first element. - /// - /// Throws a [StateError] if `this` is empty. - /// Otherwise returns the first element in the iteration order, - /// equivalent to `this.elementAt(0)`. - @override - E get first { - return _value.first; - } - - /// The last element. - /// - /// Throws a [StateError] if `this` is empty. - /// Otherwise may iterate through the elements and returns the last one - /// seen. - /// Some iterables may have more efficient ways to find the last element - /// (for example a list can directly access the last element, - /// without iterating through the previous ones). - @override - E get last { - return _value.last; - } - - /// Returns a new list containing the elements between [start] and [end]. - /// - /// The new list is a `List` containing the elements of this list at - /// positions greater than or equal to [start] and less than [end] in the same - /// order as they occur in this list. - /// - /// ```dart - /// final colors = ['red', 'green', 'blue', 'orange', 'pink']; - /// print(colors.sublist(1, 3)); // [green, blue] - /// ``` - /// - /// If [end] is omitted, it defaults to the [length] of this list. - /// - /// ```dart - /// final colors = ['red', 'green', 'blue', 'orange', 'pink']; - /// print(colors.sublist(3)); // [orange, pink] - /// ``` - /// - /// The `start` and `end` positions must satisfy the relations - /// 0 ≤ `start` ≤ `end` ≤ [length]. - /// If `end` is equal to `start`, then the returned list is empty. - @override - List sublist(int start, [int? end]) { - return _value.sublist(start, end); - } - - /// Returns a view of this list as a list of [R] instances. - /// - /// If this list contains only instances of [R], all read operations - /// will work correctly. If any operation tries to read an element - /// that is not an instance of [R], the access will throw instead. - /// - /// Elements added to the list (e.g., by using [add] or [addAll]) - /// must be instances of [R] to be valid arguments to the adding function, - /// and they must also be instances of [E] to be accepted by - /// this list as well. - /// - /// Methods which accept `Object?` as argument, like [contains] and [remove], - /// will pass the argument directly to the this list's method - /// without any checks. - /// That means that you can do `listOfStrings.cast().remove("a")` - /// successfully, even if it looks like it shouldn't have any effect. - /// - /// Typically implemented as `List.castFrom(this)`. - @override - List cast() => ListSignal(_value.cast()); - - /// Creates a [List] containing the elements of this [Iterable]. - /// - /// The elements are in iteration order. - /// The list is fixed-length if [growable] is false. - /// - /// Example: - /// ```dart - /// final planets = {1: 'Mercury', 2: 'Venus', 3: 'Mars'}; - /// final keysList = planets.keys.toList(growable: false); // [1, 2, 3] - /// final valuesList = - /// planets.values.toList(growable: false); // [Mercury, Venus, Mars] - /// ``` - @override - List toList({bool growable = true}) { - return _value.toList(growable: growable); - } - - /// The first element of the list. - /// - /// The list must be non-empty when accessing its first element. - /// - /// The first element of a list can be modified, unlike an [Iterable]. - /// A `list.first` is equivalent to `list[0]`, - /// both for getting and setting the value. - /// - /// ```dart - /// final numbers = [1, 2, 3]; - /// print(numbers.first); // 1 - /// numbers.first = 10; - /// print(numbers.first); // 10 - /// numbers.clear(); - /// numbers.first; // Throws. - /// ``` - @override - set first(E value) { - final oldValue = _value.first; - if (oldValue != value) { - _setPreviousValue(List.of(_untrackedValue)); - _value.first = value; - _notifyChanged(); - } - } - - /// The last element of the list. - /// - /// The list must be non-empty when accessing its last element. - /// - /// The last element of a list can be modified, unlike an [Iterable]. - /// A `list.last` is equivalent to `theList[theList.length - 1]`, - /// both for getting and setting the value. - /// - /// ```dart - /// final numbers = [1, 2, 3]; - /// print(numbers.last); // 3 - /// numbers.last = 10; - /// print(numbers.last); // 10 - /// numbers.clear(); - /// numbers.last; // Throws. - /// ``` - @override - set last(E value) { - final oldValue = _value.last; - if (oldValue != value) { - _setPreviousValue(List.of(_untrackedValue)); - _value.last = value; - _notifyChanged(); - } - } - - /// Removes all objects from this list; the length of the list becomes zero. - /// - /// The list must be growable. - /// - /// ```dart - /// final numbers = [1, 2, 3]; - /// numbers.clear(); - /// print(numbers.length); // 0 - /// print(numbers); // [] - /// ``` - @override - void clear() { - if (_value.isNotEmpty) { - _setPreviousValue(List.of(_untrackedValue)); - _value.clear(); - _notifyChanged(); - } - } - - /// Overwrites a range of elements with [fillValue]. - /// - /// Sets the positions greater than or equal to [start] and less than [end], - /// to [fillValue]. - /// - /// The provided range, given by [start] and [end], must be valid. - /// A range from [start] to [end] is valid if 0 ≤ `start` ≤ `end` ≤ [length]. - /// An empty range (with `end == start`) is valid. - /// - /// If the element type is not nullable, the [fillValue] must be provided and - /// must be non-`null`. - /// - /// Example: - /// ```dart - /// final words = List.filled(5, 'old'); - /// print(words); // [old, old, old, old, old] - /// words.fillRange(1, 3, 'new'); - /// print(words); // [old, new, new, old, old] - /// ``` - @override - void fillRange(int start, int end, [E? fillValue]) { - if (end > start) { - _setPreviousValue(List.of(_untrackedValue)); - _value.fillRange(start, end, fillValue); - _notifyChanged(); - } - } - - /// Inserts [element] at position [index] in this list. - /// - /// This increases the length of the list by one and shifts all objects - /// at or after the index towards the end of the list. - /// - /// The list must be growable. - /// The [index] value must be non-negative and no greater than [length]. - /// - /// ```dart - /// final numbers = [1, 2, 3, 4]; - /// const index = 2; - /// numbers.insert(index, 10); - /// print(numbers); // [1, 2, 10, 3, 4] - /// ``` - @override - void insert(int index, E element) { - _setPreviousValue(List.of(_untrackedValue)); - _value.insert(index, element); - _notifyChanged(); - } - - /// Inserts all objects of [iterable] at position [index] in this list. - /// - /// This increases the length of the list by the length of [iterable] and - /// shifts all later objects towards the end of the list. - /// - /// The list must be growable. - /// The [index] value must be non-negative and no greater than [length]. - /// ```dart - /// final numbers = [1, 2, 3, 4]; - /// final insertItems = [10, 11]; - /// numbers.insertAll(2, insertItems); - /// print(numbers); // [1, 2, 10, 11, 3, 4] - /// ``` - @override - void insertAll(int index, Iterable iterable) { - if (iterable.isNotEmpty) { - _setPreviousValue(List.of(_untrackedValue)); - _value.insertAll(index, iterable); - _notifyChanged(); - } - } - - /// Removes the first occurrence of [value] from this list. - /// - /// Returns true if [value] was in the list, false otherwise. - /// The list must be growable. - /// - /// ```dart - /// final parts = ['head', 'shoulders', 'knees', 'toes']; - /// final retVal = parts.remove('head'); // true - /// print(parts); // [shoulders, knees, toes] - /// ``` - /// The method has no effect if [value] was not in the list. - /// ```dart - /// final parts = ['shoulders', 'knees', 'toes']; - /// // Note: 'head' has already been removed. - /// final retVal = parts.remove('head'); // false - /// print(parts); // [shoulders, knees, toes] - /// ``` - @override - bool remove(Object? element) { - var didRemove = false; - final index = _value.indexOf(element as E); - if (index >= 0) { - _setPreviousValue(List.of(_untrackedValue)); - _value.removeAt(index); - _notifyChanged(); - didRemove = true; - } - - return didRemove; - } - - /// Removes the object at position [index] from this list. - /// - /// This method reduces the length of `this` by one and moves all later - /// objects down by one position. - /// - /// Returns the removed value. - /// - /// The [index] must be in the range `0 ≤ index < length`. - /// The list must be growable. - /// ```dart - /// final parts = ['head', 'shoulder', 'knees', 'toes']; - /// final retVal = parts.removeAt(2); // knees - /// print(parts); // [head, shoulder, toes] - /// ``` - @override - E removeAt(int index) { - _setPreviousValue(List.of(_untrackedValue)); - - final removed = _value.removeAt(index); - _notifyChanged(); - - return removed; - } - - /// Removes and returns the last object in this list. - /// - /// The list must be growable and non-empty. - /// ```dart - /// final parts = ['head', 'shoulder', 'knees', 'toes']; - /// final retVal = parts.removeLast(); // toes - /// print(parts); // [head, shoulder, knees] - /// ``` - @override - E removeLast() { - _setPreviousValue(List.of(_untrackedValue)); - - final removed = _value.removeLast(); - _notifyChanged(); - - return removed; - } - - /// Removes a range of elements from the list. - /// - /// Removes the elements with positions greater than or equal to [start] - /// and less than [end], from the list. - /// This reduces the list's length by `end - start`. - /// - /// The provided range, given by [start] and [end], must be valid. - /// A range from [start] to [end] is valid if 0 ≤ `start` ≤ `end` ≤ [length]. - /// An empty range (with `end == start`) is valid. - /// - /// The list must be growable. - /// ```dart - /// final numbers = [1, 2, 3, 4, 5]; - /// numbers.removeRange(1, 4); - /// print(numbers); // [1, 5] - /// ``` - @override - void removeRange(int start, int end) { - if (end > start) { - _setPreviousValue(List.of(_untrackedValue)); - _value.removeRange(start, end); - _notifyChanged(); - } - } - - /// Removes all objects from this list that satisfy [test]. - /// - /// An object `o` satisfies [test] if `test(o)` is true. - /// ```dart - /// final numbers = ['one', 'two', 'three', 'four']; - /// numbers.removeWhere((item) => item.length == 3); - /// print(numbers); // [three, four] - /// ``` - /// The list must be growable. - @override - void removeWhere(bool Function(E element) test) { - final removedIndexes = []; - for (var i = _value.length - 1; i >= 0; --i) { - final element = _value[i]; - if (test(element)) { - removedIndexes.add(i); - } - } - if (removedIndexes.isNotEmpty) { - _setPreviousValue(List.of(_untrackedValue)); - for (final index in removedIndexes) { - _value.removeAt(index); - } - _notifyChanged(); - } - } - - /// Replaces a range of elements with the elements of [replacements]. - /// - /// Removes the objects in the range from [start] to [end], - /// then inserts the elements of [replacements] at [start]. - /// ```dart - /// final numbers = [1, 2, 3, 4, 5]; - /// final replacements = [6, 7]; - /// numbers.replaceRange(1, 4, replacements); - /// print(numbers); // [1, 6, 7, 5] - /// ``` - /// The provided range, given by [start] and [end], must be valid. - /// A range from [start] to [end] is valid if 0 ≤ `start` ≤ `end` ≤ [length]. - /// An empty range (with `end == start`) is valid. - /// - /// The operation `list.replaceRange(start, end, replacements)` - /// is roughly equivalent to: - /// ```dart - /// final numbers = [1, 2, 3, 4, 5]; - /// numbers.removeRange(1, 4); - /// final replacements = [6, 7]; - /// numbers.insertAll(1, replacements); - /// print(numbers); // [1, 6, 7, 5] - /// ``` - /// but may be more efficient. - /// - /// The list must be growable. - /// This method does not work on fixed-length lists, even when [replacements] - /// has the same number of elements as the replaced range. In that case use - /// [setRange] instead. - @override - void replaceRange(int start, int end, Iterable replacements) { - if (end > start || replacements.isNotEmpty) { - _setPreviousValue(List.of(_untrackedValue)); - _value.replaceRange(start, end, replacements); - _notifyChanged(); - } - } - - /// Removes all objects from this list that fail to satisfy [test]. - /// - /// An object `o` satisfies [test] if `test(o)` is true. - /// ```dart - /// final numbers = ['one', 'two', 'three', 'four']; - /// numbers.retainWhere((item) => item.length == 3); - /// print(numbers); // [one, two] - /// ``` - /// The list must be growable. - @override - void retainWhere(bool Function(E element) test) { - final removedIndexes = []; - for (var i = _value.length - 1; i >= 0; --i) { - final element = _value[i]; - if (!test(element)) { - removedIndexes.add(i); - } - } - if (removedIndexes.isNotEmpty) { - _setPreviousValue(List.of(_untrackedValue)); - for (final index in removedIndexes) { - _value.removeAt(index); - } - } - } - - /// Overwrites elements with the objects of [iterable]. - /// - /// The elements of [iterable] are written into this list, - /// starting at position [index]. - /// This operation does not increase the length of the list. - /// - /// The [index] must be non-negative and no greater than [length]. - /// - /// The [iterable] must not have more elements than what can fit from [index] - /// to [length]. - /// - /// If `iterable` is based on this list, its values may change _during_ the - /// `setAll` operation. - /// ```dart - /// final list = ['a', 'b', 'c', 'd']; - /// list.setAll(1, ['bee', 'sea']); - /// print(list); // [a, bee, sea, d] - /// ``` - @override - void setAll(int index, Iterable iterable) { - if (iterable.isNotEmpty) { - _setPreviousValue(List.of(_untrackedValue)); - - _value.setAll(index, iterable); - _notifyChanged(); - } - } - - /// Writes some elements of [iterable] into a range of this list. - /// - /// Copies the objects of [iterable], skipping [skipCount] objects first, - /// into the range from [start], inclusive, to [end], exclusive, of this list. - /// ```dart - /// final list1 = [1, 2, 3, 4]; - /// final list2 = [5, 6, 7, 8, 9]; - /// // Copies the 4th and 5th items in list2 as the 2nd and 3rd items - /// // of list1. - /// const skipCount = 3; - /// list1.setRange(1, 3, list2, skipCount); - /// print(list1); // [1, 8, 9, 4] - /// ``` - /// The provided range, given by [start] and [end], must be valid. - /// A range from [start] to [end] is valid if 0 ≤ `start` ≤ `end` ≤ [length]. - /// An empty range (with `end == start`) is valid. - /// - /// The [iterable] must have enough objects to fill the range from `start` - /// to `end` after skipping [skipCount] objects. - /// - /// If `iterable` is this list, the operation correctly copies the elements - /// originally in the range from `skipCount` to `skipCount + (end - start)` to - /// the range `start` to `end`, even if the two ranges overlap. - /// - /// If `iterable` depends on this list in some other way, no guarantees are - /// made. - @override - void setRange(int start, int end, Iterable iterable, [int skipCount = 0]) { - if (end > start) { - _setPreviousValue(List.of(_untrackedValue)); - _value.setRange(start, end, iterable, skipCount); - _notifyChanged(); - } - } - - /// Shuffles the elements of this list randomly. - /// ```dart - /// final numbers = [1, 2, 3, 4, 5]; - /// numbers.shuffle(); - /// print(numbers); // [1, 3, 4, 5, 2] OR some other random result. - /// ``` - @override - void shuffle([Random? random]) { - if (_value.isNotEmpty) { - final oldList = _value.toList(growable: false); - final newList = _value.toList()..shuffle(random); - var hasChanges = false; - for (var i = 0; i < newList.length; ++i) { - final oldValue = oldList[i]; - final newValue = newList[i]; - if (newValue != oldValue) { - hasChanges = true; - break; - } - } - if (hasChanges) { - _setPreviousValue(List.of(_untrackedValue)); - _value = newList; - _notifyChanged(); - } - } - } - - /// Sorts this list according to the order specified by the [compare] function - /// - /// The [compare] function must act as a [Comparator]. - /// ```dart - /// final numbers = ['two', 'three', 'four']; - /// // Sort from shortest to longest. - /// numbers.sort((a, b) => a.length.compareTo(b.length)); - /// print(numbers); // [two, four, three] - /// ``` - /// The default [List] implementations use [Comparable.compare] if - /// [compare] is omitted. - /// ```dart - /// final numbers = [13, 2, -11, 0]; - /// numbers.sort(); - /// print(numbers); // [-11, 0, 2, 13] - /// ``` - /// In that case, the elements of the list must be [Comparable] to - /// each other. - /// - /// A [Comparator] may compare objects as equal (return zero), even if they - /// are distinct objects. - /// The sort function is not guaranteed to be stable, so distinct objects - /// that compare as equal may occur in any order in the result: - /// ```dart - /// final numbers = ['one', 'two', 'three', 'four']; - /// numbers.sort((a, b) => a.length.compareTo(b.length)); - /// print(numbers); // [one, two, four, three] OR [two, one, four, three] - /// ``` - @override - void sort([int Function(E a, E b)? compare]) { - if (_value.isNotEmpty) { - final oldList = _value.toList(growable: false); - final newList = _value.toList()..sort(compare); - var hasChanges = false; - for (var i = 0; i < newList.length; ++i) { - final oldValue = oldList[i]; - final newValue = newList[i]; - if (newValue != oldValue) { - hasChanges = true; - break; - } - } - if (hasChanges) { - _setPreviousValue(List.of(_untrackedValue)); - _value = newList; - _notifyChanged(); - } - } - } - - @override - String toString() => - '''ListSignal<$E>(value: $_value, previousValue: $_previousValue)'''; - - void _notifyChanged() { - _reportChanged(); - _notifySignalUpdate(); - } - - @override - // ignore: overridden_fields - final _id = ReactiveName.nameFor('ListSignal'); -} diff --git a/packages/solidart/lib/src/core/collections/map.dart b/packages/solidart/lib/src/core/collections/map.dart deleted file mode 100644 index 9338d0c9..00000000 --- a/packages/solidart/lib/src/core/collections/map.dart +++ /dev/null @@ -1,433 +0,0 @@ -part of '../core.dart'; - -/// {@template map-signal} -/// `MapSignal` makes easier interacting with maps in a reactive context. -/// -/// ```dart -/// final map = MapSignal({'first': 1}); -/// -/// Effect((_) { -/// print(map['first']); -/// }); // prints 1 -/// -/// map['first'] = 100; // the effect prints 100 -/// ``` -/// {@endtemplate} -class MapSignal extends Signal> with MapMixin { - /// {@macro map-signal} - MapSignal( - super.initialValue, { - - /// {@macro SignalBase.name} - super.name, - - /// {@macro SignalBase.equals} - super.equals, - - /// {@macro SignalBase.autoDispose} - super.autoDispose, - - /// {@macro SignalBase.trackInDevTools} - super.trackInDevTools, - - /// {@macro SignalBase.comparator} - super.comparator = identical, - - /// {@macro SignalBase.trackPreviousValue} - super.trackPreviousValue, - }); - - @override - Map setValue(Map newValue) { - if (_compare(_value, newValue)) { - return newValue; - } - _setPreviousValue(Map.of(_value)); - _untrackedValue = _value = newValue; - _notifyChanged(); - return newValue; - } - - @override - Map updateValue(Map Function(Map value) callback) => - value = callback(Map.of(_value)); - - @override - bool _compare(Map? oldValue, Map? newValue) { - // skip if the value are equals - if (equals) { - return MapEquality().equals(oldValue, newValue); - } - - // return the [comparator] result - return comparator(oldValue, newValue); - } - - /// The value for the given [key], or `null` if [key] is not in the map. - /// - /// Some maps allow `null` as a value. - /// For those maps, a lookup using this operator cannot distinguish between a - /// key not being in the map, and the key being there with a `null` value. - /// Methods like [containsKey] or [putIfAbsent] can be used if the distinction - /// is important. - @override - V? operator [](Object? key) { - value; - - // Wrap in parentheses to avoid parsing conflicts when casting the key - return _value[(key as K?)]; - } - - /// Associates the [key] with the given [value]. - /// - /// If the key was already in the map, its associated value is changed. - /// Otherwise the key/value pair is added to the map. - @override - void operator []=(K key, V value) { - final oldValue = _value[key]; - if (!_value.containsKey(key) || value != oldValue) { - _setPreviousValue(Map.of(_value)); - _value[key] = value; - _notifyChanged(); - } - } - - /// Removes all entries from the map. - /// - /// After this, the map is empty. - /// ```dart - /// final planets = {1: 'Mercury', 2: 'Venus', 3: 'Earth'}; - /// planets.clear(); // {} - /// ``` - @override - void clear() { - if (_value.isNotEmpty) { - _setPreviousValue(Map.of(_value)); - _value.clear(); - _notifyChanged(); - } - } - - /// The keys of the Map. - /// - /// The returned iterable has efficient `length` and `contains` operations, - /// based on [length] and [containsKey] of the map. - /// - /// The order of iteration is defined by the individual `Map` implementation, - /// but must be consistent between changes to the map. - /// - /// Modifying the map while iterating the keys may break the iteration. - @override - Iterable get keys { - value; - return _value.keys; - } - - /// The values of the Map. - /// - /// The values are iterated in the order of their corresponding keys. - /// This means that iterating [keys] and [values] in parallel will - /// provide matching pairs of keys and values. - /// - /// The returned iterable has an efficient `length` method based on the - /// [length] of the map. Its [Iterable.contains] method is based on - /// `==` comparison. - /// - /// Modifying the map while iterating the values may break the iteration. - @override - Iterable get values { - value; - return _value.values; - } - - /// Removes [key] and its associated value, if present, from the map. - /// - /// Returns the value associated with `key` before it was removed. - /// Returns `null` if `key` was not in the map. - /// - /// Note that some maps allow `null` as a value, - /// so a returned `null` value doesn't always mean that the key was absent. - /// ```dart - /// final terrestrial = {1: 'Mercury', 2: 'Venus', 3: 'Earth'}; - /// final removedValue = terrestrial.remove(2); // Venus - /// print(terrestrial); // {1: Mercury, 3: Earth} - /// ``` - @override - V? remove(Object? key) { - V? value; - if (_value.containsKey(key)) { - _setPreviousValue(Map.of(_value)); - value = _value.remove(key); - _notifyChanged(); - } - return value; - } - - /// Provides a view of this map as having [RK] keys and [RV] instances, - /// if necessary. - /// - /// If this map is already a `Map`, it is returned unchanged. - /// - /// If this set contains only keys of type [RK] and values of type [RV], - /// all read operations will work correctly. - /// If any operation exposes a non-[RK] key or non-[RV] value, - /// the operation will throw instead. - /// - /// Entries added to the map must be valid for both a `Map` and a - /// `Map`. - /// - /// Methods which accept `Object?` as argument, - /// like [containsKey], [remove] and [operator []], - /// will pass the argument directly to the this map's method - /// without any checks. - /// That means that you can do `mapWithStringKeys.cast().remove("a")` - /// successfully, even if it looks like it shouldn't have any effect. - @override - Map cast() => MapSignal(_value.cast()); - - /// The number of key/value pairs in the map. - @override - int get length { - value; - return _value.length; - } - - /// Whether there is no key/value pair in the map. - @override - bool get isEmpty { - value; - return _value.isEmpty; - } - - /// Whether there is at least one key/value pair in the map. - @override - bool get isNotEmpty { - value; - return _value.isNotEmpty; - } - - /// Whether this map contains the given [key]. - /// - /// Returns true if any of the keys in the map are equal to `key` - /// according to the equality used by the map. - /// ```dart - /// final moonCount = {'Mercury': 0, 'Venus': 0, 'Earth': 1, - /// 'Mars': 2, 'Jupiter': 79, 'Saturn': 82, 'Uranus': 27, 'Neptune': 14 }; - /// final containsUranus = moonCount.containsKey('Uranus'); // true - /// final containsPluto = moonCount.containsKey('Pluto'); // false - /// ``` - @override - bool containsKey(Object? key) { - value; - return _value.containsKey(key); - } - - /// Whether this map contains the given [value]. - /// - /// Returns true if any of the values in the map are equal to `value` - /// according to the `==` operator. - /// ```dart - /// final moonCount = {'Mercury': 0, 'Venus': 0, 'Earth': 1, - /// 'Mars': 2, 'Jupiter': 79, 'Saturn': 82, 'Uranus': 27, 'Neptune': 14 }; - /// final moons3 = moonCount.containsValue(3); // false - /// final moons82 = moonCount.containsValue(82); // true - /// ``` - @override - bool containsValue(Object? value) { - this.value; - return _value.containsValue(value); - } - - /// The map entries of the Map. - @override - Iterable> get entries { - value; - return _value.entries; - } - - /// Adds all key/value pairs of [newEntries] to this map. - /// - /// If a key of [newEntries] is already in this map, - /// the corresponding value is overwritten. - /// - /// The operation is equivalent to doing `this[entry.key] = entry.value` - /// for each [MapEntry] of the iterable. - /// ```dart - /// final planets = {1: 'Mercury', 2: 'Venus', - /// 3: 'Earth', 4: 'Mars'}; - /// final gasGiants = {5: 'Jupiter', 6: 'Saturn'}; - /// final iceGiants = {7: 'Uranus', 8: 'Neptune'}; - /// planets.addEntries(gasGiants.entries); - /// planets.addEntries(iceGiants.entries); - /// print(planets); - /// // {1: Mercury, 2: Venus, 3: Earth, 4: Mars, 5: Jupiter, 6: Saturn, - /// // 7: Uranus, 8: Neptune} - /// ``` - @override - void addEntries(Iterable> newEntries) { - if (newEntries.isNotEmpty) { - _setPreviousValue(Map.of(_value)); - _value.addEntries(newEntries); - _notifyChanged(); - } - } - - /// Adds all key/value pairs of [other] to this map. - /// - /// If a key of [other] is already in this map, its value is overwritten. - /// - /// The operation is equivalent to doing `this[key] = value` for each key - /// and associated value in other. It iterates over [other], which must - /// therefore not change during the iteration. - /// ```dart - /// final planets = {1: 'Mercury', 2: 'Earth'}; - /// planets.addAll({5: 'Jupiter', 6: 'Saturn'}); - /// print(planets); // {1: Mercury, 2: Earth, 5: Jupiter, 6: Saturn} - /// ``` - @override - void addAll(Map other) { - if (other.isNotEmpty) { - _setPreviousValue(Map.of(_value)); - _value.addAll(other); - _notifyChanged(); - } - } - - /// Look up the value of [key], or add a new entry if it isn't there. - /// - /// Returns the value associated to [key], if there is one. - /// Otherwise calls [ifAbsent] to get a new value, associates [key] to - /// that value, and then returns the new value. - /// ```dart - /// final diameters = {1.0: 'Earth'}; - /// final otherDiameters = {0.383: 'Mercury', 0.949: 'Venus'}; - /// - /// for (final item in otherDiameters.entries) { - /// diameters.putIfAbsent(item.key, () => item.value); - /// } - /// print(diameters); // {1.0: Earth, 0.383: Mercury, 0.949: Venus} - /// - /// // If the key already exists, the current value is returned. - /// final result = diameters.putIfAbsent(0.383, () => 'Random'); - /// print(result); // Mercury - /// print(diameters); // {1.0: Earth, 0.383: Mercury, 0.949: Venus} - /// ``` - /// Calling [ifAbsent] must not add or remove keys from the map. - @override - V putIfAbsent(K key, V Function() ifAbsent) { - if (_value.containsKey(key)) { - value; - return _value[key] as V; - } - _setPreviousValue(Map.of(_value)); - _value[key] = ifAbsent(); - _notifyChanged(); - return _value[key] as V; - } - - /// Removes all entries of this map that satisfy the given [test]. - /// ```dart - /// final terrestrial = {1: 'Mercury', 2: 'Venus', 3: 'Earth'}; - /// terrestrial.removeWhere((key, value) => value.startsWith('E')); - /// print(terrestrial); // {1: Mercury, 2: Venus} - /// ``` - @override - void removeWhere(bool Function(K key, V value) test) { - final keysToRemove = []; - for (final key in keys) { - if (test(key, this[key] as V)) keysToRemove.add(key); - } - if (keysToRemove.isNotEmpty) { - _setPreviousValue(Map.of(_value)); - } - for (final key in keysToRemove) { - _value.remove(key); - } - if (keysToRemove.isNotEmpty) { - _notifyChanged(); - } - } - - /// Updates the value for the provided [key]. - /// - /// Returns the new value associated with the key. - /// - /// If the key is present, invokes [update] with the current value and stores - /// the new value in the map. - /// - /// If the key is not present and [ifAbsent] is provided, calls [ifAbsent] - /// and adds the key with the returned value to the map. - /// - /// If the key is not present, [ifAbsent] must be provided. - /// ```dart - /// final planetsFromSun = {1: 'Mercury', 2: 'unknown', - /// 3: 'Earth'}; - /// // Update value for known key value 2. - /// planetsFromSun.update(2, (value) => 'Venus'); - /// print(planetsFromSun); // {1: Mercury, 2: Venus, 3: Earth} - /// - /// final largestPlanets = {1: 'Jupiter', 2: 'Saturn', - /// 3: 'Neptune'}; - /// // Key value 8 is missing from list, add it using [ifAbsent]. - /// largestPlanets.update(8, (value) => 'New', ifAbsent: () => 'Mercury'); - /// print(largestPlanets); // {1: Jupiter, 2: Saturn, 3: Neptune, 8: Mercury} - /// ``` - @override - V update(K key, V Function(V value) update, {V Function()? ifAbsent}) { - if (_value.containsKey(key)) { - final oldValue = _value[key]; - final newValue = update(_value[key] as V); - if (oldValue != newValue) { - _setPreviousValue(Map.of(_value)); - _value[key] = newValue; - } - return _value[key] as V; - } - if (ifAbsent != null) { - _setPreviousValue(Map.of(_value)); - return _value[key] = ifAbsent(); - } - throw ArgumentError.value(key, 'key', 'Key not in map.'); - } - - /// Updates all values. - /// - /// Iterates over all entries in the map and updates them with the result - /// of invoking [update]. - /// ```dart - /// final terrestrial = {1: 'Mercury', 2: 'Venus', 3: 'Earth'}; - /// terrestrial.updateAll((key, value) => value.toUpperCase()); - /// print(terrestrial); // {1: MERCURY, 2: VENUS, 3: EARTH} - /// ``` - @override - void updateAll(V Function(K key, V value) update) { - final changes = {}; - for (final key in keys) { - final oldValue = _value[key]; - final newValue = update(key, _value[key] as V); - if (oldValue != newValue) { - changes[key] = newValue; - } - } - if (changes.isNotEmpty) { - _setPreviousValue(Map.of(_value)); - for (final key in keys) { - _value[key] = changes[key] as V; - } - _notifyChanged(); - } - } - - @override - String toString() => - '''MapSignal<$K, $V>(value: $_value, previousValue: $_previousValue)'''; - - void _notifyChanged() { - _reportChanged(); - _notifySignalUpdate(); - } - - @override - // ignore: overridden_fields - final _id = ReactiveName.nameFor('MapSignal'); -} diff --git a/packages/solidart/lib/src/core/collections/set.dart b/packages/solidart/lib/src/core/collections/set.dart deleted file mode 100644 index 4e7324c8..00000000 --- a/packages/solidart/lib/src/core/collections/set.dart +++ /dev/null @@ -1,475 +0,0 @@ -part of '../core.dart'; - -/// {@template set-signal} -/// `SetSignal` makes easier interacting with sets in a reactive context. -/// -/// ```dart -/// final set = SetSignal({1}); -/// -/// Effect((_) { -/// print(set.first); -/// }); // prints 1 -/// -/// set[0] = 100; // the effect prints 100 -/// ``` -/// {@endtemplate} -class SetSignal extends Signal> with SetMixin { - /// {@macro set-signal} - SetSignal( - Iterable initialValue, { - - /// {@macro SignalBase.name} - super.name, - - /// {@macro SignalBase.equals} - super.equals, - - /// {@macro SignalBase.autoDispose} - super.autoDispose, - - /// {@macro SignalBase.trackInDevTools} - super.trackInDevTools, - - /// {@macro SignalBase.comparator} - super.comparator = identical, - - /// {@macro SignalBase.trackPreviousValue} - super.trackPreviousValue, - }) : super(initialValue.toSet()); - - @override - Set setValue(Set newValue) { - if (_compare(_value, newValue)) { - return newValue; - } - _setPreviousValue(Set.of(_value)); - _untrackedValue = _value = newValue; - _notifyChanged(); - return newValue; - } - - @override - Set updateValue(Set Function(Set value) callback) => - value = callback(Set.of(_value)); - - @override - bool _compare(Set? oldValue, Set? newValue) { - // skip if the value are equals - if (equals) { - return SetEquality().equals(oldValue, newValue); - } - - // return the [comparator] result - return comparator(oldValue, newValue); - } - - /// Adds [value] to the set. - /// - /// Returns `true` if [value] (or an equal value) was not yet in the set. - /// Otherwise returns `false` and the set is not changed. - /// - /// Example: - /// ```dart - /// final dateTimes = {}; - /// final time1 = DateTime.fromMillisecondsSinceEpoch(0); - /// final time2 = DateTime.fromMillisecondsSinceEpoch(0); - /// // time1 and time2 are equal, but not identical. - /// assert(time1 == time2); - /// assert(!identical(time1, time2)); - /// final time1Added = dateTimes.add(time1); - /// print(time1Added); // true - /// // A value equal to time2 exists already in the set, and the call to - /// // add doesn't change the set. - /// final time2Added = dateTimes.add(time2); - /// print(time2Added); // false - /// - /// print(dateTimes); // {1970-01-01 02:00:00.000} - /// assert(dateTimes.length == 1); - /// assert(identical(time1, dateTimes.first)); - /// print(dateTimes.length); - /// ``` - @override - bool add(E value) { - _setPreviousValue(Set.of(_value)); - final result = _value.add(value); - if (result) _notifyChanged(); - return result; - } - - /// Whether [value] is in the set. - /// ```dart - /// final characters = {'A', 'B', 'C'}; - /// final containsB = characters.contains('B'); // true - /// final containsD = characters.contains('D'); // false - /// ``` - @override - bool contains(Object? element) { - value; - return _value.contains(element); - } - - /// An iterator that iterates over the elements of this set. - /// - /// The order of iteration is defined by the individual `Set` implementation, - /// but must be consistent between changes to the set. - @override - Iterator get iterator { - value; - return _value.iterator; - } - - /// Returns the number of elements in the iterable. - /// - /// This is an efficient operation that doesn't require iterating through - /// the elements. - @override - int get length { - value; - return _value.length; - } - - /// Returns the [index]th element. - /// - /// The [index] must be non-negative and less than [length]. - /// Index zero represents the first element (so `iterable.elementAt(0)` is - /// equivalent to `iterable.first`). - /// - /// May iterate through the elements in iteration order, ignoring the - /// first [index] elements and then returning the next. - /// Some iterables may have a more efficient way to find the element. - /// - /// Example: - /// ```dart - /// final numbers = [1, 2, 3, 5, 6, 7]; - /// final elementAt = numbers.elementAt(4); // 6 - /// ``` - @override - E elementAt(int index) { - value; - return _value.elementAt(index); - } - - /// If an object equal to [object] is in the set, return it. - /// - /// Checks whether [object] is in the set, like [contains], and if so, - /// returns the object in the set, otherwise returns `null`. - /// - /// If the equality relation used by the set is not identity, - /// then the returned object may not be *identical* to [object]. - /// Some set implementations may not be able to implement this method. - /// If the [contains] method is computed, - /// rather than being based on an actual object instance, - /// then there may not be a specific object instance representing the - /// set element. - /// ```dart - /// final characters = {'A', 'B', 'C'}; - /// final containsB = characters.lookup('B'); - /// print(containsB); // B - /// final containsD = characters.lookup('D'); - /// print(containsD); // null - /// ``` - @override - E? lookup(Object? object) { - value; - return _value.lookup(object); - } - - /// Removes [value] from the set. - /// - /// Returns `true` if [value] was in the set, and `false` if not. - /// The method has no effect if [value] was not in the set. - /// ```dart - /// final characters = {'A', 'B', 'C'}; - /// final didRemoveB = characters.remove('B'); // true - /// final didRemoveD = characters.remove('D'); // false - /// print(characters); // {A, C} - /// ``` - @override - bool remove(Object? value) { - var didRemove = false; - final index = _value.toList(growable: false).indexOf(value as E); - if (index >= 0) { - _setPreviousValue(Set.of(_value)); - _value.remove(value); - _notifyChanged(); - didRemove = true; - } - - return didRemove; - } - - /// Removes each element of [elements] from this set. - /// ```dart - /// final characters = {'A', 'B', 'C'}; - /// characters.removeAll({'A', 'B', 'X'}); - /// print(characters); // {C} - /// ``` - @override - void removeAll(Iterable elements) { - _setPreviousValue(Set.of(_value)); - var hasChanges = false; - for (final element in elements) { - final removed = _value.remove(element); - if (!hasChanges && removed) { - hasChanges = true; - } - } - - if (hasChanges) _notifyChanged(); - } - - /// Removes all elements of this set that satisfy [test]. - /// ```dart - /// final characters = {'A', 'B', 'C'}; - /// characters.removeWhere((element) => element.startsWith('B')); - /// print(characters); // {A, C} - /// ``` - @override - void removeWhere(bool Function(E element) test) { - final toRemove = []; - for (final element in this) { - if (test(element)) { - toRemove.add(element); - } - } - removeAll(toRemove); - } - - /// Creates a [Set] with the same elements and behavior as this `Set`. - /// - /// The returned set behaves the same as this set - /// with regard to adding and removing elements. - /// It initially contains the same elements. - /// If this set specifies an ordering of the elements, - /// the returned set will have the same order. - @override - Set toSet() { - value; - return _value.toSet(); - } - - /// Provides a view of this set as a set of [R] instances. - /// - /// If this set contains only instances of [R], all read operations - /// will work correctly. If any operation tries to access an element - /// that is not an instance of [R], the access will throw instead. - /// - /// Elements added to the set (e.g., by using [add] or [addAll]) - /// must be instances of [R] to be valid arguments to the adding function, - /// and they must be instances of [E] as well to be accepted by - /// this set as well. - /// - /// Methods which accept one or more `Object?` as argument, - /// like [contains], [remove] and [removeAll], - /// will pass the argument directly to the this set's method - /// without any checks. - /// That means that you can do `setOfStrings.cast().remove("a")` - /// successfully, even if it looks like it shouldn't have any effect. - @override - Set cast() => SetSignal(_value.cast()); - - /// Removes all elements from the set. - /// ```dart - /// final characters = {'A', 'B', 'C'}; - /// characters.clear(); // {} - /// ``` - @override - void clear() { - if (_value.isNotEmpty) { - _setPreviousValue(Set.of(_value)); - _value.clear(); - _notifyChanged(); - } - } - - /// Removes all elements of this set that are not elements in [elements]. - /// - /// Checks for each element of [elements] whether there is an element in this - /// set that is equal to it (according to `this.contains`), and if so, the - /// equal element in this set is retained, and elements that are not equal - /// to any element in [elements] are removed. - /// ```dart - /// final characters = {'A', 'B', 'C'}; - /// characters.retainAll({'A', 'B', 'X'}); - /// print(characters); // {A, B} - /// ``` - @override - void retainAll(Iterable elements) { - // Create a copy of the set, remove all of elements from the copy, - // then remove all remaining elements in copy from this. - final toRemove = _value.toSet(); - for (final e in elements) { - toRemove.remove(e); - } - removeAll(toRemove); - } - - /// Removes all elements of this set that fail to satisfy [test]. - /// ```dart - /// final characters = {'A', 'B', 'C'}; - /// characters.retainWhere( - /// (element) => element.startsWith('B') || element.startsWith('C')); - /// print(characters); // {B, C} - /// ``` - @override - void retainWhere(bool Function(E element) test) { - final removed = []; - for (var i = _value.length - 1; i >= 0; --i) { - final element = _value.elementAt(i); - if (!test(element)) { - removed.add(element); - } - } - if (removed.isNotEmpty) { - _setPreviousValue(Set.of(_value)); - for (final item in removed) { - _value.remove(item); - } - } - } - - /// Creates a [List] containing the elements of this [Iterable]. - /// - /// The elements are in iteration order. - /// The list is fixed-length if [growable] is false. - /// - /// Example: - /// ```dart - /// final planets = {1: 'Mercury', 2: 'Venus', 3: 'Mars'}; - /// final keysList = planets.keys.toList(growable: false); // [1, 2, 3] - /// final valuesList = - /// planets.values.toList(growable: false); // [Mercury, Venus, Mars] - /// ``` - @override - List toList({bool growable = true}) { - value; - return _value.toList(growable: growable); - } - - /// The last element that satisfies the given predicate [test]. - /// - /// An iterable that can access its elements directly may check its - /// elements in any order (for example a list starts by checking the - /// last element and then moves towards the start of the list). - /// The default implementation iterates elements in iteration order, - /// checks `test(element)` for each, - /// and finally returns that last one that matched. - /// - /// Example: - /// ```dart - /// final numbers = [1, 2, 3, 5, 6, 7]; - /// var result = numbers.lastWhere((element) => element < 5); // 3 - /// result = numbers.lastWhere((element) => element > 5); // 7 - /// result = numbers.lastWhere((element) => element > 10, - /// orElse: () => -1); // -1 - /// ``` - /// - /// If no element satisfies [test], the result of invoking the [orElse] - /// function is returned. - /// If [orElse] is omitted, it defaults to throwing a [StateError]. - @override - E lastWhere(bool Function(E value) test, {E Function()? orElse}) { - value; - return _value.lastWhere(test, orElse: orElse); - } - - /// The first element that satisfies the given predicate [test]. - /// - /// Iterates through elements and returns the first to satisfy [test]. - /// - /// Example: - /// ```dart - /// final numbers = [1, 2, 3, 5, 6, 7]; - /// var result = numbers.firstWhere((element) => element < 5); // 1 - /// result = numbers.firstWhere((element) => element > 5); // 6 - /// result = - /// numbers.firstWhere((element) => element > 10, orElse: () => -1); // -1 - /// ``` - /// - /// If no element satisfies [test], the result of invoking the [orElse] - /// function is returned. - /// If [orElse] is omitted, it defaults to throwing a [StateError]. - /// Stops iterating on the first matching element. - @override - E firstWhere(bool Function(E value) test, {E Function()? orElse}) { - value; - return _value.firstWhere(test, orElse: orElse); - } - - /// Checks that this iterable has only one element, and returns that element. - /// - /// Throws a [StateError] if `this` is empty or has more than one element. - /// This operation will not iterate past the second element. - @override - E get single { - value; - return _value.single; - } - - /// The single element that satisfies [test]. - /// - /// Checks elements to see if `test(element)` returns true. - /// If exactly one element satisfies [test], that element is returned. - /// If more than one matching element is found, throws [StateError]. - /// If no matching element is found, returns the result of [orElse]. - /// If [orElse] is omitted, it defaults to throwing a [StateError]. - /// - /// Example: - /// ```dart - /// final numbers = [2, 2, 10]; - /// var result = numbers.singleWhere((element) => element > 5); // 10 - /// ``` - /// When no matching element is found, the result of calling [orElse] is - /// returned instead. - /// ```dart continued - /// result = numbers.singleWhere((element) => element == 1, - /// orElse: () => -1); // -1 - /// ``` - /// There must not be more than one matching element. - /// ```dart continued - /// result = numbers.singleWhere((element) => element == 2); // Throws Error. - /// ``` - @override - E singleWhere(bool Function(E value) test, {E Function()? orElse}) { - value; - return _value.singleWhere(test, orElse: orElse); - } - - /// The first element. - /// - /// Throws a [StateError] if `this` is empty. - /// Otherwise returns the first element in the iteration order, - /// equivalent to `this.elementAt(0)`. - @override - E get first { - value; - return _value.first; - } - - /// The last element. - /// - /// Throws a [StateError] if `this` is empty. - /// Otherwise may iterate through the elements and returns the last one - /// seen. - /// Some iterables may have more efficient ways to find the last element - /// (for example a list can directly access the last element, - /// without iterating through the previous ones). - @override - E get last { - value; - return _value.last; - } - - @override - String toString() => - '''SetSignal<$E>(value: $_value, previousValue: $_previousValue)'''; - - void _notifyChanged() { - _reportChanged(); - _notifySignalUpdate(); - } - - @override - // ignore: overridden_fields - final _id = ReactiveName.nameFor('SetSignal'); -} diff --git a/packages/solidart/lib/src/core/computed.dart b/packages/solidart/lib/src/core/computed.dart index 1df6260a..a852801f 100644 --- a/packages/solidart/lib/src/core/computed.dart +++ b/packages/solidart/lib/src/core/computed.dart @@ -1,257 +1,124 @@ -part of 'core.dart'; +part of '../solidart.dart'; -/// {@template computed} -/// A special Signal that notifies only whenever the selected -/// values change. +/// {@template solidart.computed} +/// # Computed +/// A computed signal derives its value from other signals. It is read-only +/// and recalculates whenever any dependency changes. /// -/// You may want to subscribe only to sub-field of a `Signal` value or to -/// combine multiple signal values. +/// Use `Computed` to derive state or combine multiple signals: /// ```dart -/// // first name signal /// final firstName = Signal('Josh'); -/// -/// // last name signal /// final lastName = Signal('Brown'); -/// -/// // derived signal, updates automatically when firstName or lastName change -/// final fullName = Computed(() => '${firstName()} ${lastName()}'); -/// -/// print(fullName()); // prints Josh Brown -/// -/// // just update the name, the effect above doesn't run because the age has not changed -/// user.update((value) => value.copyWith(name: 'new-name')); -/// -/// // just update the age, the effect above prints -/// user.update((value) => value.copyWith(age: 21)); -/// ``` -/// -/// A derived signal is not of type `Signal` but is a `ReadSignal`. -/// The difference with a normal `Signal` is that a `ReadSignal` doesn't have a -/// value setter, in other words it's a __read-only__ signal. -/// -/// You can also use derived signals in other ways, like here: -/// ```dart -/// final counter = Signal(0); -/// final doubleCounter = Computed(() => counter() * 2); +/// final fullName = Computed(() => '${firstName.value} ${lastName.value}'); /// ``` /// -/// Every time the `counter` signal changes, the doubleCounter updates with the -/// new doubled `counter` value. +/// Computeds only notify when the derived value changes. You can customize +/// equality via [equals] to skip updates for equivalent values. /// -/// You can also transform the value type like: -/// ```dart -/// final counter = Signal(0); // counter contains the value type `int` -/// final isGreaterThan5 = Computed(() => counter() > 5); // isGreaterThan5 contains the value type `bool` -/// ``` -/// -/// `isGreaterThan5` will update only when the `counter` value becomes lower/greater than `5`. -/// - If the `counter` value is `0`, `isGreaterThan5` is equal to `false`. -/// - If you update the value to `1`, `isGreaterThan5` doesn't emit a new -/// value, but still contains `false`. -/// - If you update the value to `6`, `isGreaterThan5` emits a new `true` value. +/// Like signals, computeds can track previous values once they have been read. /// {@endtemplate} -class Computed extends ReadSignal { - /// {@macro computed} +class Computed extends preset.ComputedNode + with DisposableMixin + implements ReadonlySignal { + /// {@macro solidart.computed} Computed( - this.selector, { - - /// {@macro SignalBase.name} - super.name, - - /// {@macro SignalBase.equals} - super.equals, - - /// {@macro SignalBase.autoDispose} - super.autoDispose, - - /// {@macro SignalBase.trackInDevTools} - super.trackInDevTools, - - /// {@macro SignalBase.comparator} - super.comparator = identical, - - /// {@macro SignalBase.trackPreviousValue} - super.trackPreviousValue, - }) { - var runnedOnce = false; - _internalComputed = _AlienComputed( - this, - (previousValue) { - if (trackPreviousValue && previousValue is T) { - _hasPreviousValue = true; - _untrackedPreviousValue = _previousValue = previousValue; - } - - try { - _untrackedValue = selector(); - - if (runnedOnce) { - _notifySignalUpdate(); - } else { - runnedOnce = true; - } - return _untrackedValue; - } catch (e, s) { - throw SolidartCaughtException(e, stackTrace: s); - } - }, - ); - - _notifySignalCreation(); + ValueGetter getter, { + this.equals = identical, + bool? autoDispose, + String? name, + bool? trackPreviousValue, + bool? trackInDevTools, + }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, + trackPreviousValue = + trackPreviousValue ?? SolidartConfig.trackPreviousValue, + trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, + identifier = ._(name), + super(flags: system.ReactiveFlags.none, getter: (_) => getter()) { + _notifySignalCreation(this); } - /// The selector applied - final T Function() selector; - - late final _AlienComputed _internalComputed; - - bool _disposed = false; - - late T _untrackedValue; - - T? _previousValue; - - T? _untrackedPreviousValue; - - // Whether or not there is a previous value - bool _hasPreviousValue = false; - - // Keeps track of all the callbacks passed to [onDispose]. - // Used later to fire each callback when this signal is disposed. - final _onDisposeCallbacks = []; - - // A computed signal is always initialized @override - bool get hasValue => true; - - final _deps = {}; + final bool autoDispose; @override - void dispose() { - if (_disposed) return; - - _internalComputed.dispose(); - _disposed = true; - for (final dep in _deps) { - if (dep is _AlienSignal) dep.parent._mayDispose(); - if (dep is _AlienComputed) dep.parent._mayDispose(); - } - - _deps.clear(); - - for (final cb in _onDisposeCallbacks) { - cb(); - } - _onDisposeCallbacks.clear(); - _notifySignalDisposal(); - } + final Identifier identifier; @override - T get value { - if (_disposed) { - return _untrackedValue; - } + final ValueComparator equals; - final value = reactiveSystem.getComputedValue(_internalComputed); - if (autoDispose) { - _mayDispose(); - } - - return value; - } + @override + final bool trackPreviousValue; @override - T call() { - return value; - } + final bool trackInDevTools; + + Option _previousValue = const None(); - /// Returns the previous value of the computed. @override T? get previousValue { if (!trackPreviousValue) return null; - // cause observation - if (!disposed) value; - return _previousValue; - } - - /// Returns the untracked value of the computed. - @override - T get untrackedValue { - return _untrackedValue; + value; + return _previousValue.safeUnwrap(); } - /// Returns the untracked previous value of the computed. @override T? get untrackedPreviousValue { - return _untrackedPreviousValue; + if (!trackPreviousValue) return null; + return _previousValue.safeUnwrap(); } @override - void _mayDispose() { - if (_disposed || !autoDispose) return; - if (_internalComputed.deps == null && _internalComputed.subs == null) { - dispose(); - } else { - _deps.clear(); - - var link = _internalComputed.deps; - for (; link != null; link = link.nextDep) { - final dep = link.dep; - _deps.add(dep); - } + T get untrackedValue { + if (currentValue != null || null is T) { + return currentValue as T; } + return untracked(() => value); } @override - bool get disposed => _disposed; - - @override - bool get hasPreviousValue { - if (!trackPreviousValue) return false; - // cause observation - value; - return _hasPreviousValue; + T get value { + assert(!isDisposed, 'Computed is disposed'); + return get(); } - // coverage:ignore-start @override - int get listenerCount => _deps.length; - // coverage:ignore-end + T call() => value; @override - void onDispose(VoidCallback cb) { - _onDisposeCallbacks.add(cb); - } - - // coverage:ignore-start - /// Indicates if the [oldValue] and the [newValue] are equal - @override - bool _compare(T? oldValue, T? newValue) { - // skip if the value are equals - if (equals) { - return oldValue == newValue; - } + bool didUpdate() { + preset.cycle++; + depsTail = null; + flags = system.ReactiveFlags.mutable | system.ReactiveFlags.recursedCheck; + + final prevSub = preset.setActiveSub(this); + try { + final previousValue = currentValue; + final pendingValue = getter(previousValue); + if (equals(previousValue, pendingValue)) { + return false; + } - // return the [comparator] result - return comparator(oldValue, newValue); - } - // coverage:ignore-end + if (trackPreviousValue && (previousValue is T)) { + _previousValue = Some(previousValue); + } - /// Manually runs the computed to update its value. - /// This is usually not necessary, as the computed will automatically - /// update when its dependencies change. - /// However, in some cases, you may want to force an update. - void run() { - if (_disposed) return; - _internalComputed.update(); + currentValue = pendingValue; + _notifySignalUpdate(this); + return true; + } finally { + preset.activeSub = prevSub; + flags &= ~system.ReactiveFlags.recursedCheck; + preset.purgeDeps(this); + } } @override - final _id = ReactiveName.nameFor('Computed'); - - @override - String toString() { - value; - return '''Computed<$T>(value: $untrackedValue, previousValue: $untrackedPreviousValue)'''; + void dispose() { + if (isDisposed) return; + Disposable.unlinkDeps(this); + Disposable.unlinkSubs(this); + preset.stop(this); + super.dispose(); + _notifySignalDisposal(this); } } diff --git a/packages/solidart/lib/src/core/config.dart b/packages/solidart/lib/src/core/config.dart index 18275f1b..d93aaa46 100644 --- a/packages/solidart/lib/src/core/config.dart +++ b/packages/solidart/lib/src/core/config.dart @@ -1,27 +1,44 @@ -part of 'core.dart'; +part of '../solidart.dart'; -/// {@template solidart-config} -/// The global configuration of the reactive system. +/// {@template solidart.config} +/// Global configuration for v3 reactive primitives. +/// +/// These flags provide defaults for newly created signals/effects/resources. +/// You can override them per-instance via constructor parameters. /// {@endtemplate} -abstract class SolidartConfig { - /// Whether to use the equality operator when updating the signal, defaults to - /// false - static bool equals = false; +final class SolidartConfig { + const SolidartConfig._(); // coverage:ignore-line - /// Whether to enable the auto disposal of the reactive system, defaults to - /// true. - static bool autoDispose = true; + /// Whether nodes auto-dispose when they lose all subscribers. + /// + /// When enabled, signals/computeds/effects may dispose themselves once + /// nothing depends on them. + static bool autoDispose = false; - /// Whether to enable the DevTools extension, defaults to false. - static bool devToolsEnabled = false; + /// Whether nested effects detach from parent subscriptions. + /// + /// When `true`, inner effects do not become dependencies of their parent + /// effect unless explicitly linked. + static bool detachEffects = false; - /// Whether to track the previous value of the signal, defaults to true. + /// Whether to track previous values by default. + /// + /// Previous values are captured only after a signal has been read at least + /// once. static bool trackPreviousValue = true; - /// {@macro Resource.useRefreshing} + /// Whether to keep values while refreshing resources. + /// + /// When `true`, a refresh marks the state as `isRefreshing` instead of + /// replacing it with `loading`. static bool useRefreshing = true; - // coverage:ignore-start + /// Whether DevTools tracking is enabled. + /// + /// Signals only emit DevTools events when both this flag and + /// `trackInDevTools` are `true`. + static bool devToolsEnabled = false; + /// Whether to assert that SignalBuilder has at least one dependency during /// its build. Defaults to true. /// @@ -32,35 +49,10 @@ abstract class SolidartConfig { /// where you might have a SignalBuilder that builds something based on /// disposed signals where you might be interested in their latest values. static bool assertSignalBuilderWithoutDependencies = true; - // coverage:ignore-end - /// The list of observers. + /// Registered observers for signal lifecycle events. + /// + /// Observers are notified only when `trackInDevTools` is enabled for the + /// signal instance. static final observers = []; - - /// If you want nested effects to have their own independent behavior, you can - /// set this to true so that the Reactive system creates a dependency chain - /// for nested inner effects. Defaults to false. - static bool detachEffects = false; -} - -/// {@template solidart-observer} -/// An object that listens to the changes of the reactive system. -/// -/// This can be used for logging purposes. -/// {@endtemplate} -abstract class SolidartObserver { - // coverage:ignore-start - - /// {@macro solidart-observer} - const SolidartObserver(); - // coverage:ignore-end - - /// A signal has been created. - void didCreateSignal(SignalBase signal); - - /// A signal has been updated. - void didUpdateSignal(SignalBase signal); - - /// A signal has been disposed. - void didDisposeSignal(SignalBase signal); } diff --git a/packages/solidart/lib/src/core/configuration.dart b/packages/solidart/lib/src/core/configuration.dart new file mode 100644 index 00000000..a649b585 --- /dev/null +++ b/packages/solidart/lib/src/core/configuration.dart @@ -0,0 +1,10 @@ +part of '../solidart.dart'; + +/// Base configuration shared by reactive primitives. +abstract interface class Configuration { + /// Whether the instance auto-disposes. + bool get autoDispose; + + /// Identifier for the instance. + Identifier get identifier; +} diff --git a/packages/solidart/lib/src/core/core.dart b/packages/solidart/lib/src/core/core.dart deleted file mode 100644 index 18a6a069..00000000 --- a/packages/solidart/lib/src/core/core.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'dart:async'; -import 'dart:collection'; -import 'dart:convert'; -import 'dart:developer' as dev; -import 'dart:math'; - -import 'package:alien_signals/alien_signals.dart' as alien; -import 'package:collection/collection.dart'; -import 'package:meta/meta.dart'; -import 'package:solidart/src/extensions/until.dart'; -import 'package:solidart/src/utils.dart'; - -part 'alien.dart'; -part 'batch.dart'; -part 'collections/list.dart'; -part 'collections/map.dart'; -part 'collections/set.dart'; -part 'computed.dart'; -part 'config.dart'; -part 'devtools.dart'; -part 'effect.dart'; -part 'reactive_system.dart'; -part 'read_signal.dart'; -part 'resource.dart'; -part 'signal.dart'; -part 'signal_base.dart'; -part 'extensions.dart'; -part 'untracked.dart'; diff --git a/packages/solidart/lib/src/core/devtools.dart b/packages/solidart/lib/src/core/devtools.dart index e676b57e..0c23703f 100644 --- a/packages/solidart/lib/src/core/devtools.dart +++ b/packages/solidart/lib/src/core/devtools.dart @@ -1,104 +1,184 @@ -part of 'core.dart'; +part of '../solidart.dart'; // coverage:ignore-start - -/// The type of the event emitted to the devtools -enum DevToolsEventType { - /// The signal was created - created, - - /// The signal was updated - updated, - - /// The signal was disposed - disposed, -} - -dynamic _toJson(Object? obj) { - try { - return jsonEncode(obj); - } catch (e) { - if (obj is List) { - return obj.map(_toJson).toList().toString(); - } - if (obj is Set) { - return obj.map(_toJson).toList().toString(); - } - if (obj is Map) { - return obj - .map((key, value) => MapEntry(_toJson(key), _toJson(value))) - .toString(); - } - return jsonEncode(obj.toString()); +Object? _computedValue(Computed signal) { + final current = signal.currentValue; + if (current != null || null is T) { + return current; } + return null; } +// coverage:ignore-end -/// Extension for the devtools -extension DevToolsExt on SignalBase { - void _notifySignalCreation() { - for (final obs in SolidartConfig.observers) { - obs.didCreateSignal(this); - } - if (!trackInDevTools) return; - _notifyDevToolsAboutSignal(this, eventType: DevToolsEventType.created); +// coverage:ignore-start +bool _hasPreviousValue(ReadonlySignal signal) { + if (!signal.trackPreviousValue) return false; + if (signal is Signal) { + return signal._previousValue is Some; } - - void _notifySignalUpdate() { - for (final obs in SolidartConfig.observers) { - obs.didUpdateSignal(this); - } - if (!trackInDevTools) return; - _notifyDevToolsAboutSignal(this, eventType: DevToolsEventType.updated); + if (signal is Computed) { + return signal._previousValue is Some; } + return false; +} +// coverage:ignore-end - void _notifySignalDisposal() { - for (final obs in SolidartConfig.observers) { - obs.didDisposeSignal(this); - } - if (!trackInDevTools) return; - _notifyDevToolsAboutSignal(this, eventType: DevToolsEventType.disposed); +// coverage:ignore-start +int _listenerCount(system.ReactiveNode node) { + var count = 0; + var link = node.subs; + while (link != null) { + count++; + link = link.nextSub; } + return count; } +// coverage:ignore-end void _notifyDevToolsAboutSignal( - SignalBase signal, { - required DevToolsEventType eventType, + ReadonlySignal signal, { + required _DevToolsEventType eventType, }) { if (!SolidartConfig.devToolsEnabled || !signal.trackInDevTools) return; final eventName = 'ext.solidart.signal.${eventType.name}'; - var value = signal.value; - var previousValue = signal.previousValue; - if (signal is Resource) { - value = signal._value.asReady?.value; - previousValue = signal._previousValue?.asReady?.value; - } - final jsonValue = _toJson(value); - final jsonPreviousValue = _toJson(previousValue); + final value = _signalValue(signal); + final previousValue = _signalPreviousValue(signal); + final hasPreviousValue = _hasPreviousValue(signal); dev.postEvent(eventName, { - '_id': signal._id, - 'name': signal.name, - 'value': jsonValue, - 'previousValue': jsonPreviousValue, - 'hasPreviousValue': signal.hasPreviousValue, - 'type': switch (signal) { - Resource() => 'Resource', - ListSignal() => 'ListSignal', - MapSignal() => 'MapSignal', - SetSignal() => 'SetSignal', - Signal() => 'Signal', - Computed() => 'Computed', - ReadableSignal() => 'ReadSignal', - _ => 'Unknown', - }, + '_id': signal.identifier.value.toString(), + 'name': signal.identifier.name, + 'value': _toJson(value), + 'previousValue': _toJson(previousValue), + 'hasPreviousValue': hasPreviousValue, + 'type': _signalType(signal), 'valueType': value.runtimeType.toString(), - if (signal.hasPreviousValue) + if (hasPreviousValue) 'previousValueType': previousValue.runtimeType.toString(), - 'disposed': signal.disposed, + 'disposed': signal.isDisposed, 'autoDispose': signal.autoDispose, - 'listenerCount': signal.listenerCount, + 'listenerCount': _listenerCount(signal), 'lastUpdate': DateTime.now().toIso8601String(), }); } +void _notifySignalCreation(ReadonlySignal signal) { + if (signal.trackInDevTools && SolidartConfig.observers.isNotEmpty) { + for (final observer in SolidartConfig.observers) { + observer.didCreateSignal(signal); + } + } + _notifyDevToolsAboutSignal(signal, eventType: _DevToolsEventType.created); +} + +void _notifySignalDisposal(ReadonlySignal signal) { + if (signal.trackInDevTools && SolidartConfig.observers.isNotEmpty) { + for (final observer in SolidartConfig.observers) { + observer.didDisposeSignal(signal); + } + } + _notifyDevToolsAboutSignal(signal, eventType: _DevToolsEventType.disposed); +} + +void _notifySignalUpdate(ReadonlySignal signal) { + if (signal.trackInDevTools && SolidartConfig.observers.isNotEmpty) { + for (final observer in SolidartConfig.observers) { + observer.didUpdateSignal(signal); + } + } + _notifyDevToolsAboutSignal(signal, eventType: _DevToolsEventType.updated); +} + +Object? _resourceValue(ResourceState? state) { + if (state == null) return null; + return state.maybeWhen(orElse: () => null, ready: (value) => value); +} + +Object? _signalPreviousValue(ReadonlySignal signal) { + if (signal is Resource) { + return _resourceValue(signal.untrackedPreviousState); + } + return signal.untrackedPreviousValue; +} + +String _signalType(ReadonlySignal signal) => switch (signal) { + Resource() => 'Resource', + ListSignal() => 'ListSignal', + MapSignal() => 'MapSignal', + SetSignal() => 'SetSignal', + LazySignal() => 'LazySignal', + Signal() => 'Signal', + Computed() => 'Computed', + _ => 'ReadonlySignal', +}; + +Object? _signalValue(ReadonlySignal signal) { + if (signal is Resource) { + return _resourceValue(signal.untrackedState); + } + if (signal is LazySignal && !signal.isInitialized) { + return null; + } + if (signal is Computed) { + return _computedValue(signal); + } + return signal.untrackedValue; +} + +// coverage:ignore-start +dynamic _toJson(Object? obj, [int depth = 0, Set? visited]) { + const maxDepth = 20; + if (depth > maxDepth) return ''; + try { + return jsonEncode(obj); + } catch (_) { + if (obj is List) { + final visitedSet = visited ?? Set.identity(); + if (!visitedSet.add(obj)) return ''; + try { + return obj + .map((e) => _toJson(e, depth + 1, visitedSet)) + .toList() + .toString(); + } finally { + visitedSet.remove(obj); + } + } + if (obj is Set) { + final visitedSet = visited ?? Set.identity(); + if (!visitedSet.add(obj)) return ''; + try { + return obj + .map((e) => _toJson(e, depth + 1, visitedSet)) + .toList() + .toString(); + } finally { + visitedSet.remove(obj); + } + } + if (obj is Map) { + final visitedSet = visited ?? Set.identity(); + if (!visitedSet.add(obj)) return ''; + try { + return obj + .map( + (key, value) => MapEntry( + _toJson(key, depth + 1, visitedSet), + _toJson(value, depth + 1, visitedSet), + ), + ) + .toString(); + } finally { + visitedSet.remove(obj); + } + } + return jsonEncode(obj.toString()); + } +} // coverage:ignore-end + +enum _DevToolsEventType { + created, + updated, + disposed, +} diff --git a/packages/solidart/lib/src/core/disposable.dart b/packages/solidart/lib/src/core/disposable.dart new file mode 100644 index 00000000..f99aa04f --- /dev/null +++ b/packages/solidart/lib/src/core/disposable.dart @@ -0,0 +1,90 @@ +part of '../solidart.dart'; + +/// Disposable behavior for reactive primitives. +abstract class Disposable { + /// Whether this instance has been disposed. + bool get isDisposed; + + /// Disposes the instance. + void dispose(); + + /// Registers a callback to run on dispose. + void onDispose(VoidCallback callback); + + /// Whether the node can be auto-disposed. + static bool canAutoDispose(system.ReactiveNode node) => switch (node) { + Disposable(:final isDisposed) && Configuration(:final autoDispose) => + !isDisposed && autoDispose, + _ => false, + }; + + /// Unlinks dependencies from a node. + /// + /// This is used to break reactive links during disposal. + static void unlinkDeps(system.ReactiveNode node) { + var link = node.deps; + while (link != null) { + final next = link.nextDep; + final dep = link.dep; + final isLastSub = + identical(dep.subs, link) && + link.prevSub == null && + link.nextSub == null; + if (canAutoDispose(dep) && isLastSub) { + (dep as Disposable).dispose(); + } else { + preset.unlink(link, node); + if (canAutoDispose(dep) && dep.subs == null) { + (dep as Disposable).dispose(); + } + } + link = next; + } + } + + /// Unlinks subscribers from a node. + /// + /// This is used to break reactive links during disposal. + static void unlinkSubs(system.ReactiveNode node) { + var link = node.subs; + while (link != null) { + final next = link.nextSub; + final sub = link.sub; + preset.unlink(link, sub); + if (canAutoDispose(sub) && sub.deps == null) { + (sub as Disposable).dispose(); + } + link = next; + } + } +} + +/// Default [Disposable] implementation using cleanup callbacks. +mixin DisposableMixin implements Disposable { + @internal + /// Registered cleanup callbacks invoked on dispose. + late final cleanups = []; + + @override + bool isDisposed = false; + + @mustCallSuper + @override + void dispose() { + if (isDisposed) return; + isDisposed = true; + try { + for (final callback in cleanups) { + callback(); + } + } finally { + cleanups.clear(); + } + } + + @mustCallSuper + @override + void onDispose(VoidCallback callback) { + cleanups.add(callback); + } +} diff --git a/packages/solidart/lib/src/core/effect.dart b/packages/solidart/lib/src/core/effect.dart index 16b2eba4..84b2e7fc 100644 --- a/packages/solidart/lib/src/core/effect.dart +++ b/packages/solidart/lib/src/core/effect.dart @@ -1,240 +1,103 @@ -part of 'core.dart'; +part of '../solidart.dart'; -/// Dispose function -typedef DisposeEffect = void Function(); - -/// The reaction interface -abstract class ReactionInterface { - /// Indicate if the reaction is dispose - bool get disposed; - - /// Tries to dispose the effects, if no listeners are present - // ignore: unused_element - void _mayDispose(); - - /// Disposes the reaction - void dispose(); -} - -/// {@template effect} -/// Signals are trackable values, but they are only one half of the equation. -/// To complement those are observers that can be updated by those trackable -/// values. An effect is one such observer; it runs a side effect that depends -/// on signals. -/// -/// An effect can be created by using `Effect`. -/// The effect subscribes automatically to any signal used in the callback and -/// reruns when any of them change. +/// {@template solidart.effect} +/// # Effect +/// Effects run a side-effect whenever any signal they read changes. /// -/// So let's create an `Effect` that reruns whenever `counter` changes: /// ```dart -/// // sample signal /// final counter = Signal(0); -/// -/// // effect creation /// Effect(() { -/// print("The count is now ${counter.value}"); -/// }); -/// // The effect prints `The count is now 0`; -/// -/// // increment the counter -/// counter.value++; -/// -/// // The effect prints `The count is now 1`; -/// ``` -/// -/// The `Effect` method returns a `Dispose` class giving you a more -/// advanced usage: -/// ```dart -/// final dispose = Effect(() { -/// print("The count is now ${counter.value}"); +/// print('count: ${counter.value}'); /// }); /// ``` /// -/// Whenever you want to stop the effect from running, you just have to call -/// the returned callback of the `Effect` method: -/// ```dart -/// final disposeEffect = Effect(() { /* your code */ }); -/// // later -/// disposeEffect(); // this will stop the effect from running -/// ``` +/// Effects run once immediately when created. If you need a lazy effect, +/// create it with [Effect.manual] and call [run] yourself. /// -/// Any effect runs at least once immediately when is created with the current -/// signals values. +/// Nested effects can either attach to their parent (default) or detach by +/// passing `detach: true` or by enabling [SolidartConfig.detachEffects]. /// -/// > An effect is useless after it is disposed, you must not use it anymore. +/// Call [dispose] to stop the effect and release dependencies. /// {@endtemplate} -class Effect implements ReactionInterface { - /// {@macro effect} +class Effect extends preset.EffectNode + with DisposableMixin + implements Disposable, Configuration { + /// {@macro solidart.effect} factory Effect( - void Function() callback, { - ErrorCallback? onError, - - /// The name of the effect, useful for logging - String? name, - - /// Delay each effect reaction - Duration? delay, - - /// Whether to automatically dispose the effect (defaults to true). - /// - /// This happens automatically when all the tracked dependencies are - /// disposed. + VoidCallback callback, { bool? autoDispose, - - /// Detach effect, default value is [SolidartConfig.detachEffects] + String? name, bool? detach, - - /// Whether to automatically run the effect (defaults to true). - bool? autorun, - }) { - late Effect effect; - - try { - final effectiveName = name ?? ReactiveName.nameFor('Effect'); - final effectiveAutoDispose = autoDispose ?? SolidartConfig.autoDispose; - if (delay == null) { - effect = Effect._internal( - callback: () => callback(), - onError: onError, - name: effectiveName, - autoDispose: effectiveAutoDispose, - detach: detach, - ); - } else { - final scheduler = createDelayedScheduler(delay); - var isScheduled = false; - Timer? timer; - - effect = Effect._internal( - callback: () { - if (!isScheduled) { - isScheduled = true; - - // coverage:ignore-start - timer?.cancel(); - // coverage:ignore-end - timer = null; - - timer = scheduler(() { - isScheduled = false; - if (!effect.disposed) { - callback(); - } else { - // coverage:ignore-start - timer?.cancel(); - // coverage:ignore-end - } - }); - } - }, - onError: onError, - name: effectiveName, - autoDispose: effectiveAutoDispose, - detach: detach, - ); - } - return effect; - } finally { - if (autorun ?? true) effect.run(); - } - } - - /// {@macro effect} - Effect._internal({ - required VoidCallback callback, - required this.name, - required this.autoDispose, - ErrorCallback? onError, + }) => .manual( + callback, + autoDispose: autoDispose, + name: name, + detach: detach, + )..run(); + + /// Creates an effect without running it. + /// + /// Use this when you need to *delay* the first run or decide *when* the + /// effect should start tracking dependencies. Common cases: + /// - you must create several signals first and only then start the effect + /// - you want to control the first run in tests + /// - you need conditional startup (e.g. after async setup) + /// + /// The effect will not track anything until you call [run]: + /// ```dart + /// final count = Signal(0); + /// final effect = Effect.manual(() { + /// print('count: ${count.value}'); + /// }); + /// + /// count.value = 1; // no output yet + /// effect.run(); // prints "count: 1" and starts tracking + /// ``` + /// + /// If you want the effect to run immediately, use the [Effect] factory. + Effect.manual( + VoidCallback callback, { + bool? autoDispose, + String? name, bool? detach, - }) : _onError = onError { - _internalEffect = _AlienEffect(this, callback, detach: detach); - } - - /// The name of the effect, useful for logging purposes. - final String name; + }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, + identifier = ._(name), + detach = detach ?? SolidartConfig.detachEffects, + super( + fn: callback, + flags: + system.ReactiveFlags.watching | system.ReactiveFlags.recursedCheck, + ); - /// Whether to automatically dispose the effect (defaults to true). + @override final bool autoDispose; - /// Optionally handle the error case - final ErrorCallback? _onError; - - bool _disposed = false; - - late final _AlienEffect _internalEffect; - - final _deps = {}; + @override + final Identifier identifier; - /// The subscriber of the effect, do not use it directly. - @protected - alien.ReactiveNode get subscriber => _internalEffect; + /// Whether this effect detaches from parent subscriptions. + final bool detach; @override - bool get disposed => _disposed; + void dispose() { + if (isDisposed) return; + Disposable.unlinkDeps(this); + preset.stop(this); + super.dispose(); + } - /// Runs the effect, tracking any signal read during the execution. + /// Runs the effect and tracks dependencies. void run() { - final currentSub = reactiveSystem.activeSub; - if (!SolidartConfig.detachEffects && currentSub != null) { - if (currentSub is! _AlienEffect || - (!_internalEffect.detach && !currentSub.detach)) { - reactiveSystem.link(_internalEffect, currentSub); - } + final prevSub = preset.setActiveSub(this); + if (!detach && prevSub != null) { + preset.link(this, prevSub, 0); } - final prevSub = reactiveSystem.setCurrentSub(_internalEffect); try { - _internalEffect.run(); - } catch (e, s) { - if (_onError != null) { - _onError.call(SolidartCaughtException(e, stackTrace: s)); - } else { - rethrow; - } + fn(); } finally { - reactiveSystem.setCurrentSub(prevSub); - if (SolidartConfig.autoDispose) { - _mayDispose(); - } - } - } - - /// Sets the dependencies of the effect, do not use it directly. - @internal - void setDependencies(alien.ReactiveNode node) { - _deps - ..clear() - ..addAll(node.getDependencies()); - } - - /// Invalidates the effect. - /// - /// After this operation the effect is useless. - void call() => dispose(); - - /// Invalidates the effect. - /// - /// After this operation the effect is useless. - @override - void dispose() { - if (_disposed) return; - _disposed = true; - - final dependencies = {...subscriber.getDependencies(), ..._deps}; - _internalEffect.dispose(); - subscriber.mayDisposeDependencies(dependencies); - } - - @override - void _mayDispose() { - if (_disposed) return; - - if (SolidartConfig.autoDispose) { - if (!autoDispose || _disposed) return; - if (subscriber.deps?.dep == null) { - dispose(); - } + preset.activeSub = prevSub; + flags &= ~system.ReactiveFlags.recursedCheck; } } } diff --git a/packages/solidart/lib/src/core/extensions.dart b/packages/solidart/lib/src/core/extensions.dart deleted file mode 100644 index 9081e537..00000000 --- a/packages/solidart/lib/src/core/extensions.dart +++ /dev/null @@ -1,34 +0,0 @@ -part of 'core.dart'; - -/// Adds the [toggle] method to boolean signals -extension ToggleBoolSignal on Signal { - /// Toggles the signal boolean value. - void toggle() => value = !value; -} - -/// A callback that is fired when the signal value changes -extension ObserveSignal on SignalBase { - /// Observe the signal and trigger the [listener] every time the value changes - DisposeObservation observe( - ObserveCallback listener, { - bool fireImmediately = false, - }) { - var skipped = false; - final disposeEffect = Effect(() { - // Tracks the value - value; - if (!fireImmediately && !skipped) { - skipped = true; - return; - } - untracked(() { - listener(untrackedPreviousValue, untrackedValue); - }); - }); - - return () { - disposeEffect(); - _mayDispose(); - }; - } -} diff --git a/packages/solidart/lib/src/core/identifier.dart b/packages/solidart/lib/src/core/identifier.dart new file mode 100644 index 00000000..cc730f2b --- /dev/null +++ b/packages/solidart/lib/src/core/identifier.dart @@ -0,0 +1,15 @@ +part of '../solidart.dart'; + +/// A unique identifier with an optional name. +/// +/// Used by DevTools and diagnostics to track instances. +class Identifier { + Identifier._(this.name) : value = _counter++; + static int _counter = 0; + + /// Optional human-readable name. + final String? name; + + /// Unique numeric identifier. + final int value; +} diff --git a/packages/solidart/lib/src/core/lazy_signal.dart b/packages/solidart/lib/src/core/lazy_signal.dart new file mode 100644 index 00000000..c80ce154 --- /dev/null +++ b/packages/solidart/lib/src/core/lazy_signal.dart @@ -0,0 +1,57 @@ +part of '../solidart.dart'; + +/// A signal that starts uninitialized until first set. +/// +/// This is the concrete type behind [Signal.lazy]. Reading [value] before the +/// first assignment throws [StateError]. +/// +/// ```dart +/// final lazy = Signal.lazy(); +/// lazy.value = 1; +/// print(lazy.value); // 1 +/// ``` +class LazySignal extends Signal { + /// Creates a lazy signal. + LazySignal({ + String? name, + bool? autoDispose, + ValueComparator equals = identical, + bool? trackPreviousValue, + bool? trackInDevTools, + }) : super._internal( + const None(), + name: name, + autoDispose: autoDispose, + equals: equals, + trackPreviousValue: trackPreviousValue, + trackInDevTools: trackInDevTools, + ); + + @override + bool get isInitialized => currentValue is Some; + + @override + T get value { + if (isInitialized || pendingValue is Some) { + return super.value; + } + throw StateError( + 'LazySignal is not initialized, please call `.value = ` first.', + ); + } + + @override + bool didUpdate() { + if (!isInitialized) { + flags = system.ReactiveFlags.mutable; + if (trackPreviousValue) { + _previousValue = const None(); + } + currentValue = pendingValue; + _notifySignalUpdate(this); + return true; + } + + return super.didUpdate(); + } +} diff --git a/packages/solidart/lib/src/core/list_signal.dart b/packages/solidart/lib/src/core/list_signal.dart new file mode 100644 index 00000000..8f0a0e3f --- /dev/null +++ b/packages/solidart/lib/src/core/list_signal.dart @@ -0,0 +1,204 @@ +part of '../solidart.dart'; + +/// {@template solidart.list-signal} +/// A reactive wrapper around a [List] that copies on write. +/// +/// Mutations create a new list instance so that updates are observable: +/// ```dart +/// final list = ListSignal([1, 2]); +/// Effect(() => print(list.length)); +/// list.add(3); // triggers effect +/// ``` +/// +/// Reads (like `length` or index access) establish dependencies; the usual +/// list API is supported. +/// {@endtemplate} +class ListSignal extends Signal> with ListMixin { + /// {@macro solidart.list-signal} + /// + /// Creates a reactive list with the provided initial values. + ListSignal( + Iterable initialValue, { + bool? autoDispose, + String? name, + ValueComparator> equals = identical, + bool? trackPreviousValue, + bool? trackInDevTools, + }) : super( + List.of(initialValue), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: trackPreviousValue, + trackInDevTools: trackInDevTools, + ); + + @override + int get length => value.length; + + @override + set length(int newLength) { + final current = untrackedValue; + if (current.length == newLength) return; + value = List.of(current)..length = newLength; + } + + @override + E operator [](int index) => value[index]; + + @override + void operator []=(int index, E element) { + final current = untrackedValue; + if (current[index] == element) return; + final next = List.of(current); + next[index] = element; + value = next; + } + + @override + void add(E element) { + final next = _copy()..add(element); + value = next; + } + + @override + void addAll(Iterable iterable) { + if (iterable.isEmpty) return; + final next = _copy()..addAll(iterable); + value = next; + } + + @override + List cast() => ListSignal(untrackedValue.cast()); + + @override + void clear() { + if (untrackedValue.isEmpty) return; + value = []; + } + + @override + void fillRange(int start, int end, [E? fill]) { + if (end <= start) return; + final next = _copy()..fillRange(start, end, fill); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + + @override + void insert(int index, E element) { + final next = _copy()..insert(index, element); + value = next; + } + + @override + void insertAll(int index, Iterable iterable) { + if (iterable.isEmpty) return; + final next = _copy()..insertAll(index, iterable); + value = next; + } + + @override + bool remove(Object? element) { + final current = untrackedValue; + final index = current.indexWhere((value) => value == element); + if (index == -1) return false; + final next = List.of(current)..removeAt(index); + value = next; + return true; + } + + @override + E removeAt(int index) { + final current = untrackedValue; + final removed = current[index]; + final next = List.of(current)..removeAt(index); + value = next; + return removed; + } + + @override + E removeLast() { + final current = untrackedValue; + final removed = current.last; + final next = List.of(current)..removeLast(); + value = next; + return removed; + } + + @override + void removeRange(int start, int end) { + if (end <= start) return; + final next = _copy()..removeRange(start, end); + value = next; + } + + @override + void removeWhere(bool Function(E element) test) { + final current = untrackedValue; + final next = List.of(current)..removeWhere(test); + if (next.length == current.length) return; + value = next; + } + + @override + void replaceRange(int start, int end, Iterable newContents) { + final next = _copy()..replaceRange(start, end, newContents); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + + @override + void retainWhere(bool Function(E element) test) { + final current = untrackedValue; + final next = List.of(current)..retainWhere(test); + if (next.length == current.length) return; + value = next; + } + + @override + void setAll(int index, Iterable iterable) { + final next = _copy()..setAll(index, iterable); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + + @override + void setRange(int start, int end, Iterable iterable, [int skipCount = 0]) { + final next = _copy()..setRange(start, end, iterable, skipCount); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + + @override + void shuffle([Random? random]) { + if (untrackedValue.length < 2) return; + final next = _copy()..shuffle(random); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + + @override + void sort([int Function(E a, E b)? compare]) { + if (untrackedValue.length < 2) return; + final next = _copy()..sort(compare); + if (_listEquals(untrackedValue, next)) return; + value = next; + } + + @override + String toString() => + 'ListSignal<$E>(value: $untrackedValue, ' + 'previousValue: $untrackedPreviousValue)'; + + List _copy() => List.of(untrackedValue); + + bool _listEquals(List a, List b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} diff --git a/packages/solidart/lib/src/core/map_signal.dart b/packages/solidart/lib/src/core/map_signal.dart new file mode 100644 index 00000000..dd16076b --- /dev/null +++ b/packages/solidart/lib/src/core/map_signal.dart @@ -0,0 +1,193 @@ +part of '../solidart.dart'; + +/// {@template solidart.map-signal} +/// A reactive wrapper around a [Map] that copies on write. +/// +/// Mutations create a new map instance so that updates are observable: +/// ```dart +/// final map = MapSignal({'a': 1}); +/// Effect(() => print(map['a'])); +/// map['a'] = 2; // triggers effect +/// ``` +/// +/// Reads (like `[]`, `keys`, or `length`) establish dependencies. +/// {@endtemplate} +class MapSignal extends Signal> with MapMixin { + /// {@macro solidart.map-signal} + /// + /// Creates a reactive map with the provided initial values. + MapSignal( + Map initialValue, { + bool? autoDispose, + String? name, + ValueComparator> equals = identical, + bool? trackPreviousValue, + bool? trackInDevTools, + }) : super( + Map.of(initialValue), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: trackPreviousValue, + trackInDevTools: trackInDevTools, + ); + + @override + bool get isEmpty { + value; + return untrackedValue.isEmpty; + } + + @override + bool get isNotEmpty { + value; + return untrackedValue.isNotEmpty; + } + + @override + Iterable get keys { + value; + return untrackedValue.keys; + } + + @override + int get length { + value; + return untrackedValue.length; + } + + @override + V? operator [](Object? key) { + value; + return untrackedValue[key]; + } + + @override + void operator []=(K key, V value) { + final current = untrackedValue; + final existing = current[key]; + if (current.containsKey(key) && existing == value) return; + final next = _copy(); + next[key] = value; + this.value = next; + } + + @override + void addAll(Map other) { + if (other.isEmpty) return; + final current = untrackedValue; + final next = _copy()..addAll(other); + if (_mapEquals(next, current)) return; + value = next; + } + + @override + Map cast() => + MapSignal(untrackedValue.cast()); + + @override + void clear() { + if (untrackedValue.isEmpty) return; + value = {}; + } + + @override + bool containsKey(Object? key) { + value; + return untrackedValue.containsKey(key); + } + + @override + bool containsValue(Object? value) { + this.value; + return untrackedValue.containsValue(value); + } + + @override + V putIfAbsent(K key, V Function() ifAbsent) { + final current = untrackedValue; + if (current.containsKey(key)) { + return current[key] as V; + } + final next = _copy(); + final value = ifAbsent(); + next[key] = value; + this.value = next; + return value; + } + + @override + V? remove(Object? key) { + final current = untrackedValue; + if (!current.containsKey(key)) return null; + final next = _copy(); + final removed = next.remove(key); + value = next; + return removed; + } + + @override + void removeWhere(bool Function(K key, V value) test) { + final current = untrackedValue; + if (current.isEmpty) return; + final next = _copy()..removeWhere(test); + if (next.length == current.length) return; + value = next; + } + + @override + String toString() => + 'MapSignal<$K, $V>(value: $untrackedValue, ' + 'previousValue: $untrackedPreviousValue)'; + + @override + V update( + K key, + V Function(V value) update, { + V Function()? ifAbsent, + }) { + final current = untrackedValue; + if (!current.containsKey(key)) { + if (ifAbsent == null) { + throw ArgumentError.value(key, 'key', 'Key not in map.'); + } + final next = _copy(); + final value = ifAbsent(); + next[key] = value; + this.value = next; + return value; + } + + final next = _copy(); + final value = update(next[key] as V); + next[key] = value; + this.value = next; + return value; + } + + @override + void updateAll(V Function(K key, V value) update) { + final current = untrackedValue; + if (current.isEmpty) return; + final next = _copy()..updateAll(update); + if (next.length == current.length && + next.keys.every((key) { + return current.containsKey(key) && current[key] == next[key]; + })) { + return; + } + value = next; + } + + Map _copy() => Map.of(untrackedValue); + + bool _mapEquals(Map a, Map b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (final entry in a.entries) { + if (!b.containsKey(entry.key)) return false; + if (b[entry.key] != entry.value) return false; + } + return true; + } +} diff --git a/packages/solidart/lib/src/core/observer.dart b/packages/solidart/lib/src/core/observer.dart new file mode 100644 index 00000000..c80272ea --- /dev/null +++ b/packages/solidart/lib/src/core/observer.dart @@ -0,0 +1,34 @@ +part of '../solidart.dart'; + +/// {@template solidart.observer} +/// Observer for signal lifecycle events. +/// +/// Use this for logging or instrumentation without depending on DevTools: +/// ```dart +/// class Logger extends SolidartObserver { +/// @override +/// void didCreateSignal(ReadonlySignal signal) { +/// print('created: ${signal.identifier.value}'); +/// } +/// @override +/// void didUpdateSignal(ReadonlySignal signal) {} +/// @override +/// void didDisposeSignal(ReadonlySignal signal) {} +/// } +/// +/// SolidartConfig.observers.add(Logger()); +/// ``` +/// {@endtemplate} +abstract class SolidartObserver { + /// {@macro solidart.observer} + const SolidartObserver(); // coverage:ignore-line + + /// Called when a signal is created. + void didCreateSignal(ReadonlySignal signal); + + /// Called when a signal is disposed. + void didDisposeSignal(ReadonlySignal signal); + + /// Called when a signal updates. + void didUpdateSignal(ReadonlySignal signal); +} diff --git a/packages/solidart/lib/src/core/option.dart b/packages/solidart/lib/src/core/option.dart new file mode 100644 index 00000000..2faafe3f --- /dev/null +++ b/packages/solidart/lib/src/core/option.dart @@ -0,0 +1,37 @@ +part of '../solidart.dart'; + +/// An absent optional value. +final class None extends Option { + /// Creates an option with no value. + const None(); +} + +/// An optional value container. +/// +/// Use [Some] to represent presence and [None] to represent absence without +/// relying on `null`. +sealed class Option { + /// Base constructor for option values. + const Option(); + + /// Returns the contained value or `null` if this is [None]. + T? safeUnwrap() => switch (this) { + Some(:final value) => value, + _ => null, + }; + + /// Returns the contained value or throws if this is [None]. + T unwrap() => switch (this) { + Some(:final value) => value, + _ => throw StateError('Option is None'), + }; +} + +/// A present optional value. +final class Some extends Option { + /// Creates an option that wraps [value]. + const Some(this.value); + + /// The wrapped value. + final T value; +} diff --git a/packages/solidart/lib/src/core/reactive_system.dart b/packages/solidart/lib/src/core/reactive_system.dart deleted file mode 100644 index 25d88943..00000000 --- a/packages/solidart/lib/src/core/reactive_system.dart +++ /dev/null @@ -1,203 +0,0 @@ -// ignore_for_file: public_member_api_docs, library_private_types_in_public_api -// -// 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() { - var link = deps; - final foundDeps = {}; - for (; link != null; link = link.nextDep) { - foundDeps.add(link.dep); - } - return foundDeps; - } - - void mayDisposeDependencies([Iterable? include]) { - final dependencies = {...getDependencies(), ...?include}; - for (final dep in dependencies) { - switch (dep) { - case _AlienSignal(): - dep.parent._mayDispose(); - case _AlienComputed(): - dep.parent._mayDispose(); - } - } - } -} - -typedef ReactionErrorHandler = - void Function(Object error, ReactionInterface reaction); - -class ReactiveName { - ReactiveName._internal(); - static final _instance = ReactiveName._internal(); - - int nextIdCounter = 0; - - int get nextId => ++nextIdCounter; - - static String nameFor(String prefix) { - assert(prefix.isNotEmpty, 'the prefix cannot be empty'); - return '$prefix@${_instance.nextId}'; - } -} - -@protected -final reactiveSystem = ReactiveSystem(); - -class ReactiveSystem extends alien.ReactiveSystem { - int batchDepth = 0; - alien.ReactiveNode? activeSub; - _AlienEffect? queuedEffects; - _AlienEffect? queuedEffectsTail; - - @override - void notify(alien.ReactiveNode node) { - final flags = node.flags; - if ((flags & 64 /* Queued */ ) == 0) { - node.flags = flags | 64 /* Queued */; - final subs = node.subs; - if (subs != null) { - notify(subs.sub); - } else if (queuedEffectsTail != null) { - queuedEffectsTail = queuedEffectsTail!.nextEffect = - node as _AlienEffect; - } else { - queuedEffectsTail = queuedEffects = node as _AlienEffect; - } - } - } - - @override - void unwatched(alien.ReactiveNode node) { - if (node is _AlienComputed) { - var toRemove = node.deps; - if (toRemove != null) { - node.flags = 17 /* Mutable | Dirty */; - do { - toRemove = unlink(toRemove!, node); - } while (toRemove != null); - } - } else if (node is! _AlienSignal) { - stopEffect(node); - } - } - - @override - bool update(alien.ReactiveNode node) { - assert( - node is _AlienUpdatable, - 'Reactive node type must be signal or computed', - ); - return (node as _AlienUpdatable).update(); - } - - void startBatch() => ++batchDepth; - void endBatch() { - if ((--batchDepth) == 0) flush(); - } - - alien.ReactiveNode? setCurrentSub(alien.ReactiveNode? sub) { - final prevSub = activeSub; - activeSub = sub; - return prevSub; - } - - T getComputedValue(_AlienComputed computed) { - final flags = computed.flags; - if ((flags & 16 /* Dirty */ ) != 0 || - ((flags & 32 /* Pending */ ) != 0 && - computed.deps != null && - checkDirty(computed.deps!, computed))) { - if (computed.update()) { - final subs = computed.subs; - if (subs != null) shallowPropagate(subs); - } - } else if ((flags & 32 /* Pending */ ) != 0) { - computed.flags = flags & -33 /* ~Pending */; - } - if (activeSub != null) { - link(computed, activeSub!); - } - - return computed.value as T; - } - - Option getSignalValue(_AlienSignal signal) { - final value = signal.value; - if ((signal.flags & 16 /* Dirty */ ) != 0) { - if (signal.update()) { - final subs = signal.subs; - if (subs != null) shallowPropagate(subs); - } - } - - if (activeSub != null) link(signal, activeSub!); - return value; - } - - void setSignalValue(_AlienSignal signal, Option value) { - if (signal.value != (signal.value = value)) { - signal.flags = 17 /* Mutable | Dirty */; - final subs = signal.subs; - if (subs != null) { - propagate(subs); - if (batchDepth == 0) flush(); - } - } - } - - void stopEffect(alien.ReactiveNode effect) { - assert(effect is! _AlienSignal, 'Reactive node type not matched'); - var dep = effect.deps; - while (dep != null) { - dep = unlink(dep, effect); - } - - final sub = effect.subs; - if (sub != null) unlink(sub, effect); - effect.flags = 0 /* None */; - } - - void run(alien.ReactiveNode effect, int flags) { - if ((flags & 16 /* Dirty */ ) != 0 || - ((flags & 32 /* Pending */ ) != 0 && - effect.deps != null && - checkDirty(effect.deps!, effect))) { - final prevSub = setCurrentSub(effect); - startTracking(effect); - try { - (effect as _AlienEffect).run(); - } finally { - activeSub = prevSub; - endTracking(effect); - } - return; - } else if ((flags & 32 /* Pending */ ) != 0) { - effect.flags = flags & -33 /* ~Pending */; - } - var link = effect.deps; - while (link != null) { - final dep = link.dep; - final depFlags = dep.flags; - if ((depFlags & 64 /* Queued */ ) != 0) { - run(dep, dep.flags = depFlags & -65 /* ~Queued */); - } - link = link.nextDep; - } - } - - void flush() { - while (queuedEffects != null) { - final effect = queuedEffects!; - if ((queuedEffects = effect.nextEffect) != null) { - effect.nextEffect = null; - } else { - queuedEffectsTail = null; - } - - run(effect, effect.flags &= -65 /* ~Queued */); - } - } -} diff --git a/packages/solidart/lib/src/core/read_signal.dart b/packages/solidart/lib/src/core/read_signal.dart deleted file mode 100644 index 9f1b9cec..00000000 --- a/packages/solidart/lib/src/core/read_signal.dart +++ /dev/null @@ -1,372 +0,0 @@ -part of 'core.dart'; - -/// {@macro readsignal} -abstract class ReadSignal extends SignalBase { - /// {@macro readsignal} - ReadSignal({ - required super.name, - super.comparator, - super.equals, - super.autoDispose, - super.trackInDevTools, - super.trackPreviousValue, - }); -} - -/// {@template readsignal} -/// A read-only [Signal]. -/// -/// When you don't need to expose the setter of a [Signal], -/// you should consider transforming it in a [ReadSignal] -/// using the `toReadSignal` method. -/// -/// All derived-signals are [ReadableSignal]s because they depend -/// on the value of a [Signal]. -/// {@endtemplate} -class ReadableSignal implements ReadSignal { - /// {@macro readsignal} - ReadableSignal( - T initialValue, { - - /// {@macro SignalBase.name} - this.name, - - /// {@macro SignalBase.equals} - bool? equals, - - /// {@macro SignalBase.autoDispose} - bool? autoDispose, - - /// {@macro SignalBase.trackInDevTools} - bool? trackInDevTools, - - /// {@macro SignalBase.comparator} - this.comparator = identical, - - /// {@macro SignalBase.trackPreviousValue} - bool? trackPreviousValue, - }) : _hasValue = true, - trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, - autoDispose = autoDispose ?? SolidartConfig.autoDispose, - equals = equals ?? SolidartConfig.equals, - trackPreviousValue = - trackPreviousValue ?? SolidartConfig.trackPreviousValue { - _internalSignal = _AlienSignal(this, Some(initialValue)); - _untrackedValue = initialValue; - _notifySignalCreation(); - } - - /// {@macro readsignal} - ReadableSignal.lazy({ - /// {@macro SignalBase.name} - this.name, - - /// {@macro SignalBase.equals} - bool? equals, - - /// {@macro SignalBase.autoDispose} - bool? autoDispose, - - /// {@macro SignalBase.trackInDevTools} - bool? trackInDevTools, - - /// {@macro SignalBase.comparator} - this.comparator = identical, - - /// {@macro SignalBase.trackPreviousValue} - bool? trackPreviousValue, - }) : _hasValue = false, - trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, - autoDispose = autoDispose ?? SolidartConfig.autoDispose, - equals = equals ?? SolidartConfig.equals, - // coverage:ignore-start - // coverage:ignore-end - trackPreviousValue = - trackPreviousValue ?? SolidartConfig.trackPreviousValue { - _internalSignal = _AlienSignal(this, None()); - } - - /// {@macro SignalBase.name} - @override - final String? name; - - /// {@macro SignalBase.equals} - @override - final bool equals; - - /// {@macro SignalBase.autoDispose} - @override - final bool autoDispose; - - /// {@macro SignalBase.trackInDevTools} - @override - final bool trackInDevTools; - - /// {@macro SignalBase.trackPreviousValue} - @override - final bool trackPreviousValue; - - /// {@macro SignalBase.comparator} - @override - final ValueComparator comparator; - - /// Tracks the internal value - late final _AlienSignal _internalSignal; - - @override - final _id = ReactiveName.nameFor('ReadSignal'); - - @override - bool get hasValue { - _reportObserved(); - return _hasValue; - } - - bool _hasValue = false; - - // Tracks the internal previous value - T? _previousValue; - - // Whether or not there is a previous value - bool _hasPreviousValue = false; - - T get _value { - if (_disposed) { - return untracked( - () => reactiveSystem.getSignalValue(_internalSignal).unwrap(), - ); - } - _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; - } - - late T _untrackedValue; - - T? _untrackedPreviousValue; - - /// Returns the untracked previous value of the signal. - @override - T? get untrackedPreviousValue { - return _untrackedPreviousValue; - } - - /// Returns the value without triggering the reactive system. - @override - T get untrackedValue { - if (!_hasValue) { - throw StateError( - '''The signal named "$name" is lazy and has not been initialized yet, cannot access its value''', - ); - } - return _untrackedValue; - } - - set _value(T newValue) { - _untrackedPreviousValue = _untrackedValue; - _untrackedValue = newValue; - reactiveSystem.setSignalValue(_internalSignal, Some(newValue)); - } - - @override - T get value { - if (!_hasValue) { - throw StateError( - '''The signal named "$name" is lazy and has not been initialized yet, cannot access its value''', - ); - } - return _value; - } - - @override - T call() { - return value; - } - - /// {@template set-signal-value} - /// Sets the current signal value with [newValue]. - /// - /// This operation may be skipped if the value is equal to the previous one, - /// check [equals] and [comparator]. - /// {@endtemplate} - @protected - T setValue(T newValue) { - final firstValue = !_hasValue; - - if (firstValue) { - _untrackedValue = newValue; - _hasValue = true; - } - - // skip if the values are equal - if (!firstValue && _compare(_untrackedValue, newValue)) { - return newValue; - } - - // store the previous value - if (!firstValue) _setPreviousValue(_untrackedValue); - - // notify with the new value - _value = newValue; - - if (firstValue) { - _notifySignalCreation(); - } else { - _notifySignalUpdate(); - } - return newValue; - } - - @override - bool get hasPreviousValue { - if (!trackPreviousValue) return false; - // cause observation - value; - return _hasPreviousValue; - } - - /// The previous value, if any. - @override - T? get previousValue { - if (!trackPreviousValue) return null; - // cause observation - value; - return _previousValue; - } - - /// Sets the previous signal value to [value]. - void _setPreviousValue(T value) { - if (!trackPreviousValue) return; - _previousValue = value; - _hasPreviousValue = true; - } - - bool _disposed = false; - - // Keeps track of all the callbacks passed to [onDispose]. - // Used later to fire each callback when this signal is disposed. - final _onDisposeCallbacks = []; - - /// Returns the number of listeners listening to this signal. - @override - int get listenerCount => _subs.length; - - final _subs = {}; - - @override - void dispose() { - // ignore if already disposed - if (_disposed) return; - _disposed = true; - - // This will dispose the signal - untracked(() { - 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(); - } - - for (final cb in _onDisposeCallbacks) { - cb(); - } - _onDisposeCallbacks.clear(); - _notifySignalDisposal(); - } - - @override - bool get disposed => _disposed; - - @override - void _mayDispose() { - if (!autoDispose || _disposed) return; - if (_internalSignal.subs == null) dispose(); - } - - @override - void onDispose(VoidCallback cb) { - _onDisposeCallbacks.add(cb); - } - - void _reportObserved() { - if (reactiveSystem.activeSub != null) { - reactiveSystem.link(_internalSignal, reactiveSystem.activeSub!); - } - } - - /// Forces a change notification even when the value - /// hasn't substantially changed. - /// - /// This should only be used when you need to force - /// trigger reactions despite no - /// actual value change. For normal value updates, - // ignore: comment_references - /// use [reactiveSystem.setSignalValue] instead. - void _reportChanged() { - _internalSignal.forceDirty = true; - _internalSignal.flags = 17 /* Mutable | Dirty */; - final subs = _internalSignal.subs; - if (subs != null) { - // coverage:ignore-start - reactiveSystem.propagate(subs); - if (reactiveSystem.batchDepth == 0) { - reactiveSystem.flush(); - } - // coverage:ignore-end - } - } - - /// Indicates if the signal should update its value. - bool shouldUpdate() { - return _internalSignal.update(); - } - - @override - String toString() => - '''ReadSignal<$T>(value: $_value, previousValue: $_previousValue)'''; - - /// Indicates if the [oldValue] and the [newValue] are equal - @override - bool _compare(T? oldValue, T? newValue) { - // skip if the value are equals - if (equals) { - return oldValue == newValue; - } - - // return the [comparator] result - return comparator(oldValue, newValue); - } -} diff --git a/packages/solidart/lib/src/core/readonly_signal.dart b/packages/solidart/lib/src/core/readonly_signal.dart new file mode 100644 index 00000000..00d2fb4f --- /dev/null +++ b/packages/solidart/lib/src/core/readonly_signal.dart @@ -0,0 +1,32 @@ +part of '../solidart.dart'; + +/// Read-only reactive value. +/// +/// Reading [value] establishes a dependency; [untrackedValue] does not. +/// This interface is implemented by [Signal], [Computed], and [Resource]. +/// +/// ```dart +/// final count = Signal(0); +/// ReadonlySignal readonly = count.toReadonly(); +/// ``` +// TODO(nank1ro): Maybe rename to `ReadSignal`? medz: I still recommend `ReadonlySignal` because it is semantically clearer., https://github.com/nank1ro/solidart/pull/166#issuecomment-3623175977 +abstract interface class ReadonlySignal + implements system.ReactiveNode, Disposable, SignalConfiguration { + /// Returns the previous value (tracked read). + /// + /// This may return `null` if tracking is disabled or the signal has not been + /// read since the last update. + T? get previousValue; + + /// Returns the previous value without tracking. + T? get untrackedPreviousValue; + + /// Returns the current value without tracking. + T get untrackedValue; + + /// Returns the current value and tracks dependencies. + T get value; + + /// Returns [value]. This allows using a signal as a callable. + T call(); +} diff --git a/packages/solidart/lib/src/core/resource.dart b/packages/solidart/lib/src/core/resource.dart deleted file mode 100644 index d0a84c91..00000000 --- a/packages/solidart/lib/src/core/resource.dart +++ /dev/null @@ -1,817 +0,0 @@ -part of 'core.dart'; - -/// {@template FutureOrThenExtension} -/// Extension to add a `then` method to `FutureOr`. -/// This is used internally to handle both `Future` and synchronous values -/// uniformly. -/// {@endtemplate} -extension FutureOrThenExtension on FutureOr { - /// Extension method to add a `then` method to `FutureOr`. - FutureOr then( - FutureOr Function(T value) onValue, { - Function? onError, - }) { - final v = this; - if (v is Future) { - return v.then(onValue, onError: onError); - } else { - return onValue(v); - } - } -} - -/// {@template resource} -/// `Resources` are special `Signal`s designed specifically to handle Async -/// loading. Their purpose is wrap async values in a way that makes them easy -/// to interact with handling the common states of a future __data__, __error__ -/// and __loading__. -/// -/// Resources can be driven by a `source` signal that provides the query to an -/// async data `fetcher` function that returns a `Future` or to a `stream` that -/// is listened again when the source changes. -/// -/// The contents of the `fetcher` function can be anything. You can hit typical -/// REST endpoints or GraphQL or anything that generates a future. Resources -/// are not opinionated on the means of loading the data, only that they are -/// driven by an async operation. -/// -/// Let's create a Resource: -/// -/// ```dart -/// // Using http as a client -/// import 'package:http/http.dart' as http; -/// -/// // The source -/// final userId = Signal(1); -/// -/// // The fetcher -/// Future fetchUser() async { -/// final response = await http.get( -/// Uri.parse('https://jsonplaceholder.typicode.com/users/${userId.value}/'), -/// headers: {'Accept': 'application/json'}, -/// ); -/// return response.body; -/// } -/// -/// // The resource (source is optional) -/// final user = Resource(fetchUser, source: userId); -/// ``` -/// -/// A Resource can also be driven from a [stream] instead of a Future. -/// In this case you just need to pass the `stream` field to the -/// `Resource.stream` constructor. -/// -/// The resource has a [state] named [ResourceState], that provides many useful -/// convenience methods to correctly handle the state of the resource. -/// -/// The `when` method forces you to handle all the states of a Resource -/// (_ready_, _error_ and _loading_). -/// The are also other convenience methods to handle only specific states: -/// - `when` forces you to handle all the states of a Resource -/// - `maybeWhen` lets you decide which states to handle and provide an `orElse` -/// action for unhandled states -/// - `map` equal to `on` but gives access to the `ResourceState` data class -/// - `maybeMap` equal to `maybeMap` but gives access to the `ResourceState` -/// data class -/// - `isReady` indicates if the `Resource` is in the ready state -/// - `isLoading` indicates if the `Resource` is in the loading state -/// - `hasError` indicates if the `Resource` is in the error state -/// - `asReady` upcast `ResourceState` into a `ResourceReady`, or return null if the `ResourceState` is in loading/error state -/// - `asError` upcast `ResourceState` into a `ResourceError`, or return null if the `ResourceState` is in loading/ready state -/// - `value` attempts to synchronously get the value of `ResourceReady` -/// - `error` attempts to synchronously get the error of `ResourceError` -/// -/// A `Resource` provides the `resolve` and `refresh` methods. -/// -/// The `resolve` method must be called only once for the lifecycle of the -/// resource. -/// If runs the `fetcher` for the first time and then it listen to the -/// [source], if provided. -/// If you're passing a [stream] it subscribes to it, and every time the source -/// changes, it resubscribes again. -/// -/// The `refresh` method forces an update and calls the `fetcher` function -/// again or subscribes againg to the [stream]. -/// {@endtemplate} -class Resource extends Signal> { - /// {@macro resource} - Resource( - this.fetcher, { - this.source, - - /// {@macro SignalBase.name} - super.name, - - /// {@macro SignalBase.equals} - super.equals, - - /// {@macro SignalBase.autoDispose} - super.autoDispose, - - /// {@macro SignalBase.trackInDevTools} - super.trackInDevTools, - - /// Indicates whether the resource should be computed lazily, defaults to - /// true. - this.lazy = true, - - /// {@macro Resource.useRefreshing} - bool? useRefreshing, - - /// Whether to track the previous state of the resource, defaults to true. - bool? trackPreviousState, - - /// The debounce delay when the source changes, optional. - this.debounceDelay, - }) : useRefreshing = useRefreshing ?? SolidartConfig.useRefreshing, - stream = null, - super( - ResourceState.loading(), - trackPreviousValue: - trackPreviousState ?? SolidartConfig.trackPreviousValue, - comparator: identical, - ) { - // resolve the resource immediately if not lazy - if (!lazy) _resolveIfNeeded(); - } - - /// {@macro resource} - Resource.stream( - this.stream, { - this.source, - - /// {@macro SignalBase.name} - super.name, - - /// {@macro SignalBase.equals} - super.equals, - - /// {@macro SignalBase.autoDispose} - super.autoDispose, - - /// {@macro SignalBase.trackInDevTools} - super.trackInDevTools, - - /// Indicates whether the resource should be computed lazily, defaults to - /// true. - this.lazy = true, - - /// {@macro Resource.useRefreshing} - bool? useRefreshing, - - /// Whether to track the previous state of the resource, defaults to true. - bool? trackPreviousState, - this.debounceDelay, - }) : useRefreshing = useRefreshing ?? SolidartConfig.useRefreshing, - fetcher = null, - super( - ResourceState.loading(), - trackPreviousValue: - trackPreviousState ?? SolidartConfig.trackPreviousValue, - comparator: identical, - ) { - // resolve the resource immediately if not lazy - if (!lazy) _resolveIfNeeded(); - } - - /// Indicates whether the resource should be computed lazily, defaults to true - final bool lazy; - - /// Reactive signal values passed to the fetcher, optional. - final SignalBase? source; - - /// The asynchrounous function used to retrieve data. - final Future Function()? fetcher; - - /// The stream used to retrieve data. - final Stream Function()? stream; - - /// The debounce delay when the source changes, optional. - final Duration? debounceDelay; - - StreamSubscription? _streamSubscription; - - // The source dispose observation - DisposeObservation? _sourceDisposeObservation; - - /// Indicates if the resource has been resolved - bool _resolved = false; - - /// {@template Resource.useRefreshing} - /// Whether to use `isRefreshing` in the current state of the resource, - /// defaults to true. - /// - /// If you set to false, the state will always transition to `loading` when - /// refreshing. - /// {@endtemplate} - final bool useRefreshing; - - @override - // ignore: overridden_fields - final _id = ReactiveName.nameFor('Resource'); - - /// The current resource state - ResourceState get state { - _resolveIfNeeded(); - return super.value; - } - - /// The current resource state - @override - ResourceState call() => state; - - /// Updates the current resource state - set state(ResourceState state) => super.value = state; - - // coverage:ignore-start - @Deprecated('Use state instead') - @override - ResourceState get value => state; - - @Deprecated('Use state instead') - @override - set value(ResourceState value) => state = value; - - @Deprecated('Use previousState instead') - @override - ResourceState? get previousValue => previousState; - - @Deprecated('Use untrackedState instead') - @override - ResourceState get untrackedValue => untrackedState; - - @Deprecated('Use untrackedPreviousState instead') - @override - ResourceState? get untrackedPreviousValue => untrackedPreviousState; - - @Deprecated('Use update instead') - @override - ResourceState updateValue( - ResourceState Function(ResourceState state) callback, - ) => update(callback); - // coverage:ignore-end - - /// The previous resource state - ResourceState? get previousState { - _resolveIfNeeded(); - if (!_resolved) return null; - return super.previousValue; - } - - /// The previous resource state, without tracking - ResourceState? get untrackedPreviousState => super.untrackedPreviousValue; - - /// The resource state without tracking - ResourceState get untrackedState => super.untrackedValue; - - // The stream trasformed in a broadcast stream, if needed - Stream get _stream { - final s = stream!(); - if (!_broadcastStreams.keys.contains(s)) { - _broadcastStreams[s] = s.isBroadcast - ? s - : s.asBroadcastStream( - onListen: (subscription) { - if (!_streamSubscriptions.contains(subscription)) { - _streamSubscriptions.add(subscription); - } - subscription.resume(); - }, - onCancel: (subscription) { - subscription.pause(); - }, - ); - } - return _broadcastStreams[s]!; - } - - final _broadcastStreams = , Stream>{}; - final _streamSubscriptions = >[]; - - /// Resolves the [Resource]. - /// - /// If you provided a [fetcher], it run the async call. - /// Otherwise it starts listening to the [stream]. - /// - /// Then will subscribe to the [source], if provided. - /// - /// This method must be called once during the life cycle of the resource. - Future _resolve() async { - assert( - !_resolved, - """The resource has been already resolved, you can't resolve it more than once. Use `refresh()` instead if you want to refresh the value.""", - ); - _resolved = true; - if (fetcher != null) { - // start fetching - await _fetch(); - } - // React the the [stream], if provided - if (stream != null) { - _subscribe(); - } - - // react to the [source], if provided. - if (source != null) { - _sourceDisposeObservation = source!.observe((p, v) { - if (debounceDelay != null) { - Debouncer.debounce( - source!._id, - debounceDelay!, - refresh, - ); - } else { - refresh(); - } - }); - source!.onDispose(_sourceDisposeObservation!); - } - } - - /// Resolves the resource, if needed - void _resolveIfNeeded() { - if (!_resolved) _resolve(); - } - - /// Runs the [fetcher] for the first time. - Future _fetch() async { - assert(fetcher != null, 'You are trying to fetch, but fetcher is null'); - try { - final result = await fetcher!(); - state = ResourceState.ready(result); - } catch (e, s) { - state = ResourceState.error(e, stackTrace: s); - } - } - - /// Subscribes to the provided [stream] for the first time. - void _subscribe() { - assert( - stream != null, - 'You are trying to listen to a stream, but stream is null', - ); - _listenStream(); - } - - /// Listens to the stream - void _listenStream() { - _streamSubscription = _stream.listen( - (data) { - state = ResourceState.ready(data); - }, - onError: (Object error, StackTrace stackTrace) { - state = ResourceState.error(error, stackTrace: stackTrace); - }, - ); - } - - /// Forces a refresh of the [fetcher] or the [stream]. - /// - /// In case of the [stream], cancels the previous subscription and - /// resubscribes. - Future refresh() async { - if (fetcher != null) { - return _refetch(); - } - return _resubscribe(); - } - - /// Resubscribes to the [stream]. - /// - /// Cancels the previous subscription and resubscribes. - void _resubscribe() { - assert( - stream != null, - 'You are trying to listen to a stream, but stream is null', - ); - _streamSubscription?.cancel(); - _streamSubscription = null; - _transition(); - _listenStream(); - } - - // Transitions to the refreshing state, if enabled, otherwise sets the state - // to loading. - void _transition() { - if (useRefreshing) { - state.map( - ready: (ready) { - state = ready.copyWith(isRefreshing: true); - }, - error: (error) { - state = error.copyWith(isRefreshing: true); - }, - loading: (_) { - state = ResourceState.loading(); - }, - ); - } else { - state = ResourceState.loading(); - } - } - - /// Force a refresh of the [fetcher]. - Future _refetch() async { - assert(fetcher != null, 'You are trying to refetch, but fetcher is null'); - try { - _transition(); - final result = await fetcher!(); - state = ResourceState.ready(result); - } catch (e, s) { - state = ResourceState.error(e, stackTrace: s); - } - } - - /// Returns a future that completes with the value when the Resource is ready - /// If the resource is already ready, it completes immediately. - FutureOr untilReady() async { - final state = await until((value) => value.isReady); - return state.asReady!.value; - } - - /// Calls a function with the current [state] and assigns the result as the - /// new state - ResourceState update( - ResourceState Function(ResourceState state) callback, - ) => state = callback(_value); - - @override - void dispose() { - _streamSubscription?.cancel(); - _streamSubscription = null; - for (final sub in _streamSubscriptions) { - sub.cancel(); - } - _streamSubscriptions.clear(); - - _sourceDisposeObservation?.call(); - _broadcastStreams.clear(); - _streamSubscriptions.clear(); - // Dispose the source, if needed - if (source != null) { - if (source!.autoDispose && source!.listenerCount == 0) { - source!.dispose(); - } - } - super.dispose(); - } - - @override - String toString() => - '''Resource<$T>(state: $_value, previousState: $_previousValue)'''; -} - -/// Manages all the different states of a [Resource]: -/// - ResourceReady -/// - ResourceLoading -/// - ResourceError -@sealed -@immutable -sealed class ResourceState { - /// Creates an [ResourceState] with a data. - /// - /// The data can be `null`. - const factory ResourceState.ready(T data, {bool isRefreshing}) = - ResourceReady; - - /// Creates an [ResourceState] in loading state. - /// - /// Prefer always using this constructor with the `const` keyword. - // coverage:ignore-start - const factory ResourceState.loading() = ResourceLoading; - // coverage:ignore-end - - /// Creates an [ResourceState] in error state. - /// - /// The parameter [error] cannot be `null`. - // coverage:ignore-start - const factory ResourceState.error( - Object error, { - StackTrace? stackTrace, - bool isRefreshing, - }) = ResourceError; - // coverage:ignore-end - - /// private mapper, so that classes inheriting Resource can specify their own - /// `map` method with different parameters. - // coverage:ignore-start - R map({ - required R Function(ResourceReady ready) ready, - required R Function(ResourceError error) error, - required R Function(ResourceLoading loading) loading, - }); - // coverage:ignore-end -} - -/// Creates an [ResourceState] in ready state with a data. -@immutable -class ResourceReady implements ResourceState { - /// Creates an [ResourceState] with a data. - const ResourceReady(this.value, {this.isRefreshing = false}); - - /// The value currently exposed. - final T value; - - /// Indicates if the data is being refreshed, defaults to false. - final bool isRefreshing; - - // coverage:ignore-start - @override - R map({ - required R Function(ResourceReady ready) ready, - required R Function(ResourceError error) error, - required R Function(ResourceLoading loading) loading, - }) { - return ready(this); - } - - @override - String toString() { - return 'ResourceReady<$T>(value: $value, refreshing: $isRefreshing)'; - } - - @override - bool operator ==(Object other) { - return runtimeType == other.runtimeType && - other is ResourceReady && - other.value == value && - other.isRefreshing == isRefreshing; - } - - @override - int get hashCode => Object.hash(runtimeType, value, isRefreshing); - - /// Convenience method to update the values of a [ResourceReady]. - ResourceReady copyWith({ - T? value, - bool? isRefreshing, - }) { - return ResourceReady( - value ?? this.value, - isRefreshing: isRefreshing ?? this.isRefreshing, - ); - } - - // coverage:ignore-end -} - -/// {@template resourceloading} -/// Creates an [ResourceState] in loading state. -/// -/// Prefer always using this constructor with the `const` keyword. -/// {@endtemplate} -@immutable -class ResourceLoading implements ResourceState { - /// {@macro resourceloading} - const ResourceLoading(); - - // coverage:ignore-start - @override - R map({ - required R Function(ResourceReady ready) ready, - required R Function(ResourceError error) error, - required R Function(ResourceLoading loading) loading, - }) { - return loading(this); - } - - @override - String toString() { - return 'ResourceLoading<$T>()'; - } - - @override - bool operator ==(Object other) { - return runtimeType == other.runtimeType; - } - - @override - int get hashCode => runtimeType.hashCode; - // coverage:ignore-end -} - -/// {@template resourceerror} -/// Creates an [ResourceState] in error state. -/// -/// The parameter [error] cannot be `null`. -/// {@endtemplate} -@immutable -class ResourceError implements ResourceState { - /// {@macro resourceerror} - const ResourceError( - this.error, { - this.stackTrace, - this.isRefreshing = false, - }); - - /// The error. - final Object error; - - /// The stackTrace of [error], optional. - final StackTrace? stackTrace; - - /// Indicates if the data is being refreshed, defaults to false. - final bool isRefreshing; - - // coverage:ignore-start - @override - R map({ - required R Function(ResourceReady ready) ready, - required R Function(ResourceError error) error, - required R Function(ResourceLoading loading) loading, - }) { - return error(this); - } - - @override - String toString() { - return 'ResourceError<$T>(error: $error, stackTrace: $stackTrace, ' - 'refreshing: $isRefreshing)'; - } - - @override - bool operator ==(Object other) { - return runtimeType == other.runtimeType && - other is ResourceError && - other.error == error && - other.stackTrace == stackTrace && - other.isRefreshing == isRefreshing; - } - - @override - int get hashCode => Object.hash(runtimeType, error, stackTrace, isRefreshing); - - /// Convenience method to update the [isRefreshing] value of a [Resource] - ResourceError copyWith({ - Object? error, - StackTrace? stackTrace, - bool? isRefreshing, - }) { - return ResourceError( - error ?? this.error, - stackTrace: stackTrace ?? this.stackTrace, - isRefreshing: isRefreshing ?? this.isRefreshing, - ); - } - - // coverage:ignore-end -} - -/// Some useful extension available on any [ResourceState]. -// coverage:ignore-start -extension ResourceExtensions on ResourceState { - /// Indicates if the resource is loading. - bool get isLoading => this is ResourceLoading; - - /// Indicates if the resource has an error. - bool get hasError => this is ResourceError; - - /// Indicates if the resource is ready. - bool get isReady => this is ResourceReady; - - /// Indicates if the resource is refreshing. Loading is not considered as - /// refreshing. - bool get isRefreshing => switch (this) { - ResourceReady(:final isRefreshing) => isRefreshing, - ResourceError(:final isRefreshing) => isRefreshing, - ResourceLoading() => false, - }; - - /// Upcast [ResourceState] into a [ResourceReady], or return null if the - /// [ResourceState] is in loading/error state. - ResourceReady? get asReady { - return map( - ready: (r) => r, - error: (_) => null, - loading: (_) => null, - ); - } - - /// Upcast [ResourceState] into a [ResourceError], or return null if the - /// [ResourceState] is in ready/loading state. - ResourceError? get asError { - return map( - error: (e) => e, - ready: (_) => null, - loading: (_) => null, - ); - } - - /// Attempts to synchronously get the value of [ResourceReady]. - /// - /// On error, this will rethrow the error. - /// If loading, will return `null`. - T? get value { - return map( - ready: (r) => r.value, - // ignore: only_throw_errors - error: (r) => throw r.error, - loading: (_) => null, - ); - } - - /// Attempts to synchronously get the error of [ResourceError]. - /// - /// On other states will return `null`. - Object? get error { - return map( - error: (r) => r.error, - ready: (_) => null, - loading: (_) => null, - ); - } - - /// Perform some actions based on the state of the [ResourceState], or call - /// orElse if the current state is not considered. - R maybeMap({ - required R Function() orElse, - R Function(ResourceReady ready)? ready, - R Function(ResourceError error)? error, - R Function(ResourceLoading loading)? loading, - }) { - return map( - ready: (r) { - if (ready != null) return ready(r); - return orElse(); - }, - error: (d) { - if (error != null) return error(d); - return orElse(); - }, - loading: (l) { - if (loading != null) return loading(l); - return orElse(); - }, - ); - } - - /// Performs an action based on the state of the [ResourceState]. - /// - /// All cases are required. - @Deprecated('Use when instead') - R on({ - required R Function(T data) ready, - required R Function(Object error, StackTrace? stackTrace) error, - required R Function() loading, - }) { - return when(ready: ready, error: error, loading: loading); - } - - /// Performs an action based on the state of the [ResourceState]. - /// - /// All cases are required. - R when({ - required R Function(T data) ready, - required R Function(Object error, StackTrace? stackTrace) error, - required R Function() loading, - }) { - return map( - ready: (r) => ready(r.value), - error: (e) => error(e.error, e.stackTrace), - loading: (l) => loading(), - ); - } - - /// Performs an action based on the state of the [ResourceState], or call - /// [orElse] if the current state is not considered. - @Deprecated('Use maybeWhen instead') - R maybeOn({ - required R Function() orElse, - R Function(T data)? ready, - R Function(Object error, StackTrace? stackTrace)? error, - R Function()? loading, - }) { - return maybeWhen( - orElse: orElse, - ready: ready, - error: error, - loading: loading, - ); - } - - /// Performs an action based on the state of the [ResourceState], or call - /// [orElse] if the current state is not considered. - R maybeWhen({ - required R Function() orElse, - R Function(T data)? ready, - R Function(Object error, StackTrace? stackTrace)? error, - R Function()? loading, - }) { - return map( - ready: (r) { - if (ready != null) return ready(r.value); - return orElse(); - }, - error: (e) { - if (error != null) return error(e.error, e.stackTrace); - return orElse(); - }, - loading: (l) { - if (loading != null) return loading(); - return orElse(); - }, - ); - } -} - -// coverage:ignore-end diff --git a/packages/solidart/lib/src/core/set_signal.dart b/packages/solidart/lib/src/core/set_signal.dart new file mode 100644 index 00000000..3046de84 --- /dev/null +++ b/packages/solidart/lib/src/core/set_signal.dart @@ -0,0 +1,130 @@ +part of '../solidart.dart'; + +/// {@template solidart.set-signal} +/// A reactive wrapper around a [Set] that copies on write. +/// +/// Mutations create a new set instance so that updates are observable: +/// ```dart +/// final set = SetSignal({1}); +/// Effect(() => print(set.length)); +/// set.add(2); // triggers effect +/// ``` +/// +/// Reads (like `length` or `contains`) establish dependencies. +/// {@endtemplate} +class SetSignal extends Signal> with SetMixin { + /// {@macro solidart.set-signal} + /// + /// Creates a reactive set with the provided initial values. + SetSignal( + Iterable initialValue, { + bool? autoDispose, + String? name, + ValueComparator> equals = identical, + bool? trackPreviousValue, + bool? trackInDevTools, + }) : super( + Set.of(initialValue), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: trackPreviousValue, + trackInDevTools: trackInDevTools, + ); + + @override + Iterator get iterator => value.iterator; + + @override + int get length => value.length; + + @override + bool add(E value) { + final current = untrackedValue; + if (current.contains(value)) return false; + final next = Set.of(current)..add(value); + this.value = next; + return true; + } + + @override + void addAll(Iterable elements) { + if (elements.isEmpty) return; + final next = _copy()..addAll(elements); + if (next.length == untrackedValue.length) return; + value = next; + } + + @override + Set cast() => SetSignal(untrackedValue.cast()); + + @override + void clear() { + if (untrackedValue.isEmpty) return; + value = {}; + } + + @override + bool contains(Object? element) { + value; + return untrackedValue.contains(element); + } + + @override + E? lookup(Object? element) { + value; + return untrackedValue.lookup(element); + } + + @override + bool remove(Object? value) { + final current = untrackedValue; + if (!current.contains(value)) return false; + final next = Set.of(current)..remove(value); + this.value = next; + return true; + } + + @override + void removeAll(Iterable elements) { + if (elements.isEmpty) return; + final current = untrackedValue; + final next = Set.of(current)..removeAll(elements); + if (next.length == current.length) return; + value = next; + } + + @override + void removeWhere(bool Function(E element) test) { + final current = untrackedValue; + final next = Set.of(current)..removeWhere(test); + if (next.length == current.length) return; + value = next; + } + + @override + void retainAll(Iterable elements) { + final current = untrackedValue; + final next = Set.of(current)..retainAll(elements); + if (next.length == current.length) return; + value = next; + } + + @override + void retainWhere(bool Function(E element) test) { + final current = untrackedValue; + final next = Set.of(current)..retainWhere(test); + if (next.length == current.length) return; + value = next; + } + + @override + Set toSet() => Set.of(untrackedValue); + + @override + String toString() => + 'SetSignal<$E>(value: $untrackedValue, ' + 'previousValue: $untrackedPreviousValue)'; + + Set _copy() => Set.of(untrackedValue); +} diff --git a/packages/solidart/lib/src/core/signal.dart b/packages/solidart/lib/src/core/signal.dart index 228b7892..805a3300 100644 --- a/packages/solidart/lib/src/core/signal.dart +++ b/packages/solidart/lib/src/core/signal.dart @@ -1,172 +1,186 @@ -part of 'core.dart'; +part of '../solidart.dart'; -/// {@template signal} +/// {@template solidart.signal} /// # Signals -/// Signals are the cornerstone of reactivity in `solidart`. They contain -/// values that change over time; when you change a signal's value, it -/// automatically updates anything that uses it. -/// -/// Create the signal with: +/// Signals are the cornerstone of reactivity in v3. They store values that +/// change over time, and any reactive computation that reads a signal will +/// automatically update when the signal changes. /// +/// Create a signal with an initial value: /// ```dart /// final counter = Signal(0); -/// ```` -/// -/// The argument passed to the create call is the initial value, and the return -/// value is the signal. +/// ``` /// -/// To retrieve the current signal value use: +/// Read the current value: /// ```dart /// counter.value; // 0 -/// // or -/// counter(); // 0 /// ``` /// -/// To update the current signal value you can use: +/// Update the value: /// ```dart -/// counter.value++; // increase by 1 -/// // or -/// counter.set(2); // sets the value to 2 +/// counter.value++; /// // or -/// counter.value = 5; // sets the value to 5 -/// // or -/// counter.update((v) => v * 2); // update based on the current value -/// ``` - -/// ## Derived Signals -/// -/// You may want to subscribe only to a sub-field of a `Signal` value. -/// ```dart -/// // sample User class -/// class User { -/// const User({ -/// required this.name, -/// required this.age, -/// }); -/// -/// final String name; -/// final int age; -/// -/// User copyWith({ -/// String? name, -/// int? age, -/// }) { -/// return User( -/// name: name ?? this.name, -/// age: age ?? this.age, -/// ); -/// } -/// } -/// -/// // create a user signal -/// final user = Signal(const User(name: "name", age: 20)); -/// -/// // create a derived signal just for the age -/// final age = Computed(() => user().age); -/// -/// // adding an effect to print the age -/// Effect((_) { -/// print('age changed from ${age.previousValue} into ${age.value}'); -/// }); -/// -/// // just update the name, the effect above doesn't run because the age has not changed -/// user.update((value) => value.copyWith(name: 'new-name')); -/// -/// // just update the age, the effect above prints -/// user.update((value) => value.copyWith(age: 21)); +/// counter.value = 10; /// ``` /// -/// A derived signal is not of type `Signal` but is a `ReadSignal`. -/// The difference with a normal `Signal` is that a `ReadSignal` doesn't have a -/// value setter, in other words it's a __read-only__ signal. -/// -/// You can also use derived signals in other ways, like here: +/// Signals support previous value tracking. When enabled, `previousValue` +/// updates only after the signal has been read at least once: /// ```dart -/// final counter = Signal(0); -/// final doubleCounter = Computed(() => counter() * 2); +/// final count = Signal(0); +/// count.value = 1; +/// count.previousValue; // null (not read yet) +/// count.value; // establishes tracking +/// count.previousValue; // 0 /// ``` /// -/// Every time the `counter` signal changes, the doubleCounter updates with the -/// new doubled `counter` value. -/// -/// You can also transform the value type into a `bool`: -/// ```dart -/// final counter = Signal(0); // type: int -/// final isGreaterThan5 = Computed(() => counter() > 5); // type: bool -/// ``` -/// -/// `isGreaterThan5` will update only when the `counter` value becomes lower/greater than `5`. -/// - If the `counter` value is `0`, `isGreaterThan5` is equal to `false`. -/// - If you update the value to `1`, `isGreaterThan5` doesn't emit a new -/// value, but still contains `false`. -/// - If you update the value to `6`, `isGreaterThan5` emits a new `true` value. +/// Signals can be created lazily using [Signal.lazy]. A lazy signal does not +/// have a value until it is first assigned, and reading it early throws +/// [StateError]. /// {@endtemplate} -class Signal extends ReadableSignal { - /// {@macro signal} +/// {@template solidart.signal-equals} +/// Updates are skipped when [equals] reports the new value is equivalent to +/// the previous one. +/// {@endtemplate} +class Signal extends preset.SignalNode> + with DisposableMixin + implements ReadonlySignal { + /// {@macro solidart.signal} + /// + /// {@macro solidart.signal-equals} Signal( - super.initialValue, { + T initialValue, { + bool? autoDispose, + String? name, + ValueComparator equals = identical, + bool? trackPreviousValue, + bool? trackInDevTools, + }) : this._internal( + Some(initialValue), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: trackPreviousValue, + trackInDevTools: trackInDevTools, + ); + + /// {@macro solidart.signal} + /// + /// This is a lazy signal: it has no value at construction time. + /// Reading [value] before the first assignment throws [StateError]. + factory Signal.lazy({ + String? name, + bool? autoDispose, + ValueComparator equals, + bool? trackPreviousValue, + bool? trackInDevTools, + }) = LazySignal; + + Signal._internal( + Option initialValue, { + this.equals = identical, + String? name, + bool? autoDispose, + bool? trackPreviousValue, + bool? trackInDevTools, + }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, + trackPreviousValue = + trackPreviousValue ?? SolidartConfig.trackPreviousValue, + trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, + identifier = ._(name), + super( + flags: system.ReactiveFlags.mutable, + currentValue: initialValue, + pendingValue: initialValue, + ) { + _notifySignalCreation(this); + } - /// {@macro SignalBase.name} - super.name, + @override + final bool autoDispose; - /// {@macro SignalBase.equals} - super.equals, + @override + final Identifier identifier; - /// {@macro SignalBase.autoDispose} - super.autoDispose, + @override + final ValueComparator equals; - /// {@macro SignalBase.trackInDevTools} - super.trackInDevTools, + @override + final bool trackPreviousValue; - /// {@macro SignalBase.comparator} - super.comparator = identical, + @override + final bool trackInDevTools; - /// {@macro SignalBase.trackPreviousValue} - super.trackPreviousValue, - }); + Option _previousValue = const None(); - /// {@macro signal} + /// Whether the signal has been initialized. /// - /// This is a lazy signal, it doesn't have a value at the moment of creation. - /// But would throw a StateError if you try to access the value before setting - /// one. - Signal.lazy({ - /// {@macro SignalBase.name} - super.name, - - /// {@macro SignalBase.equals} - super.equals, - - /// {@macro SignalBase.autoDispose} - super.autoDispose, + /// Regular signals are always initialized at construction time. + bool get isInitialized => true; - /// {@macro SignalBase.trackInDevTools} - super.trackInDevTools, + @override + T? get previousValue { + if (!trackPreviousValue) return null; + value; + return _previousValue.safeUnwrap(); + } - /// {@macro SignalBase.comparator} - super.comparator = identical, + @override + T? get untrackedPreviousValue { + if (!trackPreviousValue) return null; + return _previousValue.safeUnwrap(); + } - /// {@macro SignalBase.trackPreviousValue} - super.trackPreviousValue, - }) : super.lazy(); + @override + T get untrackedValue => super.currentValue.unwrap(); - /// {@macro set-signal-value} - set value(T newValue) => setValue(newValue); + @override + T get value { + assert(!isDisposed, 'Signal is disposed'); + return super.get().unwrap(); + } - /// Calls a function with the current value and assigns the result as the - /// new value. - T updateValue(T Function(T value) callback) => - value = callback(_untrackedValue); + /// Sets the current value. + /// + /// {@macro solidart.signal-equals} + set value(T newValue) { + assert(!isDisposed, 'Signal is disposed'); + set(Some(newValue)); + } - /// Converts this [Signal] into a [ReadableSignal] - /// Use this method to remove the visility to the value setter. - ReadableSignal toReadSignal() => this; + @override + T call() => value; + // TODO(nank1ro): See ReadonlySignal TODO, If `ReadonlySignal` rename + // to `ReadSignal`, the `.toReadonly` method should be rename? @override - // ignore: overridden_fields - final _id = ReactiveName.nameFor('Signal'); + bool didUpdate() { + flags = system.ReactiveFlags.mutable; + final current = currentValue; + final pending = pendingValue; + if (current is Some && + pending is Some && + equals(pending.value, current.value)) { + return false; + } + + if (trackPreviousValue && current is Some) { + _previousValue = current; + } + + currentValue = pending; + _notifySignalUpdate(this); + return true; + } @override - String toString() => - '''Signal<$T>(value: $_untrackedValue, previousValue: $_untrackedPreviousValue)'''; + void dispose() { + if (isDisposed) return; + Disposable.unlinkSubs(this); + preset.stop(this); + super.dispose(); + _notifySignalDisposal(this); + } + + /// Returns a read-only view of this signal. + ReadonlySignal toReadonly() => this; } diff --git a/packages/solidart/lib/src/core/signal_base.dart b/packages/solidart/lib/src/core/signal_base.dart deleted file mode 100644 index a0b7a598..00000000 --- a/packages/solidart/lib/src/core/signal_base.dart +++ /dev/null @@ -1,109 +0,0 @@ -// coverage:ignore-file - -part of 'core.dart'; - -/// A callback that stops an observation when called -typedef DisposeObservation = void Function(); - -/// A custom comparator function -typedef ValueComparator = bool Function(T a, T b); - -/// The base of a signal. -abstract class SignalBase { - /// The base of a signal. - SignalBase({ - this.name, - this.comparator = identical, - bool? equals, - bool? autoDispose, - bool? trackInDevTools, - bool? trackPreviousValue, - }) : autoDispose = autoDispose ?? SolidartConfig.autoDispose, - trackInDevTools = trackInDevTools ?? SolidartConfig.devToolsEnabled, - equals = equals ?? SolidartConfig.equals, - trackPreviousValue = - trackPreviousValue ?? SolidartConfig.trackPreviousValue; - - String get _id; - - /// {@template SignalBase.name} - /// The name of the signal, useful for logging purposes. - /// {@endtemplate} - final String? name; - - /// {@template SignalBase.equals} - /// Whether to check the equality of the value with the == equality. - /// - /// Preventing signal updates if the new value is equal to the previous. - /// - /// When this value is true, the [comparator] is not used. - /// {@endtemplate} - final bool equals; - - /// {@template SignalBase.comparator} - /// An optional comparator function, defaults to [identical]. - /// - /// Preventing signal updates if the [comparator] returns true. - /// - /// Taken into account only if [equals] is false. - /// {@endtemplate} - final ValueComparator comparator; - - /// {@template SignalBase.autoDispose} - /// Whether to automatically dispose the signal (defaults to - /// [SolidartConfig.autoDispose]). - /// - /// This happens automatically when there are no longer subscribers. - /// If you set it to false, you should remember to dispose the signal manually - /// {@endtemplate} - final bool autoDispose; - - /// Whether to track the signal in the DevTools extension, defaults to - /// [SolidartConfig.devToolsEnabled]. - final bool trackInDevTools; - - /// Whether to track the previous value of the signal, defaults to true - final bool trackPreviousValue; - - /// The current signal value - T get value; - - /// The current signal value - T call(); - - /// Whether or not the signal has been initialized with a value. - bool get hasValue; - - /// Indicates if there is a previous value. It is especially - /// helpful if [T] is nullable. - bool get hasPreviousValue; - - /// The previous signal value - /// - /// Defaults to null when no previous value is present. - T? get previousValue; - - /// Returns the untracked previous value of the signal. - T? get untrackedPreviousValue; - - /// Returns the untracked value of the signal. - T get untrackedValue; - - /// Tells if the signal is disposed; - bool get disposed; - - /// Fired when the signal is disposing - void onDispose(VoidCallback cb); - - /// The total number of listeners subscribed to the signal. - int get listenerCount; - - /// Tries to dispose the signal, if no observers are present - void _mayDispose(); - - /// Diposes the signal - void dispose(); - - /// Indicates if the [oldValue] and the [newValue] are equal - bool _compare(T? oldValue, T? newValue); -} diff --git a/packages/solidart/lib/src/core/signal_configuration.dart b/packages/solidart/lib/src/core/signal_configuration.dart new file mode 100644 index 00000000..c27e474c --- /dev/null +++ b/packages/solidart/lib/src/core/signal_configuration.dart @@ -0,0 +1,18 @@ +part of '../solidart.dart'; + +/// Common configuration for signals. +abstract interface class SignalConfiguration implements Configuration { + /// Comparator used to skip equal updates. + /// + /// When it returns `true`, the new value is treated as equal and the update + /// is skipped. + ValueComparator get equals; + + /// Whether to report to DevTools. + bool get trackInDevTools; + + /// Whether to track previous values. + /// + /// Previous values are captured on successful updates after a tracked read. + bool get trackPreviousValue; +} diff --git a/packages/solidart/lib/src/core/typedefs.dart b/packages/solidart/lib/src/core/typedefs.dart new file mode 100644 index 00000000..cef339d5 --- /dev/null +++ b/packages/solidart/lib/src/core/typedefs.dart @@ -0,0 +1,19 @@ +part of '../solidart.dart'; + +/// Disposer returned by [ObserveSignal.observe]. +typedef DisposeObservation = void Function(); + +/// Signature for callbacks fired when a signal changes. +typedef ObserveCallback = void Function(T? previousValue, T value); + +/// Compares two values for equality. +/// +/// Return `true` when the update should be skipped because values are +/// considered equivalent. +typedef ValueComparator = bool Function(T? a, T? b); + +/// Lazily produces a value. +typedef ValueGetter = T Function(); + +/// A callback that returns no value. +typedef VoidCallback = ValueGetter; diff --git a/packages/solidart/lib/src/core/untracked.dart b/packages/solidart/lib/src/core/untracked.dart index ed833142..5d2738f2 100644 --- a/packages/solidart/lib/src/core/untracked.dart +++ b/packages/solidart/lib/src/core/untracked.dart @@ -1,14 +1,22 @@ -part of 'core.dart'; +part of '../solidart.dart'; -/// Execute a callback that will not be tracked by the reactive system. +/// Runs [callback] without tracking dependencies. /// -/// This can be useful inside Effects or Observations to prevent a signal from -/// being tracked. +/// This is useful when you want to read or write signals inside an effect +/// without establishing a dependency. +/// +/// ```dart +/// final count = Signal(0); +/// Effect(() { +/// print(count.value); +/// untracked(() => count.value = count.value + 1); +/// }); +/// ``` T untracked(T Function() callback) { - final prevSub = reactiveSystem.setCurrentSub(null); + final prevSub = preset.setActiveSub(); try { return callback(); } finally { - reactiveSystem.setCurrentSub(prevSub); + preset.setActiveSub(prevSub); } } diff --git a/packages/solidart/lib/src/extensions/observe_signal.dart b/packages/solidart/lib/src/extensions/observe_signal.dart new file mode 100644 index 00000000..f5254ec0 --- /dev/null +++ b/packages/solidart/lib/src/extensions/observe_signal.dart @@ -0,0 +1,30 @@ +part of '../solidart.dart'; + +/// Observes [ReadonlySignal] changes with previous and current values. +extension ObserveSignal on ReadonlySignal { + /// Observe the signal and invoke [listener] whenever the value changes. + /// + /// When [fireImmediately] is `true`, the listener runs once on subscription. + /// Returns a disposer that stops the observation. + DisposeObservation observe( + ObserveCallback listener, { + bool fireImmediately = false, + }) { + var skipped = false; + final effect = Effect( + () { + value; + if (!fireImmediately && !skipped) { + skipped = true; + return; + } + untracked(() { + listener(untrackedPreviousValue, untrackedValue); + }); + }, + detach: true, + ); + + return effect.dispose; + } +} diff --git a/packages/solidart/lib/src/extensions/until.dart b/packages/solidart/lib/src/extensions/until.dart index fa2a832b..ce4b3814 100644 --- a/packages/solidart/lib/src/extensions/until.dart +++ b/packages/solidart/lib/src/extensions/until.dart @@ -1,15 +1,14 @@ -import 'dart:async'; +part of '../solidart.dart'; -import 'package:solidart/solidart.dart'; - -/// Extension that adds the `until` method to [SignalBase] classes. -extension Until on SignalBase { - /// Returns the future that completes when the [condition] evaluates to true. - /// If the [condition] is already true, it completes immediately. +/// Waits until a signal satisfies a condition. +extension UntilSignal on ReadonlySignal { + /// Returns a future that completes when [condition] becomes true. + /// + /// If [condition] is already true, this returns the current value + /// immediately. /// - /// The [timeout] parameter specifies the maximum time to wait for the - /// condition to be met. If provided and the timeout is reached before the - /// condition is met, the future will complete with a [TimeoutException]. + /// When [timeout] is provided, the returned future completes with a + /// [TimeoutException] if the condition is not met in time. FutureOr until( bool Function(T value) condition, { Duration? timeout, @@ -23,25 +22,28 @@ extension Until on SignalBase { void dispose() { effect.dispose(); timer?.cancel(); + timer = null; } effect = Effect( () { - if (condition(value)) { - dispose(); - completer.complete(value); + final current = value; + if (!condition(current)) return; + dispose(); + if (!completer.isCompleted) { + completer.complete(current); } }, autoDispose: false, ); - // Start timeout timer if specified + onDispose(dispose); + if (timeout != null) { timer = Timer(timeout, () { - if (!completer.isCompleted) { - dispose(); - completer.completeError(TimeoutException(null, timeout)); - } + if (completer.isCompleted) return; + dispose(); + completer.completeError(TimeoutException(null, timeout)); }); } diff --git a/packages/solidart/lib/src/resources/resource.dart b/packages/solidart/lib/src/resources/resource.dart new file mode 100644 index 00000000..1c267213 --- /dev/null +++ b/packages/solidart/lib/src/resources/resource.dart @@ -0,0 +1,326 @@ +part of '../solidart.dart'; + +/// {@template solidart.resource} +/// # Resource +/// A resource is a signal designed for async data. It wraps the common states +/// of asynchronous work: `ready`, `loading`, and `error`. +/// +/// Resources can be driven by: +/// - a `fetcher` that returns a `Future` +/// - a `stream` that yields values over time +/// - an optional `source` signal that triggers refreshes +/// +/// Example using a fetcher: +/// ```dart +/// final userId = Signal(1); +/// +/// Future fetchUser() async { +/// final id = userId.value; +/// return 'user:$id'; +/// } +/// +/// final user = Resource(fetchUser, source: userId); +/// ``` +/// +/// The current state is available via [state] and provides helpers like +/// `when`, `maybeWhen`, `asReady`, `asError`, `isLoading`, and `isRefreshing`. +/// +/// The [resolve] method starts the resource once. The [refresh] method forces +/// a new fetch or re-subscribes to the stream. When [useRefreshing] is true, +/// refresh updates the current state with `isRefreshing` instead of resetting +/// to `loading`. +/// {@endtemplate} +class Resource extends Signal> { + /// {@macro solidart.resource} + /// + /// Creates a resource backed by a future-producing [fetcher]. + Resource( + this.fetcher, { + this.source, + this.lazy = true, + bool? useRefreshing, + bool? trackPreviousState, + this.debounceDelay, + bool? autoDispose, + String? name, + bool? trackInDevTools, + ValueComparator> equals = identical, + }) : stream = null, + useRefreshing = useRefreshing ?? SolidartConfig.useRefreshing, + super( + ResourceState.loading(), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: + trackPreviousState ?? SolidartConfig.trackPreviousValue, + trackInDevTools: trackInDevTools, + ) { + if (!lazy) { + _resolveIfNeeded(); + } + } + + /// {@macro solidart.resource} + /// + /// Creates a resource backed by a stream factory. + /// + /// Use this when your data source is an ongoing stream (e.g. sockets, + /// Firestore snapshots, or SSE). The stream is subscribed on resolve and + /// re-subscribed when [refresh] is called or when [source] changes. + /// + /// ```dart + /// final ticks = Resource.stream( + /// () => Stream.periodic(const Duration(seconds: 1), (i) => i), + /// lazy: false, + /// ); + /// ``` + /// + /// When a refresh happens, the previous subscription is cancelled and + /// events from older subscriptions are ignored. + Resource.stream( + this.stream, { + this.source, + this.lazy = true, + bool? useRefreshing, + bool? trackPreviousState, + this.debounceDelay, + bool? autoDispose, + String? name, + bool? trackInDevTools, + ValueComparator> equals = identical, + }) : fetcher = null, + useRefreshing = useRefreshing ?? SolidartConfig.useRefreshing, + super( + ResourceState.loading(), + autoDispose: autoDispose, + name: name, + equals: equals, + trackPreviousValue: + trackPreviousState ?? SolidartConfig.trackPreviousValue, + trackInDevTools: trackInDevTools, + ) { + if (!lazy) { + _resolveIfNeeded(); + } + } + + /// Optional source signal that triggers refreshes when it changes. + /// + /// When [source] updates, the resource refreshes. If [debounceDelay] is set, + /// multiple source changes are coalesced. + final ReadonlySignal? source; + + /// Fetches the resource value. + final Future Function()? fetcher; + + /// Provides a stream of resource values. + final Stream Function()? stream; + + /// Whether the resource is resolved lazily. + /// + /// When `true`, the resource resolves on first read or when [resolve] is + /// called explicitly. + final bool lazy; + + /// Whether to keep previous value while refreshing. + /// + /// When `true`, refresh updates the current state with `isRefreshing` rather + /// than replacing it with `loading`. + final bool useRefreshing; + + /// Optional debounce duration for source-triggered refreshes. + final Duration? debounceDelay; + + bool _resolved = false; + int _version = 0; + Future? _resolveFuture; + Effect? _sourceEffect; + StreamSubscription? _streamSubscription; + Timer? _debounceTimer; + + /// Returns the previous state (tracked read), or `null`. + /// + /// Previous state is available only after a tracked read. + ResourceState? get previousState { + _resolveIfNeeded(); + if (!_resolved) return null; + return previousValue; + } + + /// Returns the current state, resolving lazily if needed. + ResourceState get state { + _resolveIfNeeded(); + return value; + } + + /// Sets the current state. + set state(ResourceState next) => value = next; + + /// Returns the previous state without tracking. + ResourceState? get untrackedPreviousState => untrackedPreviousValue; + + /// Returns the current state without tracking. + ResourceState get untrackedState => untrackedValue; + + @override + void dispose() { + _debounceTimer?.cancel(); + _debounceTimer = null; + _sourceEffect?.dispose(); + _sourceEffect = null; + _streamSubscription?.cancel(); + _streamSubscription = null; + super.dispose(); + } + + /// Re-fetches or re-subscribes to the resource. + /// + /// If the resource has not been resolved yet, this triggers [resolve] + /// instead. + Future refresh() async { + if (isDisposed) return; + if (!_resolved) { + await resolve(); + return; + } + + if (fetcher != null) { + return _refetch(); + } + + if (stream != null) { + _resubscribe(); + return; + } + } + + /// Returns a future that completes with the value when the resource is ready. + /// + /// If the resource is already ready, it completes immediately. + Future untilReady() async { + final state = await Future.value(until((value) => value.isReady)); + return state.asReady!.value; + } + + /// Resolves the resource if it has not been resolved yet. + /// + /// Multiple calls are coalesced into a single in-flight resolve. + Future resolve() async { + if (isDisposed) return; + if (_resolveFuture != null) return _resolveFuture!; + if (_resolved) return; + + _resolved = true; + _resolveFuture = _doResolve().whenComplete(() { + _resolveFuture = null; + }); + + return _resolveFuture!; + } + + Future _doResolve() async { + if (fetcher != null) { + await _fetch(); + } + + if (stream != null) { + _subscribe(); + } + + if (source != null) { + _setupSourceEffect(); + } + } + + Future _fetch() async { + final requestId = ++_version; + try { + final result = await fetcher!(); + if (_isStale(requestId)) return; + state = ResourceState.ready(result); + } catch (e, s) { + if (_isStale(requestId)) return; + state = ResourceState.error(e, stackTrace: s); + } + } + + bool _isStale(int requestId) => requestId != _version || isDisposed; + + void _listenStream() { + final requestId = ++_version; + _streamSubscription = stream!().listen( + (data) { + if (_isStale(requestId)) return; + state = ResourceState.ready(data); + }, + onError: (Object error, StackTrace stackTrace) { + if (_isStale(requestId)) return; + state = ResourceState.error(error, stackTrace: stackTrace); + }, + ); + } + + Future _refetch() async { + _transition(); + return _fetch(); + } + + void _resolveIfNeeded() { + if (!_resolved) { + unawaited(resolve()); + } + } + + void _resubscribe() { + _streamSubscription?.cancel(); + _streamSubscription = null; + _transition(); + _listenStream(); + } + + void _setupSourceEffect() { + var skipped = false; + _sourceEffect = Effect( + () { + source!.value; + if (!skipped) { + skipped = true; + return; + } + if (debounceDelay != null) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(debounceDelay!, () { + if (isDisposed) return; + untracked(refresh); + }); + } else { + untracked(refresh); + } + }, + autoDispose: false, + ); + } + + void _subscribe() { + _listenStream(); + } + + void _transition() { + if (!useRefreshing) { + state = ResourceState.loading(); + return; + } + state.map( + ready: (ready) { + state = ready.copyWith(isRefreshing: true); + }, + error: (error) { + state = error.copyWith(isRefreshing: true); + }, + loading: (_) { + state = ResourceState.loading(); + }, + ); + } +} diff --git a/packages/solidart/lib/src/resources/resource_state.dart b/packages/solidart/lib/src/resources/resource_state.dart new file mode 100644 index 00000000..1ba5eeb7 --- /dev/null +++ b/packages/solidart/lib/src/resources/resource_state.dart @@ -0,0 +1,187 @@ +part of '../solidart.dart'; + +/// Error state containing an error and optional stack trace. +@immutable +class ResourceError implements ResourceState { + /// Creates an error state. + const ResourceError( + this.error, { + this.stackTrace, + this.isRefreshing = false, + }); + + /// The error object. + final Object error; + + /// Optional stack trace. + final StackTrace? stackTrace; + + /// Whether the resource is refreshing. + final bool isRefreshing; + + @override + int get hashCode => Object.hash(runtimeType, error, stackTrace, isRefreshing); + + @override + bool operator ==(Object other) { + return runtimeType == other.runtimeType && + other is ResourceError && + other.error == error && + other.stackTrace == stackTrace && + other.isRefreshing == isRefreshing; + } + + /// Returns a copy with updated fields. + ResourceError copyWith({ + Object? error, + StackTrace? stackTrace, + bool? isRefreshing, + }) { + return ResourceError( + error ?? this.error, + stackTrace: stackTrace ?? this.stackTrace, + isRefreshing: isRefreshing ?? this.isRefreshing, + ); + } + + @override + R map({ + required R Function(ResourceReady ready) ready, + required R Function(ResourceError error) error, + required R Function(ResourceLoading loading) loading, + }) { + return error(this); + } + + @override + String toString() { + return 'ResourceError<$T>(error: $error, stackTrace: $stackTrace, ' + 'refreshing: $isRefreshing)'; + } +} + +/// Loading state. +@immutable +class ResourceLoading implements ResourceState { + /// Creates a loading state. + const ResourceLoading(); + + @override + int get hashCode => runtimeType.hashCode; + + @override + bool operator ==(Object other) => runtimeType == other.runtimeType; + + @override + R map({ + required R Function(ResourceReady ready) ready, + required R Function(ResourceError error) error, + required R Function(ResourceLoading loading) loading, + }) { + return loading(this); + } + + @override + String toString() => 'ResourceLoading<$T>()'; +} + +/// Ready state containing data. +@immutable +class ResourceReady implements ResourceState { + /// Creates a ready state with [value]. + const ResourceReady(this.value, {this.isRefreshing = false}); + + /// The resource value. + final T value; + + /// Whether the resource is refreshing. + final bool isRefreshing; + + @override + int get hashCode => Object.hash(runtimeType, value, isRefreshing); + + @override + bool operator ==(Object other) { + return runtimeType == other.runtimeType && + other is ResourceReady && + other.value == value && + other.isRefreshing == isRefreshing; + } + + /// Returns a copy with updated fields. + ResourceReady copyWith({ + T? value, + bool? isRefreshing, + }) { + return ResourceReady( + value ?? this.value, + isRefreshing: isRefreshing ?? this.isRefreshing, + ); + } + + @override + R map({ + required R Function(ResourceReady ready) ready, + required R Function(ResourceError error) error, + required R Function(ResourceLoading loading) loading, + }) { + return ready(this); + } + + @override + String toString() { + return 'ResourceReady<$T>(value: $value, refreshing: $isRefreshing)'; + } +} + +/// {@template solidart.resource-state} +/// Represents the state of a [Resource]. +/// +/// A resource is always in one of: +/// - `ready(data)` when a value is available +/// - `loading()` while work is in progress +/// - `error(error)` when a failure occurs +/// +/// Use [ResourceStateExtensions] helpers to map or pattern-match: +/// ```dart +/// final state = resource.state; +/// final label = state.when( +/// ready: (data) => 'ready: $data', +/// error: (err, _) => 'error: $err', +/// loading: () => 'loading', +/// ); +/// ``` +/// {@endtemplate} +@sealed +@immutable +sealed class ResourceState { + /// Base constructor for resource states. + const ResourceState(); // coverage:ignore-line + + /// {@macro solidart.resource-state} + /// + /// Creates an error state. + const factory ResourceState.error( + Object error, { + StackTrace? stackTrace, + bool isRefreshing, + }) = ResourceError; + + /// {@macro solidart.resource-state} + /// + /// Creates a loading state. + const factory ResourceState.loading() = ResourceLoading; + + /// {@macro solidart.resource-state} + /// + /// Creates a ready state with [data]. + const factory ResourceState.ready(T data, {bool isRefreshing}) = + ResourceReady; + + /// Maps each concrete state to a value. + R map({ + required R Function(ResourceReady ready) ready, + required R Function(ResourceError error) error, + required R Function(ResourceLoading loading) loading, + }); +} diff --git a/packages/solidart/lib/src/resources/resource_state_extensions.dart b/packages/solidart/lib/src/resources/resource_state_extensions.dart new file mode 100644 index 00000000..2c298f16 --- /dev/null +++ b/packages/solidart/lib/src/resources/resource_state_extensions.dart @@ -0,0 +1,112 @@ +part of '../solidart.dart'; + +/// Convenience accessors for [ResourceState]. +/// +/// Includes common flags (`isLoading`, `isReady`, `hasError`), casting helpers +/// (`asReady`, `asError`), and pattern matching helpers (`when`, `maybeWhen`, +/// `maybeMap`). +extension ResourceStateExtensions on ResourceState { + /// Casts to [ResourceError] if possible. + ResourceError? get asError => map( + error: (e) => e, + ready: (_) => null, + loading: (_) => null, + ); + + /// Casts to [ResourceReady] if possible. + ResourceReady? get asReady => map( + ready: (r) => r, + error: (_) => null, + loading: (_) => null, + ); + + /// Returns the error for error state. + Object? get error => map( + error: (r) => r.error, + ready: (_) => null, + loading: (_) => null, + ); + + /// Whether this state is an error. + bool get hasError => this is ResourceError; + + /// Whether this state is loading. + bool get isLoading => this is ResourceLoading; + + /// Whether this state is ready. + bool get isReady => this is ResourceReady; + + /// Whether this state is marked as refreshing. + bool get isRefreshing => switch (this) { + ResourceReady(:final isRefreshing) => isRefreshing, + ResourceError(:final isRefreshing) => isRefreshing, + ResourceLoading() => false, + }; + + /// Returns the value for ready state, throws for error state. + T? get value => map( + ready: (r) => r.value, + // ignore: only_throw_errors + error: (r) => throw r.error, + loading: (_) => null, + ); + + /// Executes callbacks for available handlers, otherwise [orElse]. + R maybeMap({ + required R Function() orElse, + R Function(ResourceReady ready)? ready, + R Function(ResourceError error)? error, + R Function(ResourceLoading loading)? loading, + }) { + return map( + ready: (r) { + if (ready != null) return ready(r); + return orElse(); + }, + error: (e) { + if (error != null) return error(e); + return orElse(); + }, + loading: (l) { + if (loading != null) return loading(l); + return orElse(); + }, + ); + } + + /// Executes callbacks for available handlers, otherwise [orElse]. + R maybeWhen({ + required R Function() orElse, + R Function(T data)? ready, + R Function(Object error, StackTrace? stackTrace)? error, + R Function()? loading, + }) { + return map( + ready: (r) { + if (ready != null) return ready(r.value); + return orElse(); + }, + error: (e) { + if (error != null) return error(e.error, e.stackTrace); + return orElse(); + }, + loading: (l) { + if (loading != null) return loading(); + return orElse(); + }, + ); + } + + /// Executes callbacks for each state. + R when({ + required R Function(T data) ready, + required R Function(Object error, StackTrace? stackTrace) error, + required R Function() loading, + }) { + return map( + ready: (r) => ready(r.value), + error: (e) => error(e.error, e.stackTrace), + loading: (_) => loading(), + ); + } +} diff --git a/packages/solidart/lib/src/solidart.dart b/packages/solidart/lib/src/solidart.dart new file mode 100644 index 00000000..f1b525ca --- /dev/null +++ b/packages/solidart/lib/src/solidart.dart @@ -0,0 +1,34 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:developer' as dev; +import 'dart:math'; + +import 'package:meta/meta.dart'; +import 'package:solidart/deps/preset.dart' as preset; +import 'package:solidart/deps/system.dart' as system; + +part 'core/typedefs.dart'; +part 'core/configuration.dart'; +part 'core/disposable.dart'; +part 'core/identifier.dart'; +part 'core/option.dart'; +part 'core/readonly_signal.dart'; +part 'core/signal_configuration.dart'; +part 'core/config.dart'; +part 'core/devtools.dart'; +part 'core/batch.dart'; +part 'core/untracked.dart'; +part 'core/observer.dart'; +part 'core/effect.dart'; +part 'core/computed.dart'; +part 'core/signal.dart'; +part 'core/lazy_signal.dart'; +part 'core/list_signal.dart'; +part 'core/map_signal.dart'; +part 'core/set_signal.dart'; +part 'extensions/observe_signal.dart'; +part 'extensions/until.dart'; +part 'resources/resource.dart'; +part 'resources/resource_state.dart'; +part 'resources/resource_state_extensions.dart'; diff --git a/packages/solidart/lib/src/utils.dart b/packages/solidart/lib/src/utils.dart deleted file mode 100644 index 50413fa8..00000000 --- a/packages/solidart/lib/src/utils.dart +++ /dev/null @@ -1,183 +0,0 @@ -import 'dart:async'; - -import 'package:meta/meta.dart'; - -/// coverage:ignore-start -/// Signature of callbacks that have no arguments and return no data. -typedef VoidCallback = void Function(); - -/// Error callback -typedef ErrorCallback = void Function(Object error); - -/// The callback fired by the observer -typedef ObserveCallback = void Function(T? previousValue, T value); - -/// {@template solidartexception} -/// An Exception class to capture Solidart specific exceptions -/// {@endtemplate} -@immutable -class SolidartException extends Error implements Exception { - /// {@macro solidartexception} - SolidartException(this.message); - - /// The error message - final String message; - - @override - String toString() => message; -} - -/// {@template solidartreactionexception} -/// This exception would be fired when an reaction has a cycle and does -/// not stabilize in `ReactiveConfig.maxIterations` iterations -/// {@endtemplate} -class SolidartReactionException extends SolidartException { - /// {@macro solidartreactionexception} - SolidartReactionException(super.message); -} - -/// {@template solidartcaughtexception} -/// This captures the stack trace when user-land code throws an exception -/// {@endtemplate} -class SolidartCaughtException extends SolidartException { - /// {@macro solidartcaughtexception} - SolidartCaughtException(Object exception, {required StackTrace stackTrace}) - : _exception = exception, - _stackTrace = stackTrace, - super('SolidartException: $exception'); - - final Object _exception; - final StackTrace _stackTrace; - - /// the exception - Object get exception => _exception; - - /// The stacktrace - @override - StackTrace? get stackTrace => _stackTrace; -} - -/// Creates a delayer scheduler with the given [duration]. -Timer Function(void Function()) createDelayedScheduler(Duration duration) => - (fn) => Timer(duration, fn); - -/// The `Option` class represents an optional value. - -/// It is either `Some` or `None`. -/// Use `unwrap` to get the value of a `Some` or throw an exception if it is a -/// `None`. -/// Or safely switch the sealed class to get the value. -sealed class Option { - const Option(); - - /// Unwraps the option, yielding the content of a `Some`. - T unwrap() { - return switch (this) { - final Some some => some.value, - _ => throw Exception('Cannot unwrap None'), - }; - } - - /// Safe unwraps the option, yielding the content of a `Some` or `null`. - T? safeUnwrap() { - return switch (this) { - final Some some => some.value, - _ => null, - }; - } -} - -/// {@template some} -/// The `Some` class represents a value of type `T`. -/// {@endtemplate} -class Some extends Option { - /// {@macro some} - const Some(this.value); - - /// Te value of the `Some` class. - final T value; -} - -/// {@template none} -/// The `None` class represents an absence of a value. -/// {@endtemplate} -class None extends Option { - /// {@macro none} - const None(); -} - -/// {@template DebounceOperation} -/// The operation that must be performed is the [callback] -/// It will be perfomed when the [timer] completes and only -/// if the timer has not been cancelled before -/// {@endtemplate} -@immutable -class DebounceOperation { - /// {@macro DebounceOperation} - const DebounceOperation(this.callback, this.timer); - - /// The callback to be performed - final VoidCallback callback; - - /// The timer that will trigger the callback - final Timer timer; -} - -/// A static class for handling method call debouncing. -@immutable -abstract class Debouncer { - static final Map _operations = {}; - - /// Will delay the execution of [callback] with the given [duration]. If - /// another call to `debounce()` with the same [operationId] happens within - /// this duration, the first call will be cancelled and the debouncer will - /// start waiting for another [duration] before executing [callback]. - /// - /// [operationId] is any arbitrary Object, and is used to identify this - /// particular debounce operation in subsequent calls to `debounce()` or - /// `cancel()`. I recommend using `#someString` (a Symbol). - static void debounce( - Object operationId, - Duration duration, - VoidCallback callback, - ) { - _operations[operationId]?.timer.cancel(); - - _operations[operationId] = DebounceOperation( - callback, - Timer(duration, () { - fire(operationId); - }), - ); - } - - /// Fires the callback associated with [operationId] immediately. It also - /// cancels the debounce timer. - static void fire(Object operationId) { - _operations[operationId]?.timer.cancel(); - final operation = _operations.remove(operationId); - operation?.callback(); - } - - /// Cancels any active debounce operation with the given [operationId]. - static void cancel(Object operationId) { - _operations[operationId]?.timer.cancel(); - _operations.remove(operationId); - } - - /// Cancels all active debouncers. - static void cancelAll() { - for (final operation in _operations.values) { - operation.timer.cancel(); - } - _operations.clear(); - } - - /// Returns the number of active debouncers (debouncers that haven't yet - /// called their callbacks). - static int count() { - return _operations.length; - } -} - -/// coverage:ignore-end diff --git a/packages/solidart/pubspec.yaml b/packages/solidart/pubspec.yaml index 4a424839..66efac32 100644 --- a/packages/solidart/pubspec.yaml +++ b/packages/solidart/pubspec.yaml @@ -1,6 +1,6 @@ name: solidart description: A simple State Management solution for Dart applications inspired by SolidJS -version: 2.8.3 +version: 3.0.0-dev.0 repository: https://github.com/nank1ro/solidart documentation: https://solidart.mariuti.com topics: @@ -14,11 +14,12 @@ 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.1.1 collection: ^1.18.0 meta: ^1.11.0 dev_dependencies: + fake_async: ^1.3.3 mockito: ^5.5.1 test: ^1.25.3 very_good_analysis: ^10.0.0 diff --git a/packages/solidart/test/advanced_test.dart b/packages/solidart/test/advanced_test.dart new file mode 100644 index 00000000..1046f1e9 --- /dev/null +++ b/packages/solidart/test/advanced_test.dart @@ -0,0 +1,56 @@ +import 'package:solidart/advanced.dart'; +import 'package:solidart/deps/system.dart' as system; +import 'package:test/test.dart'; + +class _TestDisposable with DisposableMixin {} + +class _TestNode extends system.ReactiveNode + with DisposableMixin + implements Configuration { + _TestNode({required this.autoDispose}) + : super(flags: system.ReactiveFlags.none); + + @override + final bool autoDispose; + + @override + Identifier get identifier => throw UnimplementedError(); +} + +void main() { + test('DisposableMixin runs cleanup callbacks once', () { + final disposable = _TestDisposable(); + var calls = 0; + + disposable + ..onDispose(() => calls++) + ..onDispose(() => calls++); + + final dispose = disposable.dispose; + dispose(); + expect(calls, 2); + + // Subsequent dispose should be a no-op. + dispose(); + expect(calls, 2); + }); + + test('Disposable.unlinkDeps disposes autoDispose deps with no subs', () { + final dep = _TestNode(autoDispose: true); + final node = _TestNode(autoDispose: true); + final link = system.Link( + version: 0, + dep: dep, + sub: node, + ); + + // Set up a minimal dependency link where dep has no subscriber list. + node + ..deps = link + ..depsTail = link; + + Disposable.unlinkDeps(node); + + expect(dep.isDisposed, isTrue); + }); +} diff --git a/packages/solidart/test/auto_dispose_test.dart b/packages/solidart/test/auto_dispose_test.dart new file mode 100644 index 00000000..691a1ce0 --- /dev/null +++ b/packages/solidart/test/auto_dispose_test.dart @@ -0,0 +1,130 @@ +import 'package:solidart/deps/system.dart' as system; +import 'package:solidart/solidart.dart'; +import 'package:test/test.dart'; + +void main() { + late bool previousAutoDispose; + + setUp(() { + previousAutoDispose = SolidartConfig.autoDispose; + SolidartConfig.autoDispose = true; + }); + + tearDown(() { + SolidartConfig.autoDispose = previousAutoDispose; + }); + + test('disposing a signal cascades to its dependents', () { + final a = Signal(0); + final b = Computed(() => a.value * 2); + final c = Effect(() { + b.value; + }); + final d = Signal(0); + final e = Effect(() { + a.value; + d.value; + }); + + a.dispose(); + + expect(a.isDisposed, isTrue); + expect(b.isDisposed, isTrue); + expect(c.isDisposed, isTrue); + expect(e.isDisposed, isFalse); + expect(d.isDisposed, isFalse); + + final deps = _depsOf(e); + expect(deps.contains(a), isFalse); + expect(deps.contains(d), isTrue); + }); + + test( + 'disposing a computed cleans subscribers but keeps shared deps alive', + () { + final a = Signal(0); + final b = Computed(() => a.value * 2); + final c = Effect(() { + b.value; + }); + final e = Effect(() { + a.value; + }); + + b.dispose(); + + expect(b.isDisposed, isTrue); + expect(c.isDisposed, isTrue); + expect(a.isDisposed, isFalse); + expect(e.isDisposed, isFalse); + }, + ); + + test( + 'disposing an effect detaches dependencies and triggers auto dispose', + () { + final a = Signal(0); + final b = Computed(() => a.value + 1); + final c = Effect(() { + b.value; + }); + final e = Effect(() { + a.value; + }); + + c.dispose(); + + expect(c.isDisposed, isTrue); + expect(b.isDisposed, isTrue); + expect(a.isDisposed, isFalse); + expect(e.isDisposed, isFalse); + expect(_depsOf(e).contains(a), isTrue); + }, + ); + + test('respects explicit autoDispose false', () { + final a = Signal(0); + final b = Computed(() => a.value + 1, autoDispose: false); + final c = Effect(() { + b.value; + }, autoDispose: false); + + a.dispose(); + + expect(a.isDisposed, isTrue); + expect(b.isDisposed, isFalse); + expect(c.isDisposed, isFalse); + expect(_depsOf(b), isEmpty); + expect(_depsOf(c), isNotEmpty); + }); + + test('global autoDispose off but explicit opt-in still disposes', () { + SolidartConfig.autoDispose = false; + final a = Signal(0, autoDispose: true); + final b = Computed(() => a.value + 1, autoDispose: true); + final c = Effect(() { + b.value; + }, autoDispose: true); + final d = Effect(() { + a.value; + }, autoDispose: false); + + a.dispose(); + + expect(a.isDisposed, isTrue); + expect(b.isDisposed, isTrue); + expect(c.isDisposed, isTrue); + expect(d.isDisposed, isFalse); + expect(_depsOf(d), isEmpty); + }); +} + +List _depsOf(system.ReactiveNode node) { + final deps = []; + var link = node.deps; + while (link != null) { + deps.add(link.dep); + link = link.nextDep; + } + return deps; +} diff --git a/packages/solidart/test/batch_test.dart b/packages/solidart/test/batch_test.dart new file mode 100644 index 00000000..d6daae8d --- /dev/null +++ b/packages/solidart/test/batch_test.dart @@ -0,0 +1,31 @@ +import 'package:solidart/solidart.dart'; +import 'package:test/test.dart'; + +void main() { + test('batch groups updates and flushes once', () { + final x = Signal(10); + final y = Signal(20); + final total = Signal(30); + + final calls = <({int x, int y, int total})>[]; + + Effect(() { + calls.add((x: x.value, y: y.value, total: total.value)); + }); + + expect(calls, [ + (x: 10, y: 20, total: 30), + ]); + + batch(() { + x.value++; + y.value++; + total.value = x.value + y.value; + }); + + expect(calls, [ + (x: 10, y: 20, total: 30), + (x: 11, y: 21, total: 32), + ]); + }); +} diff --git a/packages/solidart/test/call_operator_test.dart b/packages/solidart/test/call_operator_test.dart new file mode 100644 index 00000000..c6c63c1d --- /dev/null +++ b/packages/solidart/test/call_operator_test.dart @@ -0,0 +1,46 @@ +import 'package:solidart/solidart.dart'; +import 'package:test/test.dart'; + +void main() { + test('Signal call() returns value and tracks dependencies', () { + final counter = Signal(0); + var runs = 0; + + Effect(() { + counter(); + runs++; + }); + + expect(runs, 1); + + counter.value = 1; + expect(runs, 2); + }); + + test('ReadonlySignal call() works on typed reference', () { + final counter = Signal(0); + final readonly = counter.toReadonly(); + + expect(readonly(), 0); + + counter.value = 2; + expect(readonly(), 2); + }); + + test('Computed call() returns value and tracks dependencies', () { + final source = Signal(1); + final doubled = Computed(() => source.value * 2); + var runs = 0; + + Effect(() { + doubled(); + runs++; + }); + + expect(runs, 1); + + source.value = 3; + expect(runs, 2); + expect(doubled(), 6); + }); +} diff --git a/packages/solidart/test/collections_test.dart b/packages/solidart/test/collections_test.dart new file mode 100644 index 00000000..7e0b2ade --- /dev/null +++ b/packages/solidart/test/collections_test.dart @@ -0,0 +1,807 @@ +import 'package:solidart/solidart.dart'; +import 'package:test/test.dart'; + +void main() { + group('ListSignal', () { + test('reacts to mutations', () { + final list = ListSignal([1, 2]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.add(3); + expect(runs, 2); + + list[0] = 1; + expect(runs, 2); + + list[0] = 5; + expect(runs, 3); + + list.remove(99); + expect(runs, 3); + + list.remove(5); + expect(runs, 4); + }); + + test('tracks previous value after read', () { + final list = ListSignal([1, 2]); + + final values = ( + initial: list.previousValue, + afterAdd: (list..add(3)).previousValue, + ); + + expect(values.initial, isNull); + expect(values.afterAdd, [1, 2]); + }); + + test('respects trackPreviousValue false', () { + final list = ListSignal([1], trackPreviousValue: false); + + final values = ( + previous: (list..add(2)).previousValue, + untracked: list.untrackedPreviousValue, + ); + + expect(values.previous, isNull); + expect(values.untracked, isNull); + }); + + test('no-op mutations do not notify', () { + final list = ListSignal([1, 2, 3]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list + ..addAll([]) + ..insertAll(1, []) + ..replaceRange(0, 0, []) + ..setAll(0, [1, 2, 3]) + ..setRange(0, 3, [1, 2, 3]) + ..fillRange(1, 1) + ..removeWhere((_) => false) + ..retainWhere((_) => true) + ..sort(); + + expect(runs, 1); + }); + + test('empty list no-op mutations do not notify', () { + final list = ListSignal([]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list + ..clear() + ..removeWhere((_) => true) + ..sort() + ..shuffle(); + + expect(runs, 1); + }); + + test('length setter modifies list', () { + final list = ListSignal([1, 2, 3, 4]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + expect(list.length, 4); + + // Test shortening the list + list.length = 2; + expect(runs, 2); + expect(list.value, [1, 2]); + + // Test setting to current length (no-op) + list.length = 2; + expect(runs, 2); + }); + + test('[] operator reads elements', () { + final list = ListSignal([1, 2, 3]); + var runs = 0; + + Effect(() { + final _ = list[1]; + runs++; + }); + + expect(runs, 1); + expect(list[0], 1); + expect(list[1], 2); + expect(list[2], 3); + + list[1] = 5; + expect(runs, 2); + }); + + test('insert and insertAll add elements', () { + final list = ListSignal([1, 3]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.insert(1, 2); + expect(runs, 2); + expect(list.value, [1, 2, 3]); + + list.insertAll(0, [-1, 0]); + expect(runs, 3); + expect(list.value, [-1, 0, 1, 2, 3]); + }); + + test('removeRange removes elements', () { + final list = ListSignal([1, 2, 3, 4, 5]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.removeRange(1, 3); + expect(runs, 2); + expect(list.value, [1, 4, 5]); + }); + + test('replaceRange replaces elements', () { + final list = ListSignal([1, 2, 3, 4]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.replaceRange(1, 3, [10, 20]); + expect(runs, 2); + expect(list.value, [1, 10, 20, 4]); + }); + + test('setAll modifies elements', () { + final list = ListSignal([1, 2, 3, 4]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.setAll(1, [10, 20]); + expect(runs, 2); + expect(list.value, [1, 10, 20, 4]); + }); + + test('setRange modifies elements', () { + final list = ListSignal([1, 2, 3, 4]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.setRange(1, 3, [10, 20]); + expect(runs, 2); + expect(list.value, [1, 10, 20, 4]); + }); + + test('fillRange fills elements', () { + final list = ListSignal([1, 2, 3, 4]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.fillRange(1, 3, 0); + expect(runs, 2); + expect(list.value, [1, 0, 0, 4]); + }); + + test('shuffle randomizes list', () { + final list = ListSignal([1, 2, 3, 4, 5]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.shuffle(); + expect(runs, 2); + expect(list.length, 5); + // Can't test exact order due to randomness, but all elements should still + // exist. + expect(list.value.toSet(), {1, 2, 3, 4, 5}); + }); + + test('addAll appends elements', () { + final list = ListSignal([1]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.addAll([2, 3]); + + expect(runs, 2); + expect(list.value, [1, 2, 3]); + }); + + test('removeAt and removeLast return removed values', () { + final list = ListSignal([1, 2, 3]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + final removedAt = list.removeAt(1); + expect(removedAt, 2); + expect(runs, 2); + expect(list.value, [1, 3]); + + final removedLast = list.removeLast(); + expect(removedLast, 3); + expect(runs, 3); + expect(list.value, [1]); + }); + + test('clear empties non-empty list', () { + final list = ListSignal([1, 2]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.clear(); + + expect(runs, 2); + expect(list.value, isEmpty); + }); + + test('sort reorders list when needed', () { + final list = ListSignal([2, 1]); + var runs = 0; + + Effect(() { + final _ = list[0]; + runs++; + }); + + expect(runs, 1); + + list.sort(); + + expect(runs, 2); + expect(list.value, [1, 2]); + }); + + test('removeWhere and retainWhere update list', () { + final list = ListSignal([1, 2, 3, 4]); + var runs = 0; + + Effect(() { + list.length; + runs++; + }); + + expect(runs, 1); + + list.removeWhere((value) => value.isEven); + expect(runs, 2); + expect(list.value, [1, 3]); + + list.retainWhere((value) => value > 1); + expect(runs, 3); + expect(list.value, [3]); + }); + + test('cast and toString expose list details', () { + final list = ListSignal([1, 2]); + final casted = list.cast(); + + expect(casted, isA>()); + expect((casted as ListSignal).value, [1, 2]); + expect(list.toString(), contains('ListSignal')); + expect(list.toString(), contains('value: [1, 2]')); + }); + }); + + group('MapSignal', () { + test('reacts to mutations', () { + final map = MapSignal({'a': 1}); + var runs = 0; + + Effect(() { + final _ = map['a']; + runs++; + }); + + expect(runs, 1); + + map['a'] = 1; + expect(runs, 1); + + map['a'] = 2; + expect(runs, 2); + + map.remove('missing'); + expect(runs, 2); + + map.remove('a'); + expect(runs, 3); + }); + + test('tracks previous value after read', () { + final map = MapSignal({'a': 1}); + + final previous = (map..['a'] = 2).previousValue; + + expect(previous, {'a': 1}); + }); + + test('no-op mutations do not notify', () { + final map = MapSignal({'a': 1, 'b': 2}); + var runs = 0; + + Effect(() { + map.length; + runs++; + }); + + expect(runs, 1); + + map + ..addAll({}) + ..updateAll((key, value) => value) + ..removeWhere((key, value) => false) + ..putIfAbsent('a', () => 99); + + expect(runs, 1); + }); + + test('addAll updates existing keys', () { + final map = MapSignal({'a': 1}); + var runs = 0; + + Effect(() { + final _ = map['a']; + runs++; + }); + + expect(runs, 1); + + map.addAll({'a': 1}); + expect(runs, 1); + + map.addAll({'a': 2}); + expect(runs, 2); + }); + + test('empty map no-op mutations do not notify', () { + final map = MapSignal({}); + var runs = 0; + + Effect(() { + map.length; + runs++; + }); + + expect(runs, 1); + + map + ..clear() + ..addAll({}) + ..removeWhere((key, value) => true) + ..updateAll((key, value) => value); + + expect(runs, 1); + }); + + test('putIfAbsent adds new keys', () { + final map = MapSignal({'a': 1}); + var runs = 0; + + Effect(() { + map.length; + runs++; + }); + + expect(runs, 1); + + final value1 = map.putIfAbsent('a', () => 99); + expect(value1, 1); + expect(runs, 1); + + final value2 = map.putIfAbsent('b', () => 2); + expect(value2, 2); + expect(runs, 2); + expect(map.value, {'a': 1, 'b': 2}); + }); + + test('update modifies existing keys', () { + final map = MapSignal({'a': 1, 'b': 2}); + var runs = 0; + + Effect(() { + final _ = map['a']; + runs++; + }); + + expect(runs, 1); + + map.update('a', (value) => value + 10); + expect(runs, 2); + expect(map['a'], 11); + + map.update('c', (value) => value, ifAbsent: () => 3); + expect(runs, 3); + expect(map['c'], 3); + }); + + test('updateAll modifies all values', () { + final map = MapSignal({'a': 1, 'b': 2}); + var runs = 0; + + Effect(() { + map.length; + runs++; + }); + + expect(runs, 1); + + map.updateAll((key, value) => value * 2); + expect(runs, 2); + expect(map.value, {'a': 2, 'b': 4}); + }); + + test('removeWhere removes matching entries', () { + final map = MapSignal({'a': 1, 'b': 2, 'c': 3}); + var runs = 0; + + Effect(() { + map.length; + runs++; + }); + + expect(runs, 1); + + map.removeWhere((key, value) => value.isEven); + expect(runs, 2); + expect(map.value, {'a': 1, 'c': 3}); + }); + + test('containsKey checks for keys', () { + final map = MapSignal({'a': 1, 'b': 2}); + var runs = 0; + + Effect(() { + map.containsKey('a'); + runs++; + }); + + expect(runs, 1); + expect(map.containsKey('a'), true); + expect(map.containsKey('c'), false); + + map['c'] = 3; + expect(runs, 2); + }); + + test('containsValue checks for values', () { + final map = MapSignal({'a': 1, 'b': 2}); + var runs = 0; + + Effect(() { + map.containsValue(1); + runs++; + }); + + expect(runs, 1); + expect(map.containsValue(1), true); + expect(map.containsValue(3), false); + + map['a'] = 10; + expect(runs, 2); + }); + + test('keys/isEmpty/isNotEmpty and clear reflect state', () { + final map = MapSignal({'a': 1, 'b': 2}); + var runs = 0; + + Effect(() { + map.keys; + runs++; + }); + + expect(runs, 1); + expect(map.keys.toSet(), {'a', 'b'}); + expect(map.isEmpty, isFalse); + expect(map.isNotEmpty, isTrue); + + map.clear(); + + expect(runs, 2); + expect(map.value, isEmpty); + expect(map.isEmpty, isTrue); + expect(map.isNotEmpty, isFalse); + }); + + test('update throws when key missing and no ifAbsent', () { + final map = MapSignal({'a': 1}); + + expect( + () => map.update('b', (value) => value + 1), + throwsA(isA()), + ); + }); + + test('toString reports current and previous values', () { + final map = MapSignal({'a': 1}); + map['a'] = 2; + map.value; + + final description = map.toString(); + + expect(description, contains('MapSignal')); + expect(description, contains('value: {a: 2}')); + }); + }); + + group('SetSignal', () { + test('reacts to mutations', () { + final set = SetSignal({1}); + var runs = 0; + + Effect(() { + set.contains(1); + runs++; + }); + + expect(runs, 1); + + set.add(1); + expect(runs, 1); + + set.add(2); + expect(runs, 2); + + set.remove(3); + expect(runs, 2); + + set.remove(1); + expect(runs, 3); + }); + + test('tracks previous value after read', () { + final set = SetSignal({1}); + + final previous = (set..add(2)).previousValue; + + expect(previous, {1}); + }); + + test('no-op mutations do not notify', () { + final set = SetSignal({1, 2}); + var runs = 0; + + Effect(() { + set.length; + runs++; + }); + + expect(runs, 1); + + set + ..addAll([]) + ..removeAll([]) + ..retainAll({1, 2}) + ..removeWhere((_) => false) + ..retainWhere((_) => true); + + expect(runs, 1); + }); + + test('empty set no-op mutations do not notify', () { + final set = SetSignal({}); + var runs = 0; + + Effect(() { + set.length; + runs++; + }); + + expect(runs, 1); + + set + ..clear() + ..addAll([]) + ..removeAll([]) + ..retainAll({}); + + expect(runs, 1); + }); + + test('lookup finds elements', () { + final set = SetSignal({1, 2, 3}); + var runs = 0; + + Effect(() { + set.lookup(2); + runs++; + }); + + expect(runs, 1); + expect(set.lookup(2), 2); + expect(set.lookup(4), isNull); + + set.add(4); + expect(runs, 2); + }); + + test('addAll with existing elements', () { + final set = SetSignal({1, 2}); + var runs = 0; + + Effect(() { + set.length; + runs++; + }); + + expect(runs, 1); + + set.addAll([1, 2, 3, 4]); + expect(runs, 2); + expect(set.value, {1, 2, 3, 4}); + }); + + test('removeAll removes multiple elements', () { + final set = SetSignal({1, 2, 3, 4, 5}); + var runs = 0; + + Effect(() { + set.length; + runs++; + }); + + expect(runs, 1); + + set.removeAll([2, 4]); + expect(runs, 2); + expect(set.value, {1, 3, 5}); + }); + + test('retainAll keeps only specified elements', () { + final set = SetSignal({1, 2, 3, 4, 5}); + var runs = 0; + + Effect(() { + set.length; + runs++; + }); + + expect(runs, 1); + + set.retainAll([2, 4, 6]); + expect(runs, 2); + expect(set.value, {2, 4}); + }); + + test('iterator, toSet, cast, and toString work as expected', () { + final set = SetSignal({1, 2}); + + final iterator = set.iterator; + expect(iterator.moveNext(), isTrue); + + expect(set.toSet(), {1, 2}); + + final casted = set.cast(); + expect(casted, isA>()); + expect((casted as SetSignal).value, {1, 2}); + + final description = set.toString(); + expect(description, contains('SetSignal')); + expect(description, contains('value: {1, 2}')); + }); + + test('clear empties non-empty set', () { + final set = SetSignal({1, 2}); + var runs = 0; + + Effect(() { + set.length; + runs++; + }); + + expect(runs, 1); + + set.clear(); + + expect(runs, 2); + expect(set.value, isEmpty); + }); + + test('removeWhere and retainWhere update set', () { + final set = SetSignal({1, 2, 3, 4}); + var runs = 0; + + Effect(() { + set.length; + runs++; + }); + + expect(runs, 1); + + set.removeWhere((value) => value.isEven); + expect(runs, 2); + expect(set.value, {1, 3}); + + set.retainWhere((value) => value > 1); + expect(runs, 3); + expect(set.value, {3}); + }); + }); + + test('MapSignal cast returns a new typed signal', () { + final map = MapSignal({'a': 1}); + final casted = map.cast(); + + expect(casted, isA>()); + expect((casted as MapSignal)['a'], 1); + }); +} diff --git a/packages/solidart/test/devtools_test.dart b/packages/solidart/test/devtools_test.dart new file mode 100644 index 00000000..2c18c562 --- /dev/null +++ b/packages/solidart/test/devtools_test.dart @@ -0,0 +1,130 @@ +import 'package:solidart/solidart.dart'; +import 'package:test/test.dart'; + +class _Observer implements SolidartObserver { + int created = 0; + int updated = 0; + int disposed = 0; + + @override + void didCreateSignal(ReadonlySignal signal) { + created++; + } + + @override + void didUpdateSignal(ReadonlySignal signal) { + updated++; + } + + @override + void didDisposeSignal(ReadonlySignal signal) { + disposed++; + } +} + +class _ConstObserver extends SolidartObserver { + const _ConstObserver(); + + @override + void didCreateSignal(ReadonlySignal signal) {} + + @override + void didUpdateSignal(ReadonlySignal signal) {} + + @override + void didDisposeSignal(ReadonlySignal signal) {} +} + +void main() { + late bool previousDevToolsEnabled; + late List previousObservers; + + setUp(() { + previousDevToolsEnabled = SolidartConfig.devToolsEnabled; + previousObservers = List.of(SolidartConfig.observers); + SolidartConfig.devToolsEnabled = true; + SolidartConfig.observers.clear(); + }); + + tearDown(() { + SolidartConfig.devToolsEnabled = previousDevToolsEnabled; + SolidartConfig.observers + ..clear() + ..addAll(previousObservers); + }); + + test('notifies observers on create/update/dispose', () { + final observer = _Observer(); + SolidartConfig.observers.add(observer); + + final signal = Signal(0); + + expect(observer.created, 1); + expect(observer.updated, 0); + expect(observer.disposed, 0); + + signal + ..value = 1 + ..value; + + expect(observer.updated, 1); + + signal.dispose(); + + expect(observer.disposed, 1); + }); + + test('trackInDevTools false disables notifications', () { + final observer = _Observer(); + SolidartConfig.observers.add(observer); + + Signal(0, trackInDevTools: false) + ..value = 1 + ..value + ..dispose(); + + expect(observer.created, 0); + expect(observer.updated, 0); + expect(observer.disposed, 0); + }); + + test('trackInDevTools true overrides global disabled default', () { + SolidartConfig.devToolsEnabled = false; + final observer = _Observer(); + SolidartConfig.observers.add(observer); + + final _ = Signal(0, trackInDevTools: true); + + expect(observer.created, 1); + }); + + test('SolidartObserver supports const subclasses', () { + const observer = _ConstObserver(); + expect(observer, isA()); + }); + + test('Computed participates in DevTools events', () { + final observer = _Observer(); + SolidartConfig.observers.add(observer); + + final source = Signal(1); + final computed = Computed(() => source.value * 2); + + // Verify creation events for both source and computed. + expect(observer.created, 2); + + expect(computed.value, 2); + final updatedAfterFirstRead = observer.updated; + + source.value = 2; + expect(source.value, 2); + final updatedAfterSource = observer.updated; + expect(updatedAfterSource, greaterThan(updatedAfterFirstRead)); + + expect(computed.value, 4); + expect(observer.updated, greaterThan(updatedAfterSource)); + + source.dispose(); + computed.dispose(); + }); +} diff --git a/packages/solidart/test/effect_test.dart b/packages/solidart/test/effect_test.dart new file mode 100644 index 00000000..5917c0e0 --- /dev/null +++ b/packages/solidart/test/effect_test.dart @@ -0,0 +1,155 @@ +import 'package:solidart/deps/system.dart' as system; +import 'package:solidart/solidart.dart'; +import 'package:test/test.dart'; + +void main() { + test('Effect runs immediately and reacts to dependency changes', () { + final count = Signal(0); + var runs = 0; + + Effect(() { + count.value; + runs++; + }); + + expect(runs, 1); + + count.value = 1; + expect(runs, 2); + + // Setting the same value should be ignored + count.value = 1; + expect(runs, 2); + }); + + test('Effect.manual is lazy until run is called', () { + final count = Signal(0); + var runs = 0; + + final effect = Effect.manual(() { + count.value; + runs++; + }); + + // No run yet, so nothing tracked + count.value = 1; + expect(runs, 0); + + effect.run(); + expect(runs, 1); + + count.value = 2; + expect(runs, 2); + }); + + test('dispose stops reactions and detaches dependencies', () { + final count = Signal(0); + var runs = 0; + + final effect = Effect(() { + count.value; + runs++; + }); + + expect(_depsOf(effect), contains(count)); + + effect.dispose(); + + expect(effect.isDisposed, isTrue); + expect(_depsOf(effect), isEmpty); + + count.value = 1; + expect(runs, 1); + }); + + test( + 'nested effects attach to parent by default and auto dispose with it', + () { + final previousAutoDispose = SolidartConfig.autoDispose; + SolidartConfig.autoDispose = true; + addTearDown(() { + SolidartConfig.autoDispose = previousAutoDispose; + }); + + late Effect child; + final parent = Effect(() { + child = Effect(() {}); + }); + + expect(_depsOf(parent), contains(child)); + + parent.dispose(); + + expect(parent.isDisposed, isTrue); + expect(child.isDisposed, isTrue); + }, + ); + + test('detached effects stay alive when parent is disposed', () { + final previousAutoDispose = SolidartConfig.autoDispose; + final previousDetachEffects = SolidartConfig.detachEffects; + SolidartConfig.autoDispose = true; + SolidartConfig.detachEffects = false; + addTearDown(() { + SolidartConfig.autoDispose = previousAutoDispose; + SolidartConfig.detachEffects = previousDetachEffects; + }); + + final source = Signal(0); + var childRuns = 0; + late Effect child; + final parent = Effect(() { + child = Effect(() { + source.value; + childRuns++; + }, detach: true); + source.value; + }); + + expect(_depsOf(parent), isNot(contains(child))); + + parent.dispose(); + + expect(parent.isDisposed, isTrue); + expect(child.isDisposed, isFalse); + + source.value = 1; + expect(childRuns, 2); // initial run + one more after change + + child.dispose(); + }); + + test('global detachEffects detaches nested effects by default', () { + final previousAutoDispose = SolidartConfig.autoDispose; + final previousDetachEffects = SolidartConfig.detachEffects; + SolidartConfig.autoDispose = true; + SolidartConfig.detachEffects = true; + addTearDown(() { + SolidartConfig.autoDispose = previousAutoDispose; + SolidartConfig.detachEffects = previousDetachEffects; + }); + + late Effect child; + final parent = Effect(() { + child = Effect(() {}); + }); + + expect(_depsOf(parent), isNot(contains(child))); + + parent.dispose(); + expect(parent.isDisposed, isTrue); + expect(child.isDisposed, isFalse); + + child.dispose(); + }); +} + +List _depsOf(system.ReactiveNode node) { + final deps = []; + var link = node.deps; + while (link != null) { + deps.add(link.dep); + link = link.nextDep; + } + return deps; +} diff --git a/packages/solidart/test/equals_test.dart b/packages/solidart/test/equals_test.dart new file mode 100644 index 00000000..6e8bccbd --- /dev/null +++ b/packages/solidart/test/equals_test.dart @@ -0,0 +1,74 @@ +import 'package:solidart/solidart.dart'; +import 'package:test/test.dart'; + +void main() { + test('Signal respects custom equals comparator', () { + var runs = 0; + final s = Signal( + 0, + equals: (a, b) => a == b, + ); + Effect(() { + s.value; + runs++; + }); + + expect(runs, 1, reason: 'effect runs once on creation'); + + s.value = 0; // same value, should be ignored + expect(runs, 1); + + s.value = 1; // different value, should rerun + expect(runs, 2); + + s.value = 1; // same again, ignored + expect(runs, 2); + }); + + test('Computed respects custom equals comparator', () { + var runs = 0; + final source = Signal(0); + final comp = Computed( + () => source.value, + equals: (a, b) { + final prevParity = (a ?? 0).isEven; + final nextParity = (b ?? 0).isEven; + return prevParity == nextParity; + }, + ); + + Effect(() { + comp.value; + runs++; + }); + + expect(runs, 1); + + source.value = 2; // parity unchanged (even), skip recompute + expect(runs, 1); + + source.value = 3; // parity changed (odd), recompute + expect(runs, 2); + }); + + test('Signal.toReadonly() converts to readonly', () { + final signal = Signal(42); + final readonly = signal.toReadonly(); + + expect(readonly, isA>()); + expect(readonly.value, 42); + + // Verify it tracks changes + var runs = 0; + Effect(() { + readonly.value; + runs++; + }); + + expect(runs, 1); + + signal.value = 100; + expect(runs, 2); + expect(readonly.value, 100); + }); +} diff --git a/packages/solidart/test/observe_signal_test.dart b/packages/solidart/test/observe_signal_test.dart new file mode 100644 index 00000000..9a29f00b --- /dev/null +++ b/packages/solidart/test/observe_signal_test.dart @@ -0,0 +1,58 @@ +import 'package:solidart/solidart.dart'; +import 'package:test/test.dart'; + +void main() { + group('ObserveSignal', () { + test('skips initial run and reports changes', () { + final signal = Signal(0); + final calls = >[]; + + final dispose = signal.observe((previous, value) { + calls.add([previous, value]); + }); + + expect(calls, isEmpty); + + signal.value = 1; + expect( + calls, + equals(>[ + [0, 1], + ]), + ); + + dispose(); + signal.value = 2; + expect( + calls, + equals(>[ + [0, 1], + ]), + ); + + signal.dispose(); + }); + + test('fires immediately when requested', () { + final signal = Signal(5); + final calls = >[]; + + final dispose = signal.observe( + (previous, value) { + calls.add([previous, value]); + }, + fireImmediately: true, + ); + + expect( + calls, + equals(>[ + [null, 5], + ]), + ); + + dispose(); + signal.dispose(); + }); + }); +} diff --git a/packages/solidart/test/option_test.dart b/packages/solidart/test/option_test.dart new file mode 100644 index 00000000..7ca95dc6 --- /dev/null +++ b/packages/solidart/test/option_test.dart @@ -0,0 +1,26 @@ +import 'package:solidart/src/solidart.dart'; +import 'package:test/test.dart'; + +void main() { + group('Option', () { + test('Some.unwrap() returns value', () { + const some = Some(42); + expect(some.unwrap(), 42); + }); + + test('None.unwrap() throws StateError', () { + const none = None(); + expect(none.unwrap, throwsStateError); + }); + + test('Some.safeUnwrap() returns value', () { + const some = Some(42); + expect(some.safeUnwrap(), 42); + }); + + test('None.safeUnwrap() returns null', () { + const none = None(); + expect(none.safeUnwrap(), isNull); + }); + }); +} diff --git a/packages/solidart/test/previous_value_test.dart b/packages/solidart/test/previous_value_test.dart new file mode 100644 index 00000000..2a552051 --- /dev/null +++ b/packages/solidart/test/previous_value_test.dart @@ -0,0 +1,163 @@ +import 'package:solidart/solidart.dart'; +import 'package:test/test.dart'; + +const _skip = Object(); + +void expectPreviousValues( + ReadonlySignal signal, { + Object? previous = _skip, + Object? untracked = _skip, +}) { + if (previous != _skip && untracked != _skip) { + final values = ( + previous: signal.previousValue, + untracked: signal.untrackedPreviousValue, + ); + + expect(values.previous, previous); + expect(values.untracked, untracked); + return; + } + + if (previous != _skip) { + expect(signal.previousValue, previous); + } + + if (untracked != _skip) { + expect(signal.untrackedPreviousValue, untracked); + } +} + +void main() { + group('Signal previous value', () { + test('tracks previousValue and untrackedPreviousValue', () { + final signal = Signal(0); + + expectPreviousValues(signal, previous: null, untracked: null); + + signal.value = 1; + + expectPreviousValues(signal, previous: 0, untracked: 0); + + signal.value = 2; + + expectPreviousValues(signal, previous: 1, untracked: 1); + }); + + test('updates previous only after read', () { + final signal = Signal(0); + + expectPreviousValues(signal..value = 1, untracked: null); + + final _ = signal.value; + + expectPreviousValues(signal, untracked: 0); + }); + + test('respects trackPreviousValue false', () { + final signal = Signal(0, trackPreviousValue: false); + + expectPreviousValues( + signal + ..value = 1 + ..value, + previous: null, + untracked: null, + ); + }); + }); + + group('Computed previous value', () { + test('tracks previousValue and untrackedPreviousValue', () { + final source = Signal(1); + final computed = Computed(() => source.value * 2); + + expectPreviousValues(computed, previous: null, untracked: null); + expect(computed.value, 2); + + source.value = 2; + + expectPreviousValues(computed, previous: 2, untracked: 2); + expect(computed.value, 4); + }); + + test('updates previous only after read', () { + final source = Signal(1); + final computed = Computed(() => source.value * 2); + + { + final _ = computed.value; + } + + source.value = 2; + + expectPreviousValues(computed, untracked: null); + + { + final _ = computed.value; + } + + expectPreviousValues(computed, untracked: 2); + }); + + test('respects trackPreviousValue false', () { + final source = Signal(1); + final computed = Computed( + () => source.value * 2, + trackPreviousValue: false, + ); + + { + final _ = computed.value; + } + source.value = 2; + { + final _ = computed.value; + } + + expectPreviousValues(computed, previous: null, untracked: null); + }); + + test('untrackedValue computes when not initialized', () { + final source = Signal(2); + final computed = Computed(() => source.value * 2); + + final value = computed.untrackedValue; + + expect(value, 4); + }); + + test('untrackedValue returns cached value when available', () { + final source = Signal(3); + final computed = Computed(() => source.value * 2); + + expect(computed.value, 6); + + final value = computed.untrackedValue; + + expect(value, 6); + }); + }); + + group('LazySignal previous value', () { + test('throws when read before initialization', () { + final lazy = LazySignal(); + expect(() => lazy.value, throwsStateError); + }); + + test('tracks previous only after initialized and read', () { + final lazy = LazySignal(); + + expectPreviousValues(lazy..value = 1, previous: null, untracked: null); + expect(lazy.isInitialized, isTrue); + + lazy.value = 2; + + expectPreviousValues(lazy, untracked: null); + + final _ = lazy.value; + + expectPreviousValues(lazy, untracked: 1); + }); + }); +} diff --git a/packages/solidart/test/resource_test.dart b/packages/solidart/test/resource_test.dart new file mode 100644 index 00000000..185650b2 --- /dev/null +++ b/packages/solidart/test/resource_test.dart @@ -0,0 +1,679 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:solidart/solidart.dart'; +import 'package:test/test.dart'; + +void main() { + group('fetcher resources', () { + test('coalesces concurrent resolve calls', () async { + final completer = Completer(); + var calls = 0; + final resource = Resource(() { + calls++; + return completer.future; + }); + + final first = resource.resolve(); + final second = resource.resolve(); + + expect(calls, 1); + + completer.complete(10); + await Future.wait([first, second]); + + expect(resource.state.asReady?.value, 10); + expect(calls, 1); + }); + + test('refresh before resolve triggers a single fetch', () async { + final completer = Completer(); + var calls = 0; + final resource = Resource(() { + calls++; + return completer.future; + }); + + final refreshFuture = resource.refresh(); + expect(calls, 1); + + completer.complete(5); + await refreshFuture; + + expect(resource.state.asReady?.value, 5); + expect(calls, 1); + }); + + test('lazy resource resolves on first read', () async { + var calls = 0; + final resource = Resource(() async { + calls++; + return 1; + }); + + expect(calls, 0); + + final state = resource.state; + expect(state.isLoading, isTrue); + + await Future.delayed(Duration.zero); + + expect(calls, 1); + expect(resource.state.asReady?.value, 1); + }); + + test('refresh marks ready state as refreshing when enabled', () async { + var value = 0; + final resource = Resource( + () async => ++value, + useRefreshing: true, + lazy: false, + ); + + await resource.resolve(); + + expect(resource.state.asReady?.isRefreshing, isFalse); + + final refreshFuture = resource.refresh(); + + expect(resource.state.asReady?.isRefreshing, isTrue); + + await refreshFuture; + + expect(resource.state.asReady?.value, 2); + expect(resource.state.asReady?.isRefreshing, isFalse); + }); + + test('refresh goes to loading when useRefreshing is false', () async { + final completer1 = Completer(); + final completer2 = Completer(); + var calls = 0; + + final resource = Resource( + () { + calls++; + return calls == 1 ? completer1.future : completer2.future; + }, + useRefreshing: false, + lazy: false, + ); + + await Future.delayed(Duration.zero); + completer1.complete(1); + await Future.delayed(Duration.zero); + + expect(resource.state.asReady?.value, 1); + + final refreshFuture = resource.refresh(); + + expect(resource.state.isLoading, isTrue); + + completer2.complete(2); + await refreshFuture; + + expect(resource.state.asReady?.value, 2); + }); + + test('refresh uses latest response when requests overlap', () async { + final completer1 = Completer(); + final completer2 = Completer(); + var calls = 0; + + final resource = Resource( + () { + calls++; + return calls == 1 ? completer1.future : completer2.future; + }, + ); + + final _ = resource.state; + await Future.delayed(Duration.zero); + expect(calls, 1); + + final refreshFuture = resource.refresh(); + expect(calls, 2); + + completer2.complete(2); + await refreshFuture; + expect(resource.state.asReady?.value, 2); + + completer1.complete(1); + await Future.delayed(Duration.zero); + + expect(resource.state.asReady?.value, 2); + }); + + test( + 'fetcher error transitions to error then recovers on refresh', + () async { + var shouldThrow = true; + + final resource = Resource( + () async { + if (shouldThrow) { + throw StateError('boom'); + } + return 42; + }, + lazy: false, + ); + + await resource.resolve(); + + expect(resource.state.hasError, isTrue); + + shouldThrow = false; + await resource.refresh(); + + expect(resource.state.asReady?.value, 42); + }, + ); + + test('source change triggers a single refresh', () async { + final source = Signal(0); + var calls = 0; + + final resource = Resource( + () async { + calls++; + return source.value; + }, + source: source, + lazy: false, + ); + + await Future.delayed(Duration.zero); + expect(calls, 1); + + source.value = 1; + await Future.delayed(Duration.zero); + + expect(calls, 2); + + await Future.delayed(Duration.zero); + + expect(calls, 2); + + resource.dispose(); + }); + + test('debounce groups source-triggered refreshes', () { + fakeAsync((async) { + final source = Signal(0); + var calls = 0; + + final resource = Resource( + () async { + calls++; + return source.value; + }, + source: source, + debounceDelay: const Duration(milliseconds: 50), + lazy: false, + ); + + async.flushMicrotasks(); + + expect(calls, 1); + + source + ..value = 1 + ..value = 2; + + async + ..elapse(const Duration(milliseconds: 49)) + ..flushMicrotasks(); + + expect(calls, 1); + + async + ..elapse(const Duration(milliseconds: 1)) + ..flushMicrotasks(); + + expect(calls, 2); + + resource.dispose(); + }); + }); + + test('previousState updates after read', () async { + var value = 0; + final resource = Resource( + () async => ++value, + lazy: false, + ); + + await resource.resolve(); + { + final _ = resource.state; + } + + expect(resource.untrackedPreviousState?.isLoading, isTrue); + + await resource.refresh(); + + expect(resource.untrackedPreviousState?.isLoading, isTrue); + + { + final _ = resource.state; + } + + expect(resource.untrackedPreviousState?.asReady?.value, 1); + }); + + test('dispose cancels debounce and prevents refresh', () { + fakeAsync((async) { + final source = Signal(0); + var calls = 0; + + final resource = Resource( + () async { + calls++; + return source.value; + }, + source: source, + debounceDelay: const Duration(milliseconds: 50), + lazy: false, + ); + + async.flushMicrotasks(); + expect(calls, 1); + + source.value = 1; + resource.dispose(); + + async + ..elapse(const Duration(milliseconds: 50)) + ..flushMicrotasks(); + + expect(calls, 1); + }); + }); + + test('dispose ignores in-flight fetch result', () async { + final completer = Completer(); + var calls = 0; + final resource = Resource(() { + calls++; + return completer.future; + }); + + final resolveFuture = resource.resolve(); + expect(calls, 1); + + resource.dispose(); + + completer.complete(42); + await resolveFuture; + await Future.delayed(Duration.zero); + + expect(resource.untrackedState.isLoading, isTrue); + }); + }); + + group('stream resources', () { + test('refresh cancels and resubscribes', () async { + var listenCount = 0; + var cancelCount = 0; + final controller = StreamController.broadcast( + onListen: () => listenCount++, + onCancel: () => cancelCount++, + ); + + final resource = Resource.stream( + () => controller.stream, + lazy: false, + ); + + await Future.delayed(Duration.zero); + expect(listenCount, 1); + + controller.add(1); + await Future.delayed(Duration.zero); + expect(resource.state.asReady?.value, 1); + + await resource.refresh(); + await Future.delayed(Duration.zero); + expect(cancelCount, 1); + expect(listenCount, 2); + + controller.add(2); + await Future.delayed(Duration.zero); + expect(resource.state.asReady?.value, 2); + + await controller.close(); + resource.dispose(); + }); + + test('refresh ignores events from previous stream', () async { + final controller1 = StreamController(); + final controller2 = StreamController(); + var index = 0; + final streams = [controller1.stream, controller2.stream]; + + final resource = Resource.stream( + () => streams[index++], + lazy: false, + ); + + await Future.delayed(Duration.zero); + controller1.add(1); + await Future.delayed(Duration.zero); + expect(resource.state.asReady?.value, 1); + + await resource.refresh(); + await Future.delayed(Duration.zero); + + controller1.add(99); + await Future.delayed(Duration.zero); + expect(resource.state.asReady?.value, 1); + + controller2.add(2); + await Future.delayed(Duration.zero); + expect(resource.state.asReady?.value, 2); + + await controller1.close(); + await controller2.close(); + resource.dispose(); + }); + + test('stream errors update state', () async { + final controller = StreamController(); + final resource = Resource.stream( + () => controller.stream, + lazy: false, + ); + + await Future.delayed(Duration.zero); + + controller.addError(StateError('boom'), StackTrace.current); + await Future.delayed(Duration.zero); + + expect(resource.state.hasError, isTrue); + + await controller.close(); + resource.dispose(); + }); + + test('dispose stops stream updates', () async { + final controller = StreamController(); + final resource = Resource.stream( + () => controller.stream, + lazy: false, + ); + + await Future.delayed(Duration.zero); + expect(controller.hasListener, isTrue); + + resource.dispose(); + + expect(controller.hasListener, isFalse); + + await controller.close(); + }); + }); + + group('resource state extensions', () { + test('flags and accessors for ready/loading/error', () { + const ready = ResourceState.ready(1, isRefreshing: true); + const loading = ResourceState.loading(); + final error = ResourceState.error( + StateError('boom'), + stackTrace: StackTrace.current, + isRefreshing: true, + ); + + expect(ready.isReady, isTrue); + expect(ready.isLoading, isFalse); + expect(ready.hasError, isFalse); + expect(ready.isRefreshing, isTrue); + expect(ready.asReady?.value, 1); + expect(ready.asError, isNull); + expect(ready.value, 1); + expect(ready.error, isNull); + + expect(loading.isLoading, isTrue); + expect(loading.isReady, isFalse); + expect(loading.hasError, isFalse); + expect(loading.isRefreshing, isFalse); + expect(loading.asReady, isNull); + expect(loading.asError, isNull); + expect(loading.value, isNull); + expect(loading.error, isNull); + + expect(error.hasError, isTrue); + expect(error.isReady, isFalse); + expect(error.isLoading, isFalse); + expect(error.isRefreshing, isTrue); + expect(error.asReady, isNull); + expect(error.asError?.error, isA()); + expect(error.error, isA()); + expect(() => error.value, throwsA(isA())); + }); + + test('when/maybeWhen/maybeMap behave as expected', () { + const ready = ResourceState.ready(2); + final error = ResourceState.error(StateError('boom')); + const loading = ResourceState.loading(); + + expect( + ready.when( + ready: (value) => 'ready $value', + error: (_, stackTrace) => 'error', + loading: () => 'loading', + ), + 'ready 2', + ); + + expect( + error.when( + ready: (_) => 'ready', + error: (err, stackTrace) => err.toString(), + loading: () => 'loading', + ), + 'Bad state: boom', + ); + + expect( + loading.when( + ready: (_) => 'ready', + error: (_, stackTrace) => 'error', + loading: () => 'loading', + ), + 'loading', + ); + + expect( + ready.maybeWhen(orElse: () => 'fallback'), + 'fallback', + ); + + expect( + error.maybeWhen( + orElse: () => 'fallback', + error: (err, stackTrace) => err.runtimeType.toString(), + ), + 'StateError', + ); + + expect( + loading.maybeMap( + orElse: () => 'fallback', + loading: (_) => 'loading', + ), + 'loading', + ); + }); + + test('maybeWhen and maybeMap use provided handlers', () { + const ready = ResourceState.ready(3); + final error = ResourceState.error(StateError('boom')); + const loading = ResourceState.loading(); + + expect( + ready.maybeWhen( + orElse: () => 'fallback', + ready: (value) => 'ready $value', + ), + 'ready 3', + ); + + expect( + error.maybeWhen( + orElse: () => 'fallback', + error: (err, stackTrace) => err.runtimeType.toString(), + ), + 'StateError', + ); + + expect( + loading.maybeWhen( + orElse: () => 'fallback', + loading: () => 'loading', + ), + 'loading', + ); + + expect( + ready.maybeMap( + orElse: () => 'fallback', + ready: (state) => 'ready ${state.value}', + ), + 'ready 3', + ); + + expect( + error.maybeMap( + orElse: () => 'fallback', + error: (state) => state.error.runtimeType.toString(), + ), + 'StateError', + ); + }); + + test( + 'maybeWhen and maybeMap fall back to orElse when handler is absent', + () { + final error = ResourceState.error(StateError('boom')); + const loading = ResourceState.loading(); + final ready = ResourceState.ready(DateTime.now().microsecond); + + expect( + error.maybeWhen(orElse: () => 'fallback'), + 'fallback', + ); + + expect( + loading.maybeWhen(orElse: () => 'fallback'), + 'fallback', + ); + + expect( + ready.maybeMap(orElse: () => 'fallback'), + 'fallback', + ); + + expect( + error.maybeMap(orElse: () => 'fallback'), + 'fallback', + ); + + expect( + loading.maybeMap(orElse: () => 'fallback'), + 'fallback', + ); + }, + ); + + test('ResourceReady equality and copyWith', () { + const ready1 = ResourceReady(42); + const ready2 = ResourceReady(42); + const ready3 = ResourceReady(43); + + expect(ready1, equals(ready2)); + expect(ready1, isNot(equals(ready3))); + expect(ready1.hashCode, equals(ready2.hashCode)); + + final copied = ready1.copyWith(value: 100); + expect(copied.value, 100); + expect(copied, isNot(equals(ready1))); + }); + + test('ResourceError equality and copyWith', () { + const error1 = ResourceError('error1', stackTrace: StackTrace.empty); + const error2 = ResourceError('error1', stackTrace: StackTrace.empty); + const error3 = ResourceError('error2', stackTrace: StackTrace.empty); + + expect(error1, equals(error2)); + expect(error1, isNot(equals(error3))); + expect(error1.hashCode, equals(error2.hashCode)); + + final copied = error1.copyWith(error: 'new error'); + expect(copied.error, 'new error'); + expect(copied, isNot(equals(error1))); + + final copiedStack = error1.copyWith(stackTrace: StackTrace.current); + expect(copiedStack.stackTrace, isNot(equals(error1.stackTrace))); + }); + + test('ResourceLoading equality and hashCode', () { + const loading1 = ResourceLoading(); + const loading2 = ResourceLoading(); + + expect(loading1, equals(loading2)); + expect(loading1.hashCode, equals(loading2.hashCode)); + }); + + test('ResourceState toString outputs useful descriptions', () { + const ready = ResourceReady(1, isRefreshing: true); + const loading = ResourceLoading(); + const error = ResourceError( + 'boom', + stackTrace: StackTrace.empty, + isRefreshing: true, + ); + + expect(ready.toString(), contains('ResourceReady')); + expect(ready.toString(), contains('refreshing: true')); + expect(loading.toString(), 'ResourceLoading()'); + expect(error.toString(), contains('ResourceError')); + expect(error.toString(), contains('refreshing: true')); + }); + + test('ResourceState factories can be invoked at runtime', () { + final state = ResourceState.ready(DateTime.now().microsecond); + + expect(state, isA>()); + }); + }); + + group('Resource previousState', () { + test('tracks previous state after transitions', () async { + final resource = Resource(() async => 42); + + expect(resource.previousState, isNull); + + await resource.resolve(); + expect(resource.state.asReady?.value, 42); + expect(resource.previousState?.isLoading, isTrue); + + await resource.refresh(); + expect(resource.previousState?.asReady?.value, 42); + }); + + test('untrackedPreviousState does not create dependencies', () async { + final resource = Resource(() async => 42); + var runs = 0; + + Effect(() { + resource.untrackedPreviousState; + runs++; + }); + + expect(runs, 1); + + await resource.resolve(); + expect(runs, 1); // Should not trigger effect + }); + }); +} diff --git a/packages/solidart/test/solidart_test.dart b/packages/solidart/test/solidart_test.dart deleted file mode 100644 index 121880e5..00000000 --- a/packages/solidart/test/solidart_test.dart +++ /dev/null @@ -1,2077 +0,0 @@ -// ignore_for_file: cascade_invocations, unreachable_from_main - -import 'dart:async'; -import 'dart:math'; - -import 'package:collection/collection.dart'; -import 'package:meta/meta.dart'; -import 'package:mockito/mockito.dart'; -import 'package:solidart/src/core/core.dart'; -import 'package:solidart/src/extensions/until.dart'; -import 'package:solidart/src/utils.dart'; -import 'package:test/test.dart'; - -sealed class MyEvent {} - -class MyEventA implements MyEvent { - MyEventA(this.value); - - final int value; -} - -class MyEventB implements MyEvent { - MyEventB(this.value); - - final String value; -} - -class MockCallbackFunction extends Mock { - void call(); -} - -class MockCallbackFunctionWithValue extends Mock { - void call(T value); -} - -class _A { - _A(); -} - -class _B { - _B(this.c); - final _C c; -} - -class _C { - _C(this.count); - - final int count; -} - -@immutable -class User { - const User({required this.id}); - final int id; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is User && runtimeType == other.runtimeType && id == other.id; - - @override - int get hashCode => runtimeType.hashCode ^ id.hashCode; - - @override - String toString() => 'User(id: $id)'; -} - -class SampleList { - SampleList(this.numbers); - final List numbers; -} - -class MockSolidartObserver extends Mock implements SolidartObserver {} - -void main() { - group( - 'Signal tests - ', - () { - test( - 'with equals true it notifies only when the value changes', - () async { - final counter = Signal(0); - - final cb = MockCallbackFunction(); - final unobserve = counter.observe((_, _) => cb()); - - expect(counter(), 0); - - counter.value = 1; - await pumpEventQueue(); - expect(counter.value, 1); - - counter.value = 2; - counter.value = 2; - counter.value = 2; - - await pumpEventQueue(); - counter.value = 3; - - expect(counter.value, 3); - await pumpEventQueue(); - verify(cb()).called(3); - // clear - unobserve(); - }, - ); - - test('with the identical comparator it notifies only when the comparator ' - 'returns false', () async { - final signal = Signal<_A?>(null); - final cb = MockCallbackFunction(); - final unobserve = signal.observe((_, _) => cb()); - - expect(signal.value, null); - final a = _A(); - - signal.value = a; - signal.value = a; - signal.value = a; - - await pumpEventQueue(); - - signal.value = _A(); - await pumpEventQueue(); - verify(cb()).called(2); - - // clear - unobserve(); - }); - - test('check onDispose callback fired when disposing signal', () { - final s = Signal(0); - final cb = MockCallbackFunction(); - s - ..onDispose(cb.call) - ..dispose(); - verify(cb()).called(1); - }); - - test('observe fireImmediately works', () { - final s = Signal(0); - final cb = MockCallbackFunction(); - final unobserve = s.observe( - (previousValue, value) => cb.call(), - fireImmediately: true, - ); - verify(cb()).called(1); - addTearDown(() { - s.dispose(); - unobserve(); - }); - }); - - test('check previousValue stores the previous value', () { - final s = Signal(0); - expect( - s.previousValue, - null, - reason: 'The signal should have a null previousValue', - ); - - s.value++; - - expect( - s.previousValue, - 0, - reason: 'The signal should have 0 has previousValue', - ); - - s.updateValue((value) => value * 5); - expect( - s.previousValue, - 1, - reason: 'The signal should have 1 has previousValue', - ); - }); - - test('test until()', () async { - final count = Signal(0); - - unawaited( - expectLater(count.until((value) => value > 5), completion(11)), - ); - count.value = 2; - count.value = 11; - }); - - test( - 'test until() with timeout - condition met before timeout', - () async { - final count = Signal(0); - - unawaited( - expectLater( - count.until( - (value) => value > 5, - timeout: const Duration(milliseconds: 500), - ), - completion(11), - ), - ); - // Wait a bit then update the value before timeout - await Future.delayed(const Duration(milliseconds: 100)); - count.value = 11; - }, - ); - - test( - 'test until() with timeout - timeout occurs before condition', - () async { - final count = Signal(0); - - unawaited( - expectLater( - count.until( - (value) => value > 5, - timeout: const Duration(milliseconds: 100), - ), - throwsA(isA()), - ), - ); - - // Don't update the value, let it timeout - await Future.delayed(const Duration(milliseconds: 200)); - }, - ); - - test( - '''test until() with timeout - condition already met returns immediately''', - () async { - final count = Signal(10); // Value already meets condition - - final result = await count.until( - (value) => value > 5, - timeout: const Duration(milliseconds: 100), - ); - - expect(result, 10); - }, - ); - - test('test until() with timeout - proper cleanup on timeout', () async { - final count = Signal(0); - - // Create an until that will timeout - unawaited( - Future.value( - count.until( - (value) => value > 5, - timeout: const Duration(milliseconds: 50), - ), - ).catchError((_) { - // Expected to timeout, return a dummy value - return 0; - }), - ); - - // Wait for timeout to occur - await Future.delayed(const Duration(milliseconds: 100)); - - // Now update the value - if cleanup worked properly, - // no additional effects should trigger - count.value = 10; - - // Give time for any potential side effects - await Future.delayed(const Duration(milliseconds: 50)); - - // If we reach here without issues, cleanup worked - expect(count.value, 10); - }); - - test('test until() with timeout - proper cleanup on success', () async { - final count = Signal(0); - - final future = count.until( - (value) => value > 5, - timeout: const Duration(milliseconds: 200), - ); - - // Update value to meet condition - count.value = 10; - - // Wait for completion - final result = await future; - expect(result, 10); - - // Give time for cleanup - await Future.delayed(const Duration(milliseconds: 50)); - - // Additional changes shouldn't affect anything since effect was - // disposed - count.value = 15; - - // If we reach here without issues, cleanup worked - expect(count.value, 15); - }); - - test('check toString()', () { - final s = Signal(0); - expect(s.toString(), startsWith('Signal(value: 0')); - }); - - test('check custom name', () { - final s = ReadableSignal(0, name: 'custom-name'); - expect(s.name, 'custom-name'); - }); - - test('check custom name when lazy', () { - final s = ReadableSignal.lazy(name: 'lazy-custom-name'); - expect(s.name, 'lazy-custom-name'); - }); - - test('check Signal becomes ReadSignal', () { - final s = Signal(0); - expect(s, const TypeMatcher>()); - expect(s.toReadSignal(), const TypeMatcher>()); - }); - - test('Signal is disposed after dispose', () { - final s = Signal(0); - expect(s.disposed, false); - s.dispose(); - expect(s.disposed, true); - }); - - test('Signal toggle', () { - final signal = Signal(false); - expect(signal.value, false); - signal.toggle(); - expect(signal.value, true); - }); - - test('lazy Signal', () { - final signal = Signal.lazy(); - expect(signal.hasValue, false); - signal.value = true; - expect(signal.hasValue, true); - }); - - test( - '''lazy Signal trows StateError when accessing value before setting one''', - () { - final signal = Signal.lazy(); - expect(() => signal.value, throwsStateError); - }, - ); - - test('untrackedValue', () { - final counter = Signal(0); - - final cb = MockCallbackFunction(); - final unobserve = Effect( - () { - counter.untrackedValue; - counter.untrackedPreviousValue; - cb(); - }, - onError: (error) { - //ignore - }, - ); - addTearDown(unobserve); - - counter.value = 1; - - // An effect is always triggered once - verify(cb()).called(1); - }); - - test('Test untracked', () { - final count = Signal(0); - final effectCount = Signal(0); - int fn() => effectCount.value + 1; - - final cb = MockCallbackFunction(); - Effect(() { - count.value; - cb.call(); - - // Whenever this effect is triggered, run `fn` that gives new value - effectCount.value = untracked(fn); - }); - - expect(count.value, 0); - expect(effectCount.value, 1); - - count.value = 1; - expect(effectCount.value, 2); - - verify(cb()).called(2); - }); - - test("Check signal disposed isn't tracked by Computed", () { - final count = Signal(1); - final doubleCount = Computed(() => count.value * 2); - - expect(doubleCount.value, 2); - expect(count.disposed, false); - - count.dispose(); - expect(count.disposed, true); - expect(doubleCount.disposed, true); - - count.value = 2; - expect(doubleCount.value, 2); - }); - - test('Check Signal autoDisposes if no longer used', () { - final count = Signal(0, autoDispose: true); - final effect = Effect(() => count.value); - - expect(count.disposed, false); - expect(effect.disposed, false); - - effect.dispose(); - expect(effect.disposed, true); - expect(count.disposed, true); - }); - - test('Check Signal do not autoDisposes if no longer used', () { - final count = Signal(0, autoDispose: false); - final effect = Effect(() => count.value); - - expect(count.disposed, false); - expect(effect.disposed, false); - - effect.dispose(); - expect(effect.disposed, true); - expect(count.disposed, false); - - count.value = 1; - expect(count.value, 1); - }); - - test('shouldUpdate should return true if there is a new value', () { - final signal = Signal(0); - expect(signal.shouldUpdate(), false); - signal.value = 1; - expect(signal.shouldUpdate(), true); - // After checking, should be false again until a new value is set - expect(signal.shouldUpdate(), false); - }); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - - group( - 'Effect tests = ', - () { - test('check effect reaction', () async { - final signal1 = Signal(0); - final signal2 = Signal(0); - - final cb = MockCallbackFunctionWithValue(); - Effect(() => cb(signal1.value)); - Effect(() => cb(signal2.value)); - - signal1.value = 1; - await pumpEventQueue(); - verify(cb(1)).called(1); - signal1.value = 2; - await pumpEventQueue(); - verify(cb(2)).called(1); - signal2.value = 4; - signal1.value = 4; - await pumpEventQueue(); - verify(cb(4)).called(2); - }); - - test('check effect reaction with delay', () async { - final cb = MockCallbackFunction(); - final disposeEffect = Effect( - cb, - delay: const Duration(milliseconds: 500), - autoDispose: false, - onError: (error) { - // ignore - }, - ); - addTearDown(disposeEffect.dispose); - verifyNever(cb()); - await Future.delayed(const Duration(milliseconds: 501)); - verify(cb()).called(1); - }); - - test('check effect onError', () async { - Object? detectedError; - Effect( - () => throw Exception(), - onError: (error) { - detectedError = error; - }, - ); - expect(detectedError, isA()); - }); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - - group( - 'Computed tests - ', - () { - test( - 'check that a Computed updates only for the selected value', - () async { - final klass = _B(_C(0)); - final s = Signal(klass); - final selected = Computed(() => s.value.c.count); - final cb = MockCallbackFunctionWithValue(); - - // A computed always has a value - expect(selected.hasValue, true); - - void listener() { - cb(selected.value); - } - - final unobserve = selected.observe((_, _) => listener()); - - s.value = _B(_C(1)); - await pumpEventQueue(); - - s.value = _B(_C(5)); - await pumpEventQueue(); - - s.value = _B(_C(1)); - await pumpEventQueue(); - - verify(cb(1)).called(2); - s.value = _B(_C(2)); - await pumpEventQueue(); - s.value = _B(_C(2)); - await pumpEventQueue(); - s.value = _B(_C(3)); - await pumpEventQueue(); - verify(cb(2)).called(1); - verify(cb(3)).called(1); - - // clear - unobserve(); - }, - ); - - test('Computed contains previous value', () async { - final signal = Signal(0); - final derived = Computed(() => signal.value * 2); - await pumpEventQueue(); - expect(derived.hasPreviousValue, false); - expect(derived.previousValue, null); - - signal.value = 1; - await pumpEventQueue(); - expect(derived.hasPreviousValue, true); - expect(derived.previousValue, 0); - - signal.value = 2; - await pumpEventQueue(); - expect(derived.hasPreviousValue, true); - expect(derived.previousValue, 2); - - signal.value = 1; - await pumpEventQueue(); - expect(derived.hasPreviousValue, true); - expect(derived.previousValue, 4); - }); - - test('signal has previous value', () { - final s = Signal(0); - expect(s.hasPreviousValue, false); - s.value = 1; - expect(s.hasPreviousValue, true); - }); - - test('nullable derived signal', () async { - final count = Signal(0); - final doubleCount = Computed(() { - if (count.value == null) return null; - return count.value! * 2; - }); - - await pumpEventQueue(); - expect(doubleCount.value, 0); - - count.value = 1; - await pumpEventQueue(); - expect(doubleCount.value, 2); - - count.value = null; - await pumpEventQueue(); - expect(doubleCount.value, null); - }); - - test('derived signal disposes', () async { - final count = Signal(0); - final doubleCount = Computed(() => count.value * 2); - expect(doubleCount.disposed, false); - doubleCount.dispose(); - expect(doubleCount.disposed, true); - }); - - test('check derived signal that throws', () async { - final count = Signal(1); - final doubleCount = Computed( - () { - if (count.value == 1) { - return count.value * 2; - } - return throw Exception(); - }, - ); - - count.value = 3; - expect( - () => doubleCount.value, - throwsA(const TypeMatcher()), - ); - }); - - test('check toString computed', () { - final count = Signal(1); - final doubleCount = Computed(() => count.value * 2); - - expect(doubleCount.toString(), startsWith('Computed(value: 2')); - }); - - test("check disposed Computed won't react", () { - final count = Signal(0); - final doubleCount = Computed(() => count.value * 2); - var onDisposeCalled = false; - doubleCount.onDispose(() { - onDisposeCalled = true; - }); - final cb = MockCallbackFunctionWithValue(); - doubleCount.observe((_, _) { - cb(doubleCount.value); - }); - - expect(doubleCount.disposed, false); - expect(onDisposeCalled, false); - - count.value = 1; - expect(doubleCount.value, 2); - verify(cb(2)).called(1); - - doubleCount.dispose(); - expect(doubleCount.disposed, true); - expect(onDisposeCalled, true); - - count.value = 2; - expect(doubleCount(), 2); - verifyNever(cb(2)); - }); - - test( - 'Check Computed runs manually by counting the number of runs', - () async { - final cb = MockCallbackFunction(); - final count = Signal(0); - final doubleCount = Computed(() { - cb(); - return count.value * 2; - }); - // trigger reactive value - doubleCount.value; - // run manually twice - doubleCount.run(); - doubleCount.run(); - // 3 times in total, 1 automatically and 2 manually - verify(cb()).called(3); - }, - ); - - test('Check Computed autoDisposes if no longer used', () { - final count = Signal(0); - final doubleCount = Computed(() => count.value * 2, autoDispose: true); - - expect(count.disposed, false); - expect(doubleCount.disposed, false); - - count.value = 1; - expect(count.value, 1); - expect(doubleCount.value, 2); - - count.dispose(); - expect(count.disposed, true); - // After disposing, the Computed should be disposed - expect(doubleCount.disposed, true); - - // Changing the source signal should not trigger the Computed anymore - count.value = 2; - expect(doubleCount.value, 2); - }); - - test('Check Computed do not autoDisposes if no longer used', () { - final count = Signal(0); - final doubleCount = Computed(() => count.value * 2, autoDispose: false); - - expect(count.disposed, false); - expect(doubleCount.disposed, false); - - count.value = 1; - expect(count.value, 1); - expect(doubleCount.value, 2); - - count.dispose(); - expect(count.disposed, true); - // After disposing, the Computed should NOT be disposed - expect(doubleCount.disposed, false); - - // Changing the source signal should not trigger the Computed anymore - // because the count signal is disposed - count.value = 2; - expect(doubleCount.value, 2); - }); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - - group( - 'ReadSignal tests', - () { - // test('check ReadSignal value and listener count', () { - // final s = ReadSignal(0); - // expect(s.value, 0); - // expect(s.previousValue, null); - // expect(s.listenerCount, 0); - // - // Effect((_) { - // s(); - // }); - // expect(s.listenerCount, 1); - // }); - - test('check toString()', () { - final s = ReadableSignal(0); - expect(s.toString(), startsWith('ReadSignal(value: 0')); - }); - - test('check untrackedValue throws if no value', () { - final count = Signal.lazy(); - expect(() => count.untrackedValue, throwsStateError); - }); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - - group( - 'Resource tests', - () { - test('check Resource with stream', () async { - final streamController = StreamController(); - addTearDown(streamController.close); - - final resource = Resource.stream(() => streamController.stream); - expect(resource.state, isA>()); - expect(resource.untrackedState, isA>()); - expect(resource.previousState, isNull); - expect(resource.untrackedPreviousState, isNull); - streamController.add(1); - await pumpEventQueue(); - expect(resource.state, isA>()); - expect(resource.untrackedState, isA>()); - expect(resource.previousState, isA>()); - expect(resource.untrackedPreviousState, isA>()); - expect(resource.state.value, 1); - expect(resource.untrackedState.value, 1); - - streamController.add(10); - await pumpEventQueue(); - expect(resource(), isA>()); - expect( - resource.previousState, - isA>().having( - (p0) => p0.value, - 'previousState value', - 1, - ), - ); - expect(resource.state.value, 10); - - streamController.addError(UnimplementedError()); - await pumpEventQueue(); - expect(resource.state, isA>()); - expect(resource.state.error, isUnimplementedError); - }); - - test('check Resource with stream and source', () async { - final count = Signal(-1); - - final resource = Resource.stream( - () { - if (count.value < 1) return Stream.value(0); - return Stream.value(count.value); - }, - source: count, - lazy: false, - ); - - addTearDown(() { - resource.dispose(); - count.dispose(); - }); - - await pumpEventQueue(); - expect( - resource.state, - isA>().having((p0) => p0.value, 'equal to 0', 0), - ); - - count.value = 5; - await pumpEventQueue(); - expect( - resource.state, - isA>().having((p0) => p0.value, 'equal to 5', 5), - ); - }); - - test('check Resource with changing stream', () async { - final streamControllerA = StreamController(); - final streamControllerB = StreamController(); - final source = Signal(0); - addTearDown(() { - streamControllerA.close(); - streamControllerB.close(); - source.dispose(); - }); - - final resource = Resource.stream( - () { - if (source.value.isEven) { - return streamControllerA.stream; - } - return streamControllerB.stream; - }, - source: source, - ); - expect(resource.state, isA>()); - streamControllerA.add(1); - await pumpEventQueue(); - expect(resource.state, isA>()); - expect(resource.state.value, 1); - - // changing to stream B - source.value = 1; - expect( - resource.state, - isA>().having( - (p0) => p0.isRefreshing, - 'Should be refreshing', - true, - ), - ); - - // add to stream A, the value should not be propagated - // because we're listening to stream B - streamControllerA.add(2); - await pumpEventQueue(); - expect(resource.state.value, 1); - - streamControllerA.add(3); - source.value = 2; - await pumpEventQueue(); - expect(resource.state.value, 3); - }); - - test('check Resource with future that throws', () async { - Future getUser() => throw Exception(); - final resource = Resource( - getUser, - lazy: false, - ); - - addTearDown(resource.dispose); - - await pumpEventQueue(); - expect(resource.state, isA>()); - expect(resource.state.error, isException); - }); - - test('check Resource with future', () async { - final userId = Signal(0); - - Future getUser() { - if (userId.value == 2) throw Exception(); - return Future.value(User(id: userId.value)); - } - - final resource = Resource( - getUser, - source: userId, - lazy: false, - ); - - await pumpEventQueue(); - expect(resource.state, isA>()); - expect(resource.state.value, const User(id: 0)); - - userId.value = 1; - await pumpEventQueue(); - expect(resource.state, isA>()); - expect(resource.state.value, const User(id: 1)); - - userId.value = 2; - await pumpEventQueue(); - expect(resource.state, isA>()); - expect(resource.state.error, isException); - - userId.value = 3; - await pumpEventQueue(); - await resource.refresh(); - expect(resource.state, isA>()); - expect(resource.state.hasError, false); - expect(resource.state.asError, isNull); - expect(resource.state.isLoading, false); - expect(resource.state.asReady, isNotNull); - expect(resource.state.isReady, true); - - resource.dispose(); - }); - - test('check Resource with useRefreshing false', () async { - final userId = Signal(0); - - Future getUser() { - if (userId.value == 2) throw Exception(); - return Future.value(User(id: userId.value)); - } - - final resource = Resource( - getUser, - source: userId, - useRefreshing: false, - lazy: false, - ); - - addTearDown(resource.dispose); - addTearDown(userId.dispose); - - await pumpEventQueue(); - expect(resource.state, isA>()); - expect(resource.state.value, const User(id: 0)); - - userId.value = 1; - expect(resource.state, isA>()); - }); - - test('update ResourceState', () async { - Future fetcher() => Future.value(1); - final resource = Resource(fetcher); - expect(resource.state, isA>()); - await pumpEventQueue(); - expect( - resource.state, - isA>().having( - (p0) => p0.value, - 'value equal to 1', - 1, - ), - ); - - resource.update((state) => const ResourceReady(2)); - expect( - resource.state, - isA>().having( - (p0) => p0.value, - 'value equal to 2', - 2, - ), - ); - }); - test('refresh Resource with fetcher while loading', () async { - Future fetcher() => Future.delayed( - const Duration(milliseconds: 200), - () => 1, - ); - final resource = Resource(fetcher); - expect(resource.state, isA>()); - await Future.delayed(const Duration(milliseconds: 100)); - expect(resource.state, isA>()); - - resource.refresh().ignore(); - expect(resource.state, isA>()); - - await Future.delayed(const Duration(milliseconds: 200)); - expect(resource.state, isA>()); - }); - test('refresh Resource with stream while loading', () async { - final controller = StreamController(); - final resource = Resource.stream(() => controller.stream); - expect(resource.state, isA>()); - - await resource.refresh(); - expect(resource.state, isA>()); - - controller.add(1); - await pumpEventQueue(); - expect(resource.state, isA>()); - }); - - test('check ResourceState.when', () async { - var shouldThrow = false; - Future fetcher() { - return Future.delayed(const Duration(milliseconds: 150), () { - if (shouldThrow) throw Exception(); - return 0; - }); - } - - var dataCalledTimes = 0; - var loadingCalledTimes = 0; - var errorCalledTimes = 0; - var refreshingOnDataTimes = 0; - var refreshingOnErrorTimes = 0; - final resource = Resource(fetcher); - - Effect( - () { - resource.state.when( - ready: (data) { - if (resource.state.isRefreshing) { - refreshingOnDataTimes++; - } else { - dataCalledTimes++; - } - }, - error: (error, stackTrace) { - if (resource.state.isRefreshing) { - refreshingOnErrorTimes++; - } else { - errorCalledTimes++; - } - }, - loading: () { - loadingCalledTimes++; - }, - ); - }, - ); - - await Future.delayed(const Duration(milliseconds: 40)); - expect(loadingCalledTimes, 1); - await Future.delayed(const Duration(milliseconds: 150)); - expect(dataCalledTimes, 1); - expect(errorCalledTimes, 0); - - resource.refresh().ignore(); - await Future.delayed(const Duration(milliseconds: 40)); - expect(refreshingOnDataTimes, 1); - await Future.delayed(const Duration(milliseconds: 150)); - expect(dataCalledTimes, 2); - - expect(resource.state, const TypeMatcher>()); - shouldThrow = true; - resource.refresh().ignore(); - await Future.delayed(const Duration(milliseconds: 150)); - expect(errorCalledTimes, 1); - expect(refreshingOnErrorTimes, 0); - - resource.refresh().ignore(); - await Future.delayed(const Duration(milliseconds: 150)); - expect(refreshingOnErrorTimes, 1); - expect(errorCalledTimes, 2); - - shouldThrow = false; - resource.refresh().ignore(); - await Future.delayed(const Duration(milliseconds: 150)); - expect(refreshingOnErrorTimes, 2); - expect(errorCalledTimes, 2); - expect(dataCalledTimes, 3); - - expect(loadingCalledTimes, 1); - }); - - test( - 'test untilReady()', - () async { - Future fetcher() => Future.delayed( - const Duration(milliseconds: 300), - () => 1, - ); - final count = Resource(fetcher); - - await expectLater(count.untilReady(), completion(1)); - }, - ); - - test( - 'until syncronously fires the then callback if condition is met', - () async { - final count = Resource(() => Future.value(1), lazy: false); - var fired = false; - count.until((v) => true).then((value) => fired = true); - expect(fired, true); - }, - ); - - test( - 'until asynchronously fires the then callback if condition is met', - () async { - final count = Signal(0); - var fired = false; - count.until((v) => v == 1).then((value) => fired = true); - count.value = 1; - await pumpEventQueue(); - expect(fired, true); - }, - ); - - test('check toString()', () async { - final r = Resource( - () => Future.value(1), - lazy: false, - ); - await pumpEventQueue(); - expect( - r.toString(), - startsWith( - '''Resource(state: ResourceReady(value: 1, refreshing: false)''', - ), - ); - }); - - test( - 'check Resource debounceDelay for source that triggers very often', - () async { - final source = Signal(0); - - Future fetcher() => Future.value(42); - - final resource = Resource( - fetcher, - source: source, - debounceDelay: const Duration(milliseconds: 100), - lazy: false, // Start immediately so we get an initial load - ); - - addTearDown(() { - resource.dispose(); - source.dispose(); - }); - - // Wait for initial load to complete - await pumpEventQueue(); - expect(resource.state, isA>()); - - // Rapidly change the source value multiple times - for (var i = 1; i < 10; i++) { - source.value = i; - await Future.delayed(const Duration(milliseconds: 30)); - } - - // Wait enough time to ensure the debounce delay has passed - // and the Future completes - await Future.delayed(const Duration(milliseconds: 200)); - - // At this point, the resource should still be ready after debounced - // refresh - expect(resource.state, isA>()); - }, - ); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - - // group( - // 'ReactiveContext tests', - // () { - // test('throws Exception for reactions that do not converge', () { - // var firstTime = true; - // final count = Signal(0); - // final d = Effect((_) { - // // watch count - // count.value; - // if (firstTime) { - // firstTime = false; - // return; - // } - // - // // cyclic-dependency - // // this effect will keep on getting triggered as count.value keeps - // // changing every time it's invoked - // count.value++; - // }); - // - // expect( - // () => count.value = 1, - // throwsA(const TypeMatcher()), - // ); - // d(); - // }); - // }, - // timeout: const Timeout(Duration(seconds: 1)), - // ); - - group( - 'ListSignal tests', - () { - test('check length', () { - final list = ListSignal([1, 2]); - expect(list.length, 2); - list.add(3); - expect(list.length, 3); - }); - - test('change length', () { - final list = ListSignal([1, 2]); - expect(list.length, 2); - list.length = 3; - expect(list.length, 3); - expect(const ListEquality().equals(list, [1, 2, null]), true); - }); - - test('check elementAt', () { - final list = ListSignal([1, 2]); - expect(list.elementAt(0), 1); - expect(list.elementAt(1), 2); - expect(() => list.elementAt(2), throwsRangeError); - - list.add(3); - expect(list.elementAt(2), 3); - }); - - test('check operator +', () { - final list = ListSignal([1, 2]); - expect(list + [3, 4], [1, 2, 3, 4]); - }); - - test('check operator []', () { - final list = ListSignal([1, 2]); - expect(list[0], 1); - expect(list[1], 2); - expect(() => list[2], throwsRangeError); - - list.add(3); - expect(list[2], 3); - }); - - test('check operator []=', () { - final list = ListSignal([1, 2]); - expect(list[0], 1); - expect(list[1], 2); - list[0] = 3; - expect(list[0], 3); - list[1] = 4; - expect(list[1], 4); - }); - - test('check add', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.add(3); - expect(list, [1, 2, 3]); - }); - - test('check addAll', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.addAll([3, 4]); - expect(list, [1, 2, 3, 4]); - }); - - test('check single', () { - final list = ListSignal([1]); - expect(list.single, 1); - list.add(3); - expect(() => list.single, throwsStateError); - }); - - test('check first', () { - final list = ListSignal([1, 2]); - expect(list.first, 1); - - list.add(3); - expect(list.first, 1); - - list[0] = 4; - expect(list.first, 4); - }); - - test('check last', () { - final list = ListSignal([1, 2]); - expect(list.last, 2); - - list.add(3); - expect(list.last, 3); - - list[2] = 4; - expect(list.last, 4); - }); - - test('check singleWhere', () { - final list = ListSignal([1, 2]); - expect(list.singleWhere((e) => e == 1), 1); - expect(() => list.singleWhere((e) => e == 4), throwsStateError); - }); - - test('check firstWhere', () { - final list = ListSignal([1, 2]); - expect(list.firstWhere((e) => e == 1), 1); - expect(() => list.firstWhere((e) => e == 4), throwsStateError); - }); - - test('check lastWhere', () { - final list = ListSignal([1, 2]); - expect(list.lastWhere((e) => e == 1), 1); - expect(() => list.lastWhere((e) => e == 4), throwsStateError); - }); - - test('check lastIndexWhere', () { - final list = ListSignal([1, 2]); - expect(list.lastIndexWhere((e) => e == 1), 0); - expect(list.lastIndexWhere((e) => e == 4), -1); - }); - - test('check isEmpty', () { - final list = ListSignal([1, 2]); - expect(list.isEmpty, false); - list.clear(); - expect(list.isEmpty, true); - }); - - test('check isNotEmpty', () { - final list = ListSignal([1, 2]); - expect(list.isNotEmpty, true); - list.clear(); - expect(list.isNotEmpty, false); - }); - - test('check clear', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.clear(); - expect(list, isEmpty); - }); - - test('check remove', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.remove(1); - expect(list, [2]); - }); - - test('check removeAt', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.removeAt(0); - expect(list, [2]); - }); - - test('check removeWhere', () { - final list = ListSignal([1, 2, 1]); - expect(list, [1, 2, 1]); - list.removeWhere((e) => e == 1); - expect(list, [2]); - }); - - test('check retainWhere', () { - final list = ListSignal([1, 2, 1]); - expect(list, [1, 2, 1]); - - list.retainWhere((e) => e == 1); - expect(list, [1, 1]); - }); - - test('check setAll', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.setAll(0, [3, 4]); - expect(list, [3, 4]); - }); - - test('check setRange', () { - final list1 = ListSignal([1, 2, 3, 4]); - final list2 = [5, 6, 7, 8, 9]; - - const skipCount = 3; - list1.setRange(1, 3, list2, skipCount); - expect(list1, [1, 8, 9, 4]); - }); - - test('check replaceRange', () { - final list = ListSignal([1, 2, 3, 4, 5]); - final replacements = [6, 7]; - list.replaceRange(1, 4, replacements); - expect(list, [1, 6, 7, 5]); - }); - - test('check fillRange', () { - final list = ListSignal([1, 2, 3, 4, 5]); - expect(list, [1, 2, 3, 4, 5]); - list.fillRange(1, 4, 6); - expect(list, [1, 6, 6, 6, 5]); - }); - - test('check sort', () { - final list = ListSignal([3, 1, 2, 4]); - expect(list, [3, 1, 2, 4]); - list.sort((a, b) => a.compareTo(b)); - expect(list, [1, 2, 3, 4]); - }); - - test('check sublist', () { - final list = ListSignal([1, 2, 3, 4]); - expect(list.sublist(1, 3), [2, 3]); - }); - - test('check toList', () { - final list = ListSignal([1, 2]); - expect(list.toList(growable: false), [1, 2]); - }); - - test('check cast', () { - final list = ListSignal([1, 2]); - expect(list.cast(), [1, 2]); - }); - - test('check toString', () { - final list = ListSignal([1, 2]); - expect(list.toString(), startsWith('ListSignal(value: [1, 2]')); - }); - - test('check set first', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.first = 3; - expect(list, [3, 2]); - }); - - test('check set last', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.last = 3; - expect(list, [1, 3]); - }); - - test('check insert', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.insert(0, 3); - expect(list, [3, 1, 2]); - }); - - test('check insertAll', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.insertAll(0, [3, 4]); - expect(list, [3, 4, 1, 2]); - }); - - test('check removeLast', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.removeLast(); - expect(list, [1]); - }); - - test('check removeRange', () { - final list = ListSignal([1, 2, 3, 4]); - expect(list, [1, 2, 3, 4]); - list.removeRange(1, 3); - expect(list, [1, 4]); - }); - - test('check shuffle', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.shuffle(_AlwaysZeroRandom()); - expect(list, [2, 1]); - }); - - test('check set', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.value = [3, 4]; - expect(list, [3, 4]); - }); - - test('check set with equals', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.value = [3, 4]; - expect(list, [3, 4]); - }); - - test('check updateValue', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - list.updateValue((v) => v..add(3)); - expect(list, [1, 2, 3]); - }); - - test('check value and previousValue', () { - final list = ListSignal([1, 2]); - expect(list, [1, 2]); - expect(list.previousValue, null); - list.updateValue((v) => v..add(3)); - expect(list, [1, 2, 3]); - expect(list.previousValue, [1, 2]); - }); - - test('check equals', () { - final list = ListSignal([1], equals: true); - expect(list, [1]); - list.value = [1, 2]; - expect(list, [1, 2]); - }); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - - group( - 'SetSignal tests', - () { - test('check length', () { - final set = SetSignal({1, 2}); - expect(set.length, 2); - set.add(3); - expect(set.length, 3); - }); - - test('check elementAt', () { - final set = SetSignal({1, 2}); - expect(set.elementAt(0), 1); - expect(set.elementAt(1), 2); - expect(() => set.elementAt(2), throwsRangeError); - - set.add(3); - expect(set.elementAt(2), 3); - }); - - test('check add', () { - final set = SetSignal({1, 2}); - expect(set, [1, 2]); - set.add(3); - expect(set, [1, 2, 3]); - }); - - test('check addAll', () { - final set = SetSignal({1, 2}); - expect(set, [1, 2]); - set.addAll([3, 4]); - expect(set, [1, 2, 3, 4]); - }); - - test('check single', () { - final set = SetSignal({1}); - expect(set.single, 1); - set.add(3); - expect(() => set.single, throwsStateError); - }); - - test('check first', () { - final set = SetSignal({1, 2}); - expect(set.first, 1); - - set.remove(1); - expect(set.first, 2); - }); - - test('check last', () { - final set = SetSignal({1, 2}); - expect(set.last, 2); - - set.add(3); - expect(set.last, 3); - }); - - test('check singleWhere', () { - final set = SetSignal({1, 2}); - expect(set.singleWhere((e) => e == 1), 1); - expect(() => set.singleWhere((e) => e == 4), throwsStateError); - }); - - test('check firstWhere', () { - final set = SetSignal({1, 2}); - expect(set.firstWhere((e) => e == 1), 1); - expect(() => set.firstWhere((e) => e == 4), throwsStateError); - }); - - test('check lastWhere', () { - final set = SetSignal({1, 2}); - expect(set.lastWhere((e) => e == 1), 1); - expect(() => set.lastWhere((e) => e == 4), throwsStateError); - }); - - test('check isEmpty', () { - final set = SetSignal({1, 2}); - expect(set.isEmpty, false); - set.clear(); - expect(set.isEmpty, true); - }); - - test('check isNotEmpty', () { - final set = SetSignal({1, 2}); - expect(set.isNotEmpty, true); - set.clear(); - expect(set.isNotEmpty, false); - }); - - test('check clear', () { - final set = SetSignal({1, 2}); - expect(set, [1, 2]); - set.clear(); - expect(set, isEmpty); - }); - - test('check remove', () { - final set = SetSignal({1, 2}); - expect(set, [1, 2]); - set.remove(1); - expect(set, [2]); - }); - - test('check removeWhere', () { - final set = SetSignal({1, 2, 3}); - expect(set, {1, 2, 3}); - set.removeWhere((e) => e == 1); - expect(set, {2, 3}); - }); - - test('check retainWhere', () { - final set = SetSignal({ - 1, - 2, - }); - expect(set, {1, 2}); - - set.retainWhere((e) => e == 1); - expect(set, {1}); - }); - - test('check toList', () { - final set = SetSignal({1, 2}); - expect(set.toList(growable: false), [1, 2]); - }); - - test('check cast', () { - final set = SetSignal({1, 2}); - expect(set.cast(), [1, 2]); - }); - - test('check toString', () { - final set = SetSignal({1, 2}); - expect(set.toString(), startsWith('SetSignal(value: {1, 2}')); - }); - - test('check contains', () { - final set = SetSignal({1, 2}); - expect(set.contains(1), true); - expect(set.contains(3), false); - }); - - test('check lookup', () { - final set = SetSignal({1, 2}); - expect(set.lookup(1), 1); - expect(set.lookup(3), null); - }); - - test('check retainAll', () { - final set = SetSignal({1, 2, 3, 4}); - expect(set, {1, 2, 3, 4}); - set.retainAll({1, 3, 10}); - expect(set, {1, 3}); - }); - - test('check set', () { - final set = SetSignal({1, 2}); - expect(set, {1, 2}); - set.value = {3, 4}; - expect(set, {3, 4}); - }); - - test('check set with equals', () { - final set = SetSignal({1, 2}); - expect(set, {1, 2}); - set.value = {3, 4}; - expect(set, {3, 4}); - }); - - test('check updateValue', () { - final set = SetSignal({1, 2}); - expect(set, {1, 2}); - set.updateValue((v) => v..add(3)); - expect(set, {1, 2, 3}); - }); - - test('check value and previousValue', () { - final set = SetSignal({1, 2}); - expect(set, {1, 2}); - expect(set.previousValue, null); - set.updateValue((v) => v..add(3)); - expect(set, {1, 2, 3}); - expect(set.previousValue, {1, 2}); - }); - - test('check equals', () { - final set = SetSignal({1}, equals: true); - expect(set, {1}); - set.value = {1, 2}; - expect(set, {1, 2}); - }); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - - group( - 'MapSignal tests', - () { - test('check [] operator', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map['a'], 1); - expect(map['c'], null); - expect(map['b'], 2); - }); - - test('check []= operator', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map['a'], 1); - map['a'] = 3; - expect(map['a'], 3); - }); - - test('check clear', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.clear(); - expect(map, isEmpty); - }); - - test('check keys', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map.keys, ['a', 'b']); - map.clear(); - expect(map.keys, isEmpty); - }); - - test('check values', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map.values, [1, 2]); - map.clear(); - expect(map.values, isEmpty); - }); - - test('check remove', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.remove('a'); - expect(map, {'b': 2}); - }); - - test('check cast', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map.cast(), {'a': 1, 'b': 2}); - }); - - test('check length', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map.length, 2); - map['c'] = 3; - expect(map.length, 3); - }); - - test('check isEmpty', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map.isEmpty, false); - map.clear(); - expect(map.isEmpty, true); - }); - - test('check isNotEmpty', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map.isNotEmpty, true); - map.clear(); - expect(map.isNotEmpty, false); - }); - - test('check containsKey', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map.containsKey('a'), true); - expect(map.containsKey('c'), false); - }); - - test('check containsValue', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map.containsValue(1), true); - expect(map.containsValue(3), false); - }); - - test('check entries', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(Map.fromEntries(map.entries), map); - map.clear(); - expect(map.entries, isEmpty); - }); - - test('check addEntries', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.addEntries({'c': 3, 'd': 4}.entries); - expect(map, {'a': 1, 'b': 2, 'c': 3, 'd': 4}); - }); - - test('check addAll', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.addAll({'c': 3, 'd': 4}); - expect(map, {'a': 1, 'b': 2, 'c': 3, 'd': 4}); - }); - - test('check putIfAbset', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.putIfAbsent('c', () => 3); - expect(map, {'a': 1, 'b': 2, 'c': 3}); - map.putIfAbsent('a', () => 4); - expect(map, {'a': 1, 'b': 2, 'c': 3}); - }); - - test('check removeWhere', () { - final map = MapSignal({'a': 1, 'b': 2, 'c': 1}); - expect(map, {'a': 1, 'b': 2, 'c': 1}); - map.removeWhere((k, v) => v == 1); - expect(map, {'b': 2}); - }); - - test('check update', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.update('a', (value) => 3); - expect(map, {'a': 3, 'b': 2}); - - map.update('c', (value) => 4, ifAbsent: () => 4); - expect(map, {'a': 3, 'b': 2, 'c': 4}); - - expect(() => map.update('d', (value) => 5), throwsArgumentError); - }); - - test('check updateAll', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.updateAll((k, v) => v * 2); - expect(map, {'a': 2, 'b': 4}); - }); - - test('check toString', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect( - map.toString(), - startsWith('MapSignal(value: {a: 1, b: 2}'), - ); - }); - - test('check set', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.value = {'c': 3, 'd': 4}; - expect(map, {'c': 3, 'd': 4}); - }); - - test('check set with equals', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.value = {'c': 3, 'd': 4}; - expect(map, {'c': 3, 'd': 4}); - }); - - test('check updateValue', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.updateValue((v) { - v['c'] = 3; - return v; - }); - expect(map, {'a': 1, 'b': 2, 'c': 3}); - }); - - test('check value and previousValue', () { - final map = MapSignal({'a': 1, 'b': 2}); - expect(map, {'a': 1, 'b': 2}); - map.updateValue((v) { - v['c'] = 3; - return v; - }); - expect(map, {'a': 1, 'b': 2, 'c': 3}); - expect(map.previousValue, {'a': 1, 'b': 2}); - }); - - test('check equals', () { - final map = MapSignal({'a': 1}, equals: true); - expect(map, {'a': 1}); - map.value = {'b': 2}; - expect(map, {'b': 2}); - }); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - - group( - 'SolidartObserver', - () { - test('didCreateSignal is fired on signal creation', () { - final observer = MockSolidartObserver(); - SolidartConfig.observers.add(observer); - final count = Signal(0); - verify(observer.didCreateSignal(count)).called(1); - }); - - test('didUpdateSignal is fired on signal update', () { - final observer = MockSolidartObserver(); - SolidartConfig.observers.add(observer); - final count = Signal(0); - verifyNever(observer.didUpdateSignal(count)); - count.value++; - verify(observer.didUpdateSignal(count)).called(1); - }); - - test('didDisposeSignal is fired on signal update', () { - final observer = MockSolidartObserver(); - SolidartConfig.observers.add(observer); - final count = Signal(0); - verifyNever(observer.didDisposeSignal(count)); - count.dispose(); - verify(observer.didDisposeSignal(count)).called(1); - }); - - test('modifications are batched', () { - final x = Signal(10); - final y = Signal(20); - final total = Signal(30); - - final calls = <({int x, int y, int total})>[]; - expect(calls, isEmpty); - expect(total.value, equals(30)); - - final disposeEffect = Effect(() { - calls.add((x: x.value, y: y.value, total: total.value)); - }); - - addTearDown(disposeEffect); - - expect( - calls, - equals([ - (x: 10, y: 20, total: 30), - ]), - ); - - batch(() { - x.value++; - y.value++; - - total.value = x.value + y.value; - }); - - expect( - calls, - equals([ - (x: 10, y: 20, total: 30), - (x: 11, y: 21, total: 32), - ]), - ); - }); - - test('should correctly propagate changes through computed signals', () { - final source = Signal(0); - final c1 = Computed(() => source.value % 2); - final c2 = Computed(() => c1.value); - final c3 = Computed(() => c2.value); - - c3.value; - source.value = 1; - c2.value; - source.value = 3; - - expect(c3.value, equals(1)); - }); - test('should clear subscriptions when untracked by all subscribers', () { - var bRunTimes = 0; - - final a = Signal(1); - final b = Computed(() { - bRunTimes++; - return a.value * 2; - }); - final disposeEffect = Effect(() => b.value); - - expect(bRunTimes, equals(1)); - - a.value = 2; - expect(bRunTimes, equals(2)); - - disposeEffect(); - a.value = 2; - expect(bRunTimes, equals(2)); - }); - - test('should not run untracked inner effect', () { - final a = Signal(3); - final b = Computed(() => a.value > 0); - - late VoidCallback disposeInnerEffect; - - final disposeEffect = Effect( - () { - if (b.value) { - disposeInnerEffect = Effect( - () { - if (a.value == 0) { - throw Error(); - } - }, - name: 'inner', - ); - } else { - disposeInnerEffect(); - } - }, - name: 'outer', - ); - - addTearDown(() { - a.dispose(); - b.dispose(); - disposeEffect(); - disposeInnerEffect(); - }); - - a.value--; - a.value--; - a.value--; - expect(b.value, isFalse); - }); - - test('should run outer effect first', () { - final a = Signal(1); - final b = Signal(1); - - late VoidCallback disposeInnerEffect; - final disposeEffect = Effect(() { - if (a.value > 0) { - disposeInnerEffect = Effect(() { - b.value; - if (a.value == 0) { - throw Error(); - } - }); - } else { - disposeInnerEffect(); - } - }); - - addTearDown(() { - disposeEffect(); - a.dispose(); - b.dispose(); - }); - - batch(() { - a.value = 0; - b.value = 0; - }); - }); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); -} - -class _AlwaysZeroRandom implements Random { - @override - bool nextBool() => false; - - @override - double nextDouble() => 0; - - @override - int nextInt(int max) => 0; -} diff --git a/packages/solidart/test/until_test.dart b/packages/solidart/test/until_test.dart new file mode 100644 index 00000000..210c83f4 --- /dev/null +++ b/packages/solidart/test/until_test.dart @@ -0,0 +1,51 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:solidart/solidart.dart'; +import 'package:test/test.dart'; + +void main() { + group('until', () { + test('returns current value when condition already true', () async { + final signal = Signal(0); + + final result = signal.until((value) => value == 0); + + expect(await Future.value(result), 0); + }); + + test('completes when condition becomes true', () async { + final signal = Signal(0); + + final future = Future.value(signal.until((value) => value == 2)); + signal.value = 2; + + expect(await future, 2); + }); + + test('completes with TimeoutException when timed out', () { + fakeAsync((async) { + final signal = Signal(0); + + final future = Future.value( + signal.until( + (value) => value == 1, + timeout: const Duration(seconds: 1), + ), + ); + + var completed = false; + expectLater( + future, + throwsA(isA()), + ).whenComplete(() => completed = true); + + async + ..elapse(const Duration(seconds: 1)) + ..flushMicrotasks(); + + expect(completed, isTrue); + }); + }); + }); +} diff --git a/packages/solidart/test/untracked_test.dart b/packages/solidart/test/untracked_test.dart new file mode 100644 index 00000000..494d85e0 --- /dev/null +++ b/packages/solidart/test/untracked_test.dart @@ -0,0 +1,28 @@ +import 'package:solidart/solidart.dart'; +import 'package:test/test.dart'; + +void main() { + test('untracked prevents dependency tracking', () { + final count = Signal(0); + final effectCount = Signal(0); + var runs = 0; + + Effect(() { + count.value; + runs++; + effectCount.value = untracked(() => effectCount.value + 1); + }); + + expect(runs, 1); + expect(effectCount.value, 1); + + count.value = 1; + + expect(runs, 2); + expect(effectCount.value, 2); + + effectCount.value = 3; + + expect(runs, 2); + }); +} diff --git a/packages/solidart_devtools_extension/CHANGELOG.md b/packages/solidart_devtools_extension/CHANGELOG.md new file mode 100644 index 00000000..4ed3eb2a --- /dev/null +++ b/packages/solidart_devtools_extension/CHANGELOG.md @@ -0,0 +1,7 @@ +## 1.0.1 + +- **CHORE**: Align extension versioning with Solidart v3 dev releases. + +## 1.0.0 + +- Initial release. diff --git a/packages/solidart_devtools_extension/lib/main.dart b/packages/solidart_devtools_extension/lib/main.dart index 597b52e4..c6c034b4 100644 --- a/packages/solidart_devtools_extension/lib/main.dart +++ b/packages/solidart_devtools_extension/lib/main.dart @@ -40,8 +40,9 @@ class Signals extends StatefulWidget { } enum SignalType { - readSignal, + readonlySignal, signal, + lazySignal, computed, resource, listSignal, @@ -50,8 +51,9 @@ enum SignalType { static SignalType byName(String name) { return switch (name) { - 'ReadSignal' => SignalType.readSignal, + 'ReadonlySignal' => SignalType.readonlySignal, 'Signal' => SignalType.signal, + 'LazySignal' => SignalType.lazySignal, 'Computed' => SignalType.computed, 'Resource' => SignalType.resource, 'ListSignal' => SignalType.listSignal, @@ -93,6 +95,7 @@ class SignalData { return value.toString().toLowerCase().contains(search) || previousValue.toString().toLowerCase().contains(search) || valueType.toLowerCase().contains(search) || + type.name.toLowerCase().contains(search) || (previousValueType != null && previousValueType!.toLowerCase().contains(search)); } @@ -134,17 +137,23 @@ class _SignalsState extends State { final vmService = await serviceManager.onServiceAvailable; sub = vmService.onExtensionEvent .where((e) { - return e.extensionKind?.startsWith('ext.solidart.signal') ?? false; + final kind = e.extensionKind; + return kind != null && kind.startsWith('ext.solidart.signal'); }) .listen((event) { final data = event.extensionData?.data; if (data == null) return; - switch (event.extensionKind) { + final kind = event.extensionKind; + switch (kind) { case 'ext.solidart.signal.created': case 'ext.solidart.signal.updated': case 'ext.solidart.signal.disposed': - signals[data['_id']] = SignalData( - name: data['name'] ?? data['_id'], + final id = data['_id']; + final signalId = id == null + ? DateTime.now().microsecondsSinceEpoch.toString() + : id.toString(); + signals[signalId] = SignalData( + name: data['name'] ?? id ?? signalId, value: jsonDecode(data['value'] ?? 'null'), hasPreviousValue: data['hasPreviousValue'], previousValue: jsonDecode(data['previousValue'] ?? 'null'), diff --git a/packages/solidart_devtools_extension/pubspec.yaml b/packages/solidart_devtools_extension/pubspec.yaml index c9b8add5..cd93614c 100644 --- a/packages/solidart_devtools_extension/pubspec.yaml +++ b/packages/solidart_devtools_extension/pubspec.yaml @@ -1,7 +1,7 @@ name: solidart_devtools_extension description: "solidart DevTools extension" publish_to: "none" -version: 1.0.0 +version: 1.0.1 environment: sdk: ^3.10.0 diff --git a/packages/solidart_hooks/CHANGELOG.md b/packages/solidart_hooks/CHANGELOG.md index b8951191..251fd7d0 100644 --- a/packages/solidart_hooks/CHANGELOG.md +++ b/packages/solidart_hooks/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.2.0-dev.0 (Unreleased) + +- **CHORE**: Bump `flutter_solidart` dependency to `3.0.0-dev.2`. + ## 3.1.2 ### Changes from solidart @@ -19,14 +23,18 @@ ## 3.0.0 - **BREAKING CHANGE**: `SignalHook` no longer calls `setState` to trigger a rebuild when the signal changes. Instead, you should use `SignalBuilder` to listen to signal changes and rebuild the UI accordingly. This change improves performance and reduces unnecessary rebuilds. You can also use `useListenable` if you want to trigger a rebuild on signal changes. + ### Migration Guide **Before (v2.x):** + ```dart final count = useSignal(0); return Text('Count: ${count.value}'); // Auto-rebuilds ``` + **After (v3.x):** + ```dart final count = useSignal(0); return SignalBuilder( @@ -35,11 +43,13 @@ ``` Or use `useListenable` for full widget rebuild: + ```dart final count = useSignal(0); useListenable(count); return Text('Count: ${count.value}'); ``` + This is inline with the behaviour of `useValueNotifier` from `flutter_hooks`. ## 2.0.0 diff --git a/packages/solidart_hooks/example/lib/main.dart b/packages/solidart_hooks/example/lib/main.dart index 6bfaf0e6..7206a740 100644 --- a/packages/solidart_hooks/example/lib/main.dart +++ b/packages/solidart_hooks/example/lib/main.dart @@ -55,17 +55,17 @@ class HookListScreen extends HookWidget { ), HookInfo( title: 'useListSignal', - description: 'Create a reactive list signal', + description: 'Create a list signal', example: () => const UseListSignalExample(), ), HookInfo( title: 'useSetSignal', - description: 'Create a reactive set signal', + description: 'Create a set signal', example: () => const UseSetSignalExample(), ), HookInfo( title: 'useMapSignal', - description: 'Create a reactive map signal', + description: 'Create a map signal', example: () => const UseMapSignalExample(), ), HookInfo( diff --git a/packages/solidart_hooks/example/pubspec.yaml b/packages/solidart_hooks/example/pubspec.yaml index a18b8f2a..8066be72 100644 --- a/packages/solidart_hooks/example/pubspec.yaml +++ b/packages/solidart_hooks/example/pubspec.yaml @@ -5,16 +5,16 @@ version: 1.0.0+1 environment: sdk: ^3.10.0 - + resolution: workspace dependencies: flutter: sdk: flutter flutter_hooks: ^0.21.3+1 - solidart: + solidart: 3.0.0-dev.0 solidart_hooks: - + dev_dependencies: flutter_test: sdk: flutter diff --git a/packages/solidart_hooks/lib/solidart_hooks.dart b/packages/solidart_hooks/lib/solidart_hooks.dart index e1a64067..07b9e2fd 100644 --- a/packages/solidart_hooks/lib/solidart_hooks.dart +++ b/packages/solidart_hooks/lib/solidart_hooks.dart @@ -7,35 +7,32 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_solidart/flutter_solidart.dart'; -/// Bind an existing signal to the hook widget +/// Bind an existing signal to the hook widget. /// -/// This will not dispose the signal when the widget is unmounted -T useExistingSignal(T value) { +/// This will not dispose the signal when the widget is unmounted. +T useExistingSignal(T value) { final target = useMemoized(() => value, [value]); return use(_SignalHook('useExistingSignal', target, disposeOnUnmount: false)); } -/// {macro signal} +/// Create a [Signal] inside a hook widget. Signal useSignal( /// The initial value of the signal. T initialValue, { - /// {macro SignalBase.name} + /// Optional name used by DevTools. String? name, - /// {macro SignalBase.equals} - bool? equals, - - /// {@macro SignalBase.autoDispose} - bool? autoDispose = false, + /// Whether the signal should auto-dispose when unused. + bool? autoDispose, - /// {@macro SignalBase.trackInDevTools} + /// Whether to report updates to DevTools. bool? trackInDevTools, - /// {@macro SignalBase.comparator} - bool Function(T? a, T? b) comparator = identical, + /// Comparator used to skip equal updates. + ValueComparator equals = identical, - /// {@macro SignalBase.trackPreviousValue} + /// Whether to track previous values. bool? trackPreviousValue, }) { final target = useMemoized( @@ -45,37 +42,31 @@ Signal useSignal( name: name, equals: equals, trackInDevTools: trackInDevTools, - comparator: comparator, trackPreviousValue: trackPreviousValue, ), [], ); - return use( - _SignalHook('useSignal', target, disposeOnUnmount: autoDispose ?? true), - ); + return use(_SignalHook('useSignal', target)); } -/// {macro list-signal} +/// Create a [ListSignal] inside a hook widget. ListSignal useListSignal( /// The initial value of the signal. Iterable initialValue, { - /// {macro SignalBase.name} + /// Optional name used by DevTools. String? name, - /// {macro SignalBase.equals} - bool? equals, - - /// {@macro SignalBase.autoDispose} - bool? autoDispose = false, + /// Whether the list signal should auto-dispose when unused. + bool? autoDispose, - /// {@macro SignalBase.trackInDevTools} + /// Whether to report updates to DevTools. bool? trackInDevTools, - /// {@macro SignalBase.comparator} - bool Function(List? a, List? b) comparator = identical, + /// Comparator used to skip equal updates. + ValueComparator> equals = identical, - /// {@macro SignalBase.trackPreviousValue} + /// Whether to track previous values. bool? trackPreviousValue, }) { final target = useMemoized( @@ -85,37 +76,31 @@ ListSignal useListSignal( name: name, equals: equals, trackInDevTools: trackInDevTools, - comparator: comparator, trackPreviousValue: trackPreviousValue, ), [], ); - return use( - _SignalHook('useListSignal', target, disposeOnUnmount: autoDispose ?? true), - ); + return use(_SignalHook('useListSignal', target)); } -/// {macro set-signal} +/// Create a [SetSignal] inside a hook widget. SetSignal useSetSignal( /// The initial value of the signal. Iterable initialValue, { - /// {macro SignalBase.name} + /// Optional name used by DevTools. String? name, - /// {macro SignalBase.equals} - bool? equals, - - /// {@macro SignalBase.autoDispose} - bool? autoDispose = false, + /// Whether the set signal should auto-dispose when unused. + bool? autoDispose, - /// {@macro SignalBase.trackInDevTools} + /// Whether to report updates to DevTools. bool? trackInDevTools, - /// {@macro SignalBase.comparator} - bool Function(Set? a, Set? b) comparator = identical, + /// Comparator used to skip equal updates. + ValueComparator> equals = identical, - /// {@macro SignalBase.trackPreviousValue} + /// Whether to track previous values. bool? trackPreviousValue, }) { final target = useMemoized( @@ -125,37 +110,31 @@ SetSignal useSetSignal( name: name, equals: equals, trackInDevTools: trackInDevTools, - comparator: comparator, trackPreviousValue: trackPreviousValue, ), [], ); - return use( - _SignalHook('useSetSignal', target, disposeOnUnmount: autoDispose ?? true), - ); + return use(_SignalHook('useSetSignal', target)); } -/// {macro map-signal} +/// Create a [MapSignal] inside a hook widget. MapSignal useMapSignal( /// The initial value of the signal. Map initialValue, { - /// {macro SignalBase.name} + /// Optional name used by DevTools. String? name, - /// {macro SignalBase.equals} - bool? equals, - - /// {@macro SignalBase.autoDispose} - bool? autoDispose = false, + /// Whether the map signal should auto-dispose when unused. + bool? autoDispose, - /// {@macro SignalBase.trackInDevTools} + /// Whether to report updates to DevTools. bool? trackInDevTools, - /// {@macro SignalBase.comparator} - bool Function(Map? a, Map? b) comparator = identical, + /// Comparator used to skip equal updates. + ValueComparator> equals = identical, - /// {@macro SignalBase.trackPreviousValue} + /// Whether to track previous values. bool? trackPreviousValue, }) { final target = useMemoized( @@ -165,14 +144,11 @@ MapSignal useMapSignal( name: name, equals: equals, trackInDevTools: trackInDevTools, - comparator: comparator, trackPreviousValue: trackPreviousValue, ), [], ); - return use( - _SignalHook('useMapSignal', target, disposeOnUnmount: autoDispose ?? true), - ); + return use(_SignalHook('useMapSignal', target)); } /// Create a new computed signal @@ -180,22 +156,19 @@ Computed useComputed( /// The selector function to compute the value. T Function() selector, { - /// {macro SignalBase.name} + /// Optional name used by DevTools. String? name, - /// {macro SignalBase.equals} - bool? equals, - - /// {@macro SignalBase.autoDispose} - bool? autoDispose = false, + /// Whether the computed should auto-dispose when unused. + bool? autoDispose, - /// {@macro SignalBase.trackInDevTools} + /// Whether to report updates to DevTools. bool? trackInDevTools, - /// {@macro SignalBase.comparator} - bool Function(T? a, T? b) comparator = identical, + /// Comparator used to skip equal updates. + ValueComparator equals = identical, - /// {@macro SignalBase.trackPreviousValue} + /// Whether to track previous values. bool? trackPreviousValue, }) { final instance = useRef(selector); @@ -207,35 +180,32 @@ Computed useComputed( name: name, equals: equals, trackInDevTools: trackInDevTools, - comparator: comparator, trackPreviousValue: trackPreviousValue, ), [], ); - return use( - _SignalHook('useComputed', target, disposeOnUnmount: autoDispose ?? true), - ); + return use(_SignalHook('useComputed', target)); } -/// {macro resource} +/// Create a [Resource] from a future-producing [fetcher]. Resource useResource( - /// The asynchrounous function used to retrieve data. + /// The asynchronous function used to retrieve data. final Future Function()? fetcher, { - /// {macro SignalBase.name} + /// Optional name used by DevTools. String? name, - /// {macro SignalBase.equals} - bool? equals, - - /// {@macro SignalBase.autoDispose} - bool? autoDispose = false, + /// Whether the resource should auto-dispose when unused. + bool? autoDispose, - /// {@macro SignalBase.trackInDevTools} + /// Whether to report updates to DevTools. bool? trackInDevTools, + /// Comparator used to skip equal updates. + ValueComparator> equals = identical, + /// Reactive signal values passed to the fetcher, optional. - final SignalBase? source, + final ReadonlySignal? source, /// Indicates whether the resource should be computed lazily, defaults to true final bool lazy = true, @@ -264,30 +234,28 @@ Resource useResource( ), [], ); - return use( - _SignalHook('useResource', target, disposeOnUnmount: autoDispose ?? true), - ); + return use(_SignalHook('useResource', target)); } -/// {macro resource} +/// Create a [Resource] from a stream factory. Resource useResourceStream( - /// The asynchrounous function used to retrieve data. + /// The asynchronous function used to retrieve data. final Stream Function()? stream, { - /// {macro SignalBase.name} + /// Optional name used by DevTools. String? name, - /// {macro SignalBase.equals} - bool? equals, - - /// {@macro SignalBase.autoDispose} - bool? autoDispose = false, + /// Whether the resource should auto-dispose when unused. + bool? autoDispose, - /// {@macro SignalBase.trackInDevTools} + /// Whether to report updates to DevTools. bool? trackInDevTools, + /// Comparator used to skip equal updates. + ValueComparator> equals = identical, + /// Reactive signal values passed to the fetcher, optional. - final SignalBase? source, + final ReadonlySignal? source, /// Indicates whether the resource should be computed lazily, defaults to true final bool lazy = true, @@ -316,56 +284,36 @@ Resource useResourceStream( ), [], ); - return use( - _SignalHook( - 'useResourceStream', - target, - disposeOnUnmount: autoDispose ?? true, - ), - ); + return use(_SignalHook('useResourceStream', target)); } -/// Create a signal effect +/// Create an effect inside a hook widget. void useSolidartEffect( - dynamic Function() cb, { + VoidCallback cb, { - void Function(Object error)? onError, - - /// The name of the effect, useful for logging + /// The name of the effect, useful for logging. String? name, - /// Delay each effect reaction - Duration? delay, - - /// Whether to automatically dispose the effect (defaults to true). - /// - /// This happens automatically when all the tracked dependencies are - /// disposed. + /// Whether the effect should auto-dispose when unused. bool? autoDispose, - /// Detach effect, default value is [SolidartConfig.detachEffects] + /// Detach effect, default value is [SolidartConfig.detachEffects]. bool? detach, - - /// Whether to automatically run the effect (defaults to true). - bool? autorun, }) { final instance = useRef(cb); instance.value = cb; useEffect( () => Effect( () => instance.value(), - onError: onError, name: name, - delay: delay, autoDispose: autoDispose, detach: detach, - autorun: autorun, ).dispose, [], ); } -class _SignalHook> extends Hook { +class _SignalHook> extends Hook { const _SignalHook(this.type, this.target, {this.disposeOnUnmount = true}); final String type; @@ -376,7 +324,7 @@ class _SignalHook> extends Hook { _SignalHookState createState() => _SignalHookState(); } -class _SignalHookState> +class _SignalHookState> extends HookState> { @override void initHook() { diff --git a/packages/solidart_hooks/pubspec.yaml b/packages/solidart_hooks/pubspec.yaml index 587dbd5f..b19e67a6 100644 --- a/packages/solidart_hooks/pubspec.yaml +++ b/packages/solidart_hooks/pubspec.yaml @@ -1,6 +1,6 @@ name: solidart_hooks description: Flutter Hooks bindings for Solidart, suitable for ephemeral state and for writing less boilerplate. -version: 3.1.2 +version: 3.2.0-dev.0 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.2 + flutter_solidart: 3.0.0-dev.0 dev_dependencies: flutter_test: diff --git a/packages/solidart_lint/CHANGELOG.md b/packages/solidart_lint/CHANGELOG.md index 07be8eef..5650d0fa 100644 --- a/packages/solidart_lint/CHANGELOG.md +++ b/packages/solidart_lint/CHANGELOG.md @@ -1,3 +1,13 @@ +## 3.0.3 + +- **CHORE**: Bump `solidart` and `flutter_solidart` dev dependencies to `3.0.0-dev.2`. + +## 3.0.2 + +- **CHORE**: Upgrade `solidart` and `flutter_solidart` dev dependencies to v3. +- **DOCS**: Update README version snippet. +- **EXAMPLE**: Migrate example code to v3 APIs. + ## 3.0.1 - Update README.md diff --git a/packages/solidart_lint/README.md b/packages/solidart_lint/README.md index 1643424d..b89e03b8 100644 --- a/packages/solidart_lint/README.md +++ b/packages/solidart_lint/README.md @@ -10,7 +10,7 @@ Then edit your `analysis_options.yaml` file and add these lines of code: ```yaml plugins: - solidart_lint: ^3.0.0 + solidart_lint: ^3.0.2 ``` ## ASSISTS diff --git a/packages/solidart_lint/example/lib/main.dart b/packages/solidart_lint/example/lib/main.dart index da28b39a..aa4c13cd 100644 --- a/packages/solidart_lint/example/lib/main.dart +++ b/packages/solidart_lint/example/lib/main.dart @@ -45,7 +45,7 @@ class MyHomePage extends StatelessWidget { return ElevatedButton( child: const Text('Increment'), onPressed: () { - counter.updateValue((value) => throw UnimplementedError()); + counter.value = counter.value + 1; }, ); } diff --git a/packages/solidart_lint/example/pubspec.yaml b/packages/solidart_lint/example/pubspec.yaml index 9dbc2a75..cb7cfdb4 100644 --- a/packages/solidart_lint/example/pubspec.yaml +++ b/packages/solidart_lint/example/pubspec.yaml @@ -31,12 +31,17 @@ dependencies: disco: ^1.0.3 flutter: sdk: flutter - flutter_solidart: ^2.1.0 + flutter_solidart: + path: ../../flutter_solidart dev_dependencies: flutter_test: sdk: flutter +dependency_overrides: + solidart: + path: ../../solidart + # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your diff --git a/packages/solidart_lint/pubspec.yaml b/packages/solidart_lint/pubspec.yaml index 6b6f2d18..64254d60 100644 --- a/packages/solidart_lint/pubspec.yaml +++ b/packages/solidart_lint/pubspec.yaml @@ -1,6 +1,6 @@ name: solidart_lint description: solidart_lint is a developer tool for users of solidart, designed to help stop common issues and simplify repetitive tasks -version: 3.0.1 +version: 3.0.3 repository: https://github.com/nank1ro/solidart documentation: https://solidart.mariuti.com topics: @@ -27,5 +27,5 @@ dependencies: dev_dependencies: lints: ^6.0.0 test: ^1.25.2 - solidart: ^2.0.0 - flutter_solidart: ^2.0.0 + solidart: 3.0.0-dev.0 + flutter_solidart: 3.0.0-dev.0