├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── lib ├── preset.dart ├── system.dart ├── alien_signals.dart └── src │ ├── surface.dart │ ├── preset.dart │ └── system.dart ├── analysis_options.yaml ├── .gitignore ├── assets ├── logo.png └── logo.svg ├── renovate.json ├── test ├── fixtures │ └── dart2js_entry.dart ├── dart2js_pragma_test.dart ├── effect_scope_test.dart ├── trigger_test.dart ├── computed_test.dart ├── effect_cleanup_test.dart ├── untrack_test.dart └── effect_test.dart ├── example ├── web │ ├── README.md │ ├── main.dart │ └── index.html ├── main.dart └── preset_playground.dart ├── pubspec.yaml ├── LICENSE ├── bench.dart ├── README.md ├── CHANGELOG.md └── MIGRATION.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: medz 2 | -------------------------------------------------------------------------------- /lib/preset.dart: -------------------------------------------------------------------------------- 1 | export 'src/preset.dart' hide system; 2 | -------------------------------------------------------------------------------- /lib/system.dart: -------------------------------------------------------------------------------- 1 | export 'src/system.dart' hide Stack; 2 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lints/recommended.yaml 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .dart_tool/ 3 | 4 | build/ 5 | pubspec.lock 6 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medz/alien-signals-dart/HEAD/assets/logo.png -------------------------------------------------------------------------------- /lib/alien_signals.dart: -------------------------------------------------------------------------------- 1 | export 'src/preset.dart' show startBatch, endBatch, trigger; 2 | export 'src/surface.dart'; 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/dart2js_entry.dart: -------------------------------------------------------------------------------- 1 | import 'package:alien_signals/alien_signals.dart'; 2 | 3 | void main() { 4 | final count = signal(0); 5 | effect(() { 6 | count(); 7 | }); 8 | count.set(1); 9 | } 10 | -------------------------------------------------------------------------------- /example/web/README.md: -------------------------------------------------------------------------------- 1 | # Alien Signals Web Demo 2 | 3 | Build the JavaScript output: 4 | 5 | ```bash 6 | dart compile js example/web/main.dart -o example/web/main.js 7 | ``` 8 | 9 | Then open `example/web/index.html` in your browser. 10 | -------------------------------------------------------------------------------- /example/web/main.dart: -------------------------------------------------------------------------------- 1 | import 'dart:html'; 2 | 3 | import 'package:alien_signals/alien_signals.dart'; 4 | 5 | void main() { 6 | final count = signal(0); 7 | final button = querySelector('#inc') as ButtonElement; 8 | final output = querySelector('#value') as SpanElement; 9 | 10 | effect(() { 11 | output.text = count().toString(); 12 | }); 13 | 14 | button.onClick.listen((_) { 15 | count.set(count() + 1); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: alien_signals 2 | description: Alien Signals is a reactive state management library that brings the power of signals to Dart and Flutter applications. 3 | 4 | version: 2.1.1 5 | repository: https://github.com/medz/alien-signals-dart 6 | 7 | topics: 8 | - signals 9 | - reactive 10 | - state-management 11 | - alien-signals 12 | 13 | funding: 14 | - https://github.com/sponsors/medz 15 | - https://opencollective.com/openodroe 16 | 17 | screenshots: 18 | - description: Alien Signals 19 | path: assets/logo.png 20 | 21 | environment: 22 | sdk: ^3.6.0 23 | 24 | dev_dependencies: 25 | lints: ^5.1.1 26 | reactivity_benchmark: ^0.0.1 27 | test: ^1.25.8 28 | -------------------------------------------------------------------------------- /example/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:alien_signals/alien_signals.dart'; 2 | 3 | void basis() { 4 | print("\n=========== Basic Usage ==========="); 5 | 6 | final count = signal(1); 7 | final doubleCount = computed((_) => count() * 2); 8 | 9 | effect(() { 10 | print("Count is: ${count()}"); 11 | }); // Count is: 1 12 | 13 | print(doubleCount()); // 2 14 | 15 | count.set(2); // Count is: 2 16 | 17 | print(doubleCount()); // 4 18 | } 19 | 20 | void scope() { 21 | print("\n=========== Effect Scope ==========="); 22 | 23 | final count = signal(1); 24 | final stop = effectScope(() { 25 | effect(() { 26 | print("Count is: ${count()}"); 27 | }); // Count is: 1 28 | }); 29 | 30 | count.set(2); // Count is: 2 31 | stop(); 32 | count.set(3); // Not printed 33 | } 34 | 35 | void main() { 36 | basis(); 37 | scope(); 38 | } 39 | -------------------------------------------------------------------------------- /test/dart2js_pragma_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:test/test.dart'; 4 | 5 | void main() { 6 | test('dart2js can compile a basic alien_signals entrypoint', () async { 7 | final tempDir = await Directory.systemTemp 8 | .createTemp('alien_signals_dart2js_compile_'); 9 | final outputPath = 10 | '${tempDir.path}${Platform.pathSeparator}alien_signals_entry.js'; 11 | 12 | try { 13 | final result = await Process.run( 14 | Platform.resolvedExecutable, 15 | ['compile', 'js', 'test/fixtures/dart2js_entry.dart', '-o', outputPath], 16 | workingDirectory: Directory.current.path, 17 | ); 18 | 19 | if (result.exitCode != 0) { 20 | fail( 21 | 'dart2js compile failed (exit ${result.exitCode}).\n' 22 | 'stdout: ${result.stdout}\n' 23 | 'stderr: ${result.stderr}', 24 | ); 25 | } 26 | 27 | expect(await File(outputPath).exists(), isTrue); 28 | } finally { 29 | await tempDir.delete(recursive: true); 30 | } 31 | }, timeout: const Timeout(Duration(minutes: 2))); 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-present Seven Du 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bench.dart: -------------------------------------------------------------------------------- 1 | import 'package:alien_signals/alien_signals.dart' as alien_signals; 2 | import 'package:reactivity_benchmark/reactive_framework.dart'; 3 | import 'package:reactivity_benchmark/run_framework_bench.dart'; 4 | import 'package:reactivity_benchmark/utils/create_computed.dart'; 5 | import 'package:reactivity_benchmark/utils/create_signal.dart'; 6 | 7 | class Bench extends ReactiveFramework { 8 | const Bench() : super("alien_signals"); 9 | 10 | @override 11 | Computed computed(T Function() fn) { 12 | final c = alien_signals.computed((_) => fn()); 13 | return createComputed(c.call); 14 | } 15 | 16 | @override 17 | void effect(void Function() fn) { 18 | alien_signals.effect(fn); 19 | } 20 | 21 | @override 22 | Signal signal(T value) { 23 | final s = alien_signals.signal(value); 24 | return createSignal(s.call, s.set); 25 | } 26 | 27 | @override 28 | void withBatch(T Function() fn) { 29 | alien_signals.startBatch(); 30 | try { 31 | fn(); 32 | } finally { 33 | alien_signals.endBatch(); 34 | } 35 | } 36 | 37 | @override 38 | T withBuild(T Function() fn) { 39 | return fn(); 40 | } 41 | } 42 | 43 | void main() { 44 | runFrameworkBench(const Bench()); 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: testing 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | analyze: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v6 10 | - uses: dart-lang/setup-dart@v1 11 | with: 12 | sdk: latest 13 | - run: dart pub get 14 | - run: dart analyze 15 | 16 | test: 17 | needs: analyze 18 | runs-on: ubuntu-latest 19 | strategy: 20 | matrix: 21 | sdk: [stable, 3.6, 3.7, 3.8] 22 | steps: 23 | - uses: actions/checkout@v6 24 | - uses: dart-lang/setup-dart@v1 25 | with: 26 | sdk: ${{ matrix.sdk }} 27 | - run: dart pub get 28 | - run: dart test 29 | 30 | compile-web: 31 | needs: analyze 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v6 35 | - uses: dart-lang/setup-dart@v1 36 | with: 37 | sdk: latest 38 | - run: dart pub get 39 | - run: dart compile js test/fixtures/dart2js_entry.dart -o /tmp/alien_signals_entry.js 40 | - run: dart compile wasm test/fixtures/dart2js_entry.dart -o /tmp/alien_signals_entry.wasm 41 | 42 | try-publish: 43 | needs: [analyze, test, compile-web] 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v6 47 | - uses: dart-lang/setup-dart@v1 48 | with: 49 | sdk: latest 50 | - run: dart pub get 51 | - run: dart pub publish --dry-run 52 | -------------------------------------------------------------------------------- /test/effect_scope_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:alien_signals/alien_signals.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | test('should not trigger after stop', () { 6 | final count = signal(1); 7 | int triggers = 0; 8 | 9 | final stopScope = effectScope(() { 10 | effect(() { 11 | triggers++; 12 | count(); 13 | }); 14 | expect(triggers, 1); 15 | 16 | count.set(2); 17 | expect(triggers, 2); 18 | }); 19 | 20 | count.set(3); 21 | expect(triggers, 3); 22 | stopScope(); 23 | count.set(4); 24 | expect(triggers, 3); 25 | }); 26 | 27 | test('should dispose inner effects if created in an effect', () { 28 | final source = signal(1); 29 | 30 | int triggers = 0; 31 | 32 | effect(() { 33 | final dispose = effectScope(() { 34 | effect(() { 35 | source(); 36 | triggers++; 37 | }); 38 | }); 39 | expect(triggers, 1); 40 | 41 | source.set(2); 42 | expect(triggers, 2); 43 | dispose(); 44 | source.set(3); 45 | expect(triggers, 2); 46 | }); 47 | }); 48 | 49 | test( 50 | 'should track signal updates in an inner scope when accessed by an outer effect', 51 | () { 52 | final source = signal(1); 53 | 54 | int triggers = 0; 55 | 56 | effect(() { 57 | effectScope(() { 58 | source(); 59 | }); 60 | triggers++; 61 | }); 62 | 63 | expect(triggers, 1); 64 | source.set(2); 65 | expect(triggers, 2); 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /example/preset_playground.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: camel_case_types 2 | 3 | import 'package:alien_signals/preset.dart'; 4 | import 'package:alien_signals/system.dart'; 5 | 6 | extension type signal._(SignalNode _) { 7 | factory signal(T initialValue) { 8 | return signal._(SignalNode( 9 | flags: ReactiveFlags.mutable, 10 | currentValue: initialValue, 11 | pendingValue: initialValue, 12 | )); 13 | } 14 | 15 | T get value => _.get(); 16 | set value(newValue) => _.set(newValue); 17 | } 18 | 19 | extension type computed._(ComputedNode _) { 20 | factory computed(T Function() getter) { 21 | return computed._(ComputedNode( 22 | getter: (_) => getter(), 23 | flags: ReactiveFlags.none, 24 | )); 25 | } 26 | 27 | T get value => _.get(); 28 | } 29 | 30 | extension type effect._(EffectNode _) { 31 | factory effect(void Function() run) { 32 | final node = EffectNode( 33 | fn: run, flags: ReactiveFlags.watching | ReactiveFlags.recursedCheck); 34 | final prevSub = setActiveSub(node); 35 | if (prevSub != null) link(node, prevSub, 0); 36 | try { 37 | run(); 38 | return effect._(node); 39 | } finally { 40 | activeSub = prevSub; 41 | node.flags &= ~ReactiveFlags.recursedCheck; 42 | } 43 | } 44 | 45 | void call() => stop(_); 46 | } 47 | 48 | void main() { 49 | final count = signal(0); 50 | final doubled = computed(() => count.value * 2); 51 | 52 | final stop = effect(() { 53 | print('Count: ${count.value}, Doubled: ${doubled.value}'); 54 | }); 55 | 56 | count.value = 2; 57 | stop(); 58 | count.value = 3; 59 | } 60 | -------------------------------------------------------------------------------- /test/trigger_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:alien_signals/alien_signals.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | test('should not throw when triggering with no dependencies', () { 6 | trigger(() {}); 7 | }); 8 | 9 | test('should trigger updates for dependent computed signals', () { 10 | final arr = signal([]); 11 | final length = computed((_) => arr().length); 12 | 13 | expect(length(), 0); 14 | arr().add(1); 15 | trigger(() => arr()); 16 | expect(length(), 1); 17 | }); 18 | 19 | test('should trigger updates for the second source signal', () { 20 | final src1 = signal([]); 21 | final src2 = signal([]); 22 | final length = computed((_) => src2().length); 23 | 24 | expect(length(), 0); 25 | src2().add(1); 26 | trigger(() { 27 | src1(); 28 | src2(); 29 | }); 30 | expect(length(), 1); 31 | }); 32 | 33 | test('should trigger effect once', () { 34 | final src1 = signal([]); 35 | final src2 = signal([]); 36 | 37 | int triggers = 0; 38 | 39 | effect(() { 40 | triggers++; 41 | src1(); 42 | src2(); 43 | }); 44 | 45 | expect(triggers, 1); 46 | trigger(() { 47 | src1(); 48 | src2(); 49 | }); 50 | expect(triggers, 2); 51 | }); 52 | 53 | test('should not notify the trigger function sub', () { 54 | final src1 = signal>([]); 55 | final src2 = computed((_) => src1()); 56 | 57 | effect(() { 58 | src1(); 59 | src2(); 60 | }); 61 | 62 | trigger(() { 63 | src1(); 64 | src2(); 65 | }); 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /test/computed_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:alien_signals/alien_signals.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | test('should correctly propagate changes through computed signals', () { 6 | final src = signal(0); 7 | final c1 = computed((_) => src() % 2); 8 | final c2 = computed((_) => c1()); 9 | final c3 = computed((_) => c2()); 10 | 11 | c3(); 12 | src.set(1); // c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty 13 | c2(); // c1 -> none, c2 -> none 14 | src.set(3); // c1 -> dirty, c2 -> toCheckDirty 15 | 16 | expect(c3(), 1); 17 | }); 18 | 19 | test('should propagate updated source value through chained computations', 20 | () { 21 | final src = signal(0); 22 | final a = computed((_) => src()); 23 | final b = computed((_) => a() % 2); 24 | final c = computed((_) => src()); 25 | final d = computed((_) => b() + c()); 26 | 27 | expect(d(), 0); 28 | src.set(2); 29 | expect(d(), 2); 30 | }); 31 | 32 | test('should handle flags are indirectly updated during checkDirty', () { 33 | final a = signal(false); 34 | final b = computed((_) => a()); 35 | final c = computed((_) { 36 | b(); 37 | return 0; 38 | }); 39 | final d = computed((_) { 40 | c(); 41 | return b(); 42 | }); 43 | 44 | expect(d(), false); 45 | a.set(true); 46 | expect(d(), true); 47 | }); 48 | 49 | test('should not update if the signal value is reverted', () { 50 | int times = 0; 51 | 52 | final src = signal(0); 53 | final c1 = computed((_) { 54 | times++; 55 | return src(); 56 | }); 57 | c1(); 58 | expect(times, 1); 59 | src.set(1); 60 | src.set(0); 61 | c1(); 62 | expect(times, 1); 63 | }); 64 | } 65 | -------------------------------------------------------------------------------- /example/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Alien Signals Web Demo 7 | 52 | 53 | 54 |
55 |

Alien Signals

56 |

A tiny counter wired with signals.

57 | 58 | 0 59 |
60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /test/effect_cleanup_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:alien_signals/alien_signals.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | test('effect should unsubscribe from stale dependencies when branches change', 6 | () { 7 | final useA = signal(true); 8 | final a = signal(0); 9 | final b = signal(0); 10 | int runs = 0; 11 | 12 | effect(() { 13 | runs++; 14 | if (useA()) { 15 | a(); 16 | } else { 17 | b(); 18 | } 19 | }); 20 | 21 | expect(runs, 1); 22 | a.set(1); 23 | expect(runs, 2); 24 | 25 | useA.set(false); 26 | expect(runs, 3); 27 | 28 | a.set(2); 29 | expect(runs, 3, reason: 'stale dependency should not trigger'); 30 | 31 | b.set(1); 32 | expect(runs, 4); 33 | 34 | useA.set(true); 35 | expect(runs, 5); 36 | 37 | b.set(2); 38 | expect(runs, 5, reason: 'stale dependency should not trigger'); 39 | }); 40 | 41 | test('effectScope stops nested scopes and effects', () { 42 | final count = signal(0); 43 | int outer = 0; 44 | int inner = 0; 45 | int leaf = 0; 46 | 47 | final stop = effectScope(() { 48 | effect(() { 49 | outer++; 50 | count(); 51 | }); 52 | effectScope(() { 53 | effect(() { 54 | inner++; 55 | count(); 56 | }); 57 | effectScope(() { 58 | effect(() { 59 | leaf++; 60 | count(); 61 | }); 62 | }); 63 | }); 64 | }); 65 | 66 | expect(outer, 1); 67 | expect(inner, 1); 68 | expect(leaf, 1); 69 | 70 | count.set(1); 71 | expect(outer, 2); 72 | expect(inner, 2); 73 | expect(leaf, 2); 74 | 75 | stop(); 76 | count.set(2); 77 | expect(outer, 2); 78 | expect(inner, 2); 79 | expect(leaf, 2); 80 | }); 81 | } 82 | -------------------------------------------------------------------------------- /test/untrack_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:alien_signals/alien_signals.dart'; 2 | import 'package:alien_signals/preset.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | void main() { 6 | test("should pause tracking in computed", () { 7 | final s = signal(0); 8 | int computedTriggerTimes = 0; 9 | 10 | final c = computed((_) { 11 | computedTriggerTimes++; 12 | final currentSub = setActiveSub(null); 13 | try { 14 | return s(); 15 | } finally { 16 | setActiveSub(currentSub); 17 | } 18 | }); 19 | 20 | expect(c(), 0); 21 | expect(computedTriggerTimes, 1); 22 | 23 | s.set(1); 24 | s.set(2); 25 | s.set(3); 26 | expect(c(), 0); 27 | expect(computedTriggerTimes, 1); 28 | }); 29 | 30 | test("should pause tracking in effect", () { 31 | final a = signal(0); 32 | final b = signal(0); 33 | 34 | int effectTriggerTimes = 0; 35 | effect(() { 36 | effectTriggerTimes++; 37 | if (b() > 0) { 38 | final currentSub = setActiveSub(null); 39 | a(); 40 | setActiveSub(currentSub); 41 | } 42 | }); 43 | 44 | expect(effectTriggerTimes, 1); 45 | 46 | b.set(1); 47 | expect(effectTriggerTimes, 2); 48 | 49 | a.set(1); 50 | a.set(2); 51 | a.set(3); 52 | expect(effectTriggerTimes, 2); 53 | 54 | b.set(2); 55 | expect(effectTriggerTimes, 3); 56 | 57 | a.set(4); 58 | a.set(5); 59 | a.set(6); 60 | expect(effectTriggerTimes, 3); 61 | 62 | b.set(0); 63 | expect(effectTriggerTimes, 4); 64 | 65 | a.set(7); 66 | a.set(8); 67 | a.set(9); 68 | expect(effectTriggerTimes, 4); 69 | }); 70 | 71 | test("should pause tracking in effect scope", () { 72 | final s = signal(0); 73 | int effectTriggerTimes = 0; 74 | effectScope(() { 75 | effect(() { 76 | effectTriggerTimes++; 77 | final currentSub = setActiveSub(null); 78 | s(); 79 | setActiveSub(currentSub); 80 | }); 81 | }); 82 | 83 | expect(effectTriggerTimes, 1); 84 | 85 | s.set(1); 86 | s.set(2); 87 | s.set(3); 88 | expect(effectTriggerTimes, 1); 89 | }); 90 | } 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 |

4 | 5 |

6 | 7 | Alien Signals on pub.dev 8 | 9 | 10 | testing status 11 | 12 | Ask DeepWiki 13 |

14 | 15 | ## 🎊 Get Started Today! 16 | 17 | ```dart 18 | // Your reactive journey starts here 19 | import 'package:alien_signals/alien_signals.dart'; 20 | 21 | final welcome = signal('🎉 Welcome to Alien Signals!'); 22 | effect(() => print(welcome())); 23 | ``` 24 | 25 | ## 🌟 What is Alien Signals? 26 | 27 | Alien Signals is a reactive state management library that brings the power of signals to Dart and Flutter applications. Originally inspired by [StackBlitz's alien-signals](https://github.com/stackblitz/alien-signals), our Dart implementation provides: 28 | 29 | - **🪶 Ultra Lightweight**: Minimal overhead, maximum efficiency 30 | - **🎯 Simple API**: Intuitive `signal()`, `computed()`, and `effect()` functions 31 | - **🔧 Production Ready**: Battle-tested through comprehensive beta releases 32 | 33 | ## 🚀 Key Features 34 | 35 | ### Core Reactive Primitives 36 | 37 | ```dart 38 | import 'package:alien_signals/alien_signals.dart'; 39 | 40 | void main() { 41 | // Create reactive state 42 | final count = signal(0); 43 | 44 | // Create derived state 45 | final doubled = computed((_) => count() * 2); 46 | 47 | // Create side effects 48 | effect(() { 49 | print('Count: ${count()}, Doubled: ${doubled()}'); 50 | }); 51 | 52 | // Update state - triggers all dependencies 53 | count.set(1); // Output: Count: 1, Doubled: 2 54 | } 55 | ``` 56 | 57 | ### Advanced Features 58 | 59 | - **Effect Scopes**: Group and manage effects together 60 | - **Batch Operations**: Control when reactivity updates occur 61 | - **Flexible API**: Both high-level presets and low-level system access 62 | 63 | ## 📦 Installation 64 | 65 | To install Alien Signals, add the following to your `pubspec.yaml`: 66 | 67 | ```yaml 68 | dependencies: 69 | alien_signals: ^2.0.1 70 | ``` 71 | 72 | Alternatively, you can run the following command: 73 | 74 | ```bash 75 | dart pub add alien_signals 76 | ``` 77 | 78 | ## 🌍 Community & Ecosystem 79 | 80 | ### Adoptions 81 | 82 | - **[Solidart](https://github.com/nank1ro/solidart)** - Signals for Flutter inspired by SolidJS 83 | - **[Oref](https://github.com/medz/oref)** - Magical reactive state management for Flutter 84 | - **[flutter_compositions](https://github.com/yoyo930021/flutter_compositions)** - Vue-inspired reactive building blocks for Flutter 85 | 86 | ### Growing Ecosystem 87 | Join our thriving community of developers building reactive applications with Alien Signals! 88 | 89 | ## 📚 Resources 90 | 91 | - **[API Documentation](https://pub.dev/documentation/alien_signals/latest/)** - Complete API reference 92 | - **[Examples](https://github.com/medz/alien-signals-dart/tree/main/example)** - Code examples and demos 93 | - **[Migration Guide](MIGRATION.md)** - Upgrade instructions 94 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | A friendly alien mascot holding a stylized Dart logo in the center, 4 | with a small character on the left sending horizontal signal waves to the right, 5 | representing reactive state management for Dart and Flutter. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | ALIEN 87 | SIGNALS 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /test/effect_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math' as math; 2 | 3 | import 'package:alien_signals/alien_signals.dart'; 4 | import 'package:alien_signals/preset.dart'; 5 | import 'package:alien_signals/system.dart' show ReactiveFlags; 6 | import 'package:test/test.dart'; 7 | 8 | void main() { 9 | test('should clear subscriptions when untracked by all subscribers', () { 10 | int bRunTimes = 0; 11 | 12 | final a = signal(1); 13 | final b = computed((_) { 14 | bRunTimes++; 15 | return a() * 2; 16 | }); 17 | final stopEffect = effect(() { 18 | b(); 19 | }); 20 | 21 | expect(bRunTimes, 1); 22 | a.set(2); 23 | expect(bRunTimes, 2); 24 | stopEffect(); 25 | a.set(3); 26 | expect(bRunTimes, 2); 27 | }); 28 | 29 | test('should not run untracked inner effect', () { 30 | final a = signal(3); 31 | final b = computed((_) => a() > 0); 32 | 33 | effect(() { 34 | if (b()) { 35 | effect(() { 36 | if (a() == 0) { 37 | throw StateError('bad'); 38 | } 39 | }); 40 | } 41 | }); 42 | 43 | a.set(2); 44 | a.set(1); 45 | a.set(0); 46 | }); 47 | 48 | test('should run outer effect first', () { 49 | final a = signal(1); 50 | final b = signal(1); 51 | 52 | effect(() { 53 | if (a() != 0) { 54 | effect(() { 55 | b(); 56 | if (a() == 0) { 57 | throw StateError("bad"); 58 | } 59 | }); 60 | } 61 | }); 62 | 63 | startBatch(); 64 | b.set(0); 65 | a.set(0); 66 | endBatch(); 67 | }); 68 | 69 | test('should not trigger inner effect when resolve maybe dirty', () { 70 | final a = signal(0); 71 | final b = computed((_) => a() % 2); 72 | 73 | int innerTriggerTimes = 0; 74 | 75 | effect(() { 76 | effect(() { 77 | b(); 78 | innerTriggerTimes++; 79 | if (innerTriggerTimes >= 2) { 80 | throw StateError("bad"); 81 | } 82 | }); 83 | }); 84 | 85 | a.set(2); 86 | }); 87 | 88 | test('should notify inner effects in the same order as non-inner effects', 89 | () { 90 | final a = signal(0); 91 | final b = signal(0); 92 | final c = computed((_) => a() - b()); 93 | final order1 = []; 94 | final order2 = []; 95 | final order3 = []; 96 | 97 | effect(() { 98 | order1.add('effect1'); 99 | a(); 100 | }); 101 | effect(() { 102 | order1.add('effect2'); 103 | a(); 104 | b(); 105 | }); 106 | 107 | effect(() { 108 | c(); 109 | effect(() { 110 | order2.add('effect1'); 111 | a(); 112 | }); 113 | effect(() { 114 | order2.add('effect2'); 115 | a(); 116 | b(); 117 | }); 118 | }); 119 | 120 | effectScope(() { 121 | effect(() { 122 | order3.add('effect1'); 123 | a(); 124 | }); 125 | effect(() { 126 | order3.add('effect2'); 127 | a(); 128 | b(); 129 | }); 130 | }); 131 | 132 | order1.length = 0; 133 | order2.length = 0; 134 | order3.length = 0; 135 | 136 | startBatch(); 137 | b.set(1); 138 | a.set(1); 139 | endBatch(); 140 | 141 | expect(order1, ['effect2', 'effect1']); 142 | expect(order2, order1); 143 | expect(order3, order1); 144 | }); 145 | 146 | test('should custom effect support batch', () { 147 | void batchEffect(void Function() fn) { 148 | effect(() { 149 | startBatch(); 150 | try { 151 | return fn(); 152 | } finally { 153 | endBatch(); 154 | } 155 | }); 156 | } 157 | 158 | final logs = []; 159 | final a = signal(0); 160 | final b = signal(0); 161 | 162 | final aa = computed((_) { 163 | logs.add('aa-0'); 164 | if (a() == 0) { 165 | b.set(1); 166 | } 167 | logs.add('aa-1'); 168 | }); 169 | 170 | final bb = computed((_) { 171 | logs.add('bb'); 172 | return b(); 173 | }); 174 | 175 | batchEffect(() { 176 | bb(); 177 | }); 178 | batchEffect(() { 179 | aa(); 180 | }); 181 | 182 | expect(logs, ['bb', 'aa-0', 'aa-1', 'bb']); 183 | }); 184 | 185 | test('should duplicate subscribers do not affect the notify order', () { 186 | final src1 = signal(0); 187 | final src2 = signal(0); 188 | final order = []; 189 | 190 | effect(() { 191 | order.add('a'); 192 | final currentSub = setActiveSub(); 193 | final isOne = src2() == 1; 194 | setActiveSub(currentSub); 195 | if (isOne) { 196 | src1(); 197 | } 198 | src2(); 199 | src1(); 200 | }); 201 | effect(() { 202 | order.add('b'); 203 | src1(); 204 | }); 205 | src2.set(1); // src1.subs: a -> b -> a 206 | 207 | order.length = 0; 208 | src1.set(src1() + 1); 209 | 210 | expect(order, ['a', 'b']); 211 | }); 212 | 213 | test('should handle side effect with inner effects', () { 214 | final a = signal(0); 215 | final b = signal(0); 216 | final order = []; 217 | 218 | effect(() { 219 | effect(() { 220 | a(); 221 | order.add('a'); 222 | }); 223 | effect(() { 224 | b(); 225 | order.add('b'); 226 | }); 227 | expect(order, ['a', 'b']); 228 | 229 | order.length = 0; 230 | b.set(1); 231 | a.set(1); 232 | expect(order, ['b', 'a']); 233 | }); 234 | }); 235 | 236 | test('should handle flags are indirectly updated during checkDirty', () { 237 | final a = signal(false); 238 | final b = computed((_) => a()); 239 | final c = computed((_) { 240 | b(); 241 | return 0; 242 | }); 243 | final d = computed((_) { 244 | c(); 245 | return b(); 246 | }); 247 | 248 | int triggers = 0; 249 | 250 | effect(() { 251 | d(); 252 | triggers++; 253 | }); 254 | expect(triggers, 1); 255 | a.set(true); 256 | expect(triggers, 2); 257 | }); 258 | 259 | test('should handle effect recursion for the first execution', () { 260 | final src1 = signal(0); 261 | final src2 = signal(0); 262 | 263 | int triggers1 = 0; 264 | int triggers2 = 0; 265 | 266 | effect(() { 267 | triggers1++; 268 | src1.set(math.min(src1() + 1, 5)); 269 | }); 270 | effect(() { 271 | triggers2++; 272 | src2.set(math.min(src2() + 1, 5)); 273 | src2(); 274 | }); 275 | 276 | expect(triggers1, 1); 277 | expect(triggers2, 1); 278 | }); 279 | 280 | test('should support custom recurse effect', () { 281 | final src = signal(0); 282 | 283 | int triggers = 0; 284 | 285 | effect(() { 286 | getActiveSub()!.flags &= ~ReactiveFlags.recursedCheck; 287 | triggers++; 288 | src.set(math.min(src() + 1, 5)); 289 | }); 290 | 291 | expect(triggers, 6); 292 | }); 293 | } 294 | -------------------------------------------------------------------------------- /lib/src/surface.dart: -------------------------------------------------------------------------------- 1 | import 'package:alien_signals/preset.dart' 2 | show 3 | setActiveSub, 4 | activeSub, 5 | link, 6 | stop, 7 | SignalNode, 8 | ComputedNode, 9 | EffectNode; 10 | import 'package:alien_signals/system.dart' show ReactiveFlags, ReactiveNode; 11 | 12 | /// A reactive signal that holds a value of type [T]. 13 | /// 14 | /// Signals are the foundation of the reactive system. They are observable 15 | /// values that notify their dependents when their value changes. 16 | /// 17 | /// Use the [call] method to read the current value of the signal. 18 | /// 19 | /// Example: 20 | /// ```dart 21 | /// final count = signal(0); 22 | /// print(count()); // prints: 0 23 | /// ``` 24 | abstract interface class Signal { 25 | /// Reads the current value of the signal. 26 | /// 27 | /// When called within a reactive context (like inside a [computed] or [effect]), 28 | /// this will establish a dependency relationship. 29 | T call(); 30 | } 31 | 32 | /// A reactive signal that can be both read and written. 33 | /// 34 | /// WritableSignal extends [Signal] to provide write capabilities. 35 | /// It allows updating the signal's value, which will trigger updates 36 | /// to all dependent computations and effects. 37 | /// 38 | /// Example: 39 | /// ```dart 40 | /// final count = signal(0); 41 | /// count.set(5); // sets value to 5 42 | /// print(count()); // prints: 5 43 | /// ``` 44 | abstract interface class WritableSignal implements Signal { 45 | /// Sets the value of this writable signal. 46 | /// 47 | /// This will update the signal's value and trigger notifications to all 48 | /// dependent computations and effects. 49 | /// 50 | /// Example: 51 | /// ```dart 52 | /// final count = signal(0); 53 | /// count.set(5); // sets value to 5 54 | /// print(count()); // prints: 5 55 | /// ``` 56 | /// 57 | /// - Parameter [value]: The new value to set. 58 | void set(T value); 59 | } 60 | 61 | /// A reactive computed value that derives from other signals. 62 | /// 63 | /// Computed values automatically recalculate when their dependencies change. 64 | /// They are lazily evaluated, meaning they only recompute when accessed 65 | /// and their dependencies have changed. 66 | /// 67 | /// Computed values are read-only and cannot be directly set. 68 | /// 69 | /// Example: 70 | /// ```dart 71 | /// final count = signal(2); 72 | /// final doubled = computed((prev) => count() * 2); 73 | /// print(doubled()); // prints: 4 74 | /// count(3); 75 | /// print(doubled()); // prints: 6 76 | /// ``` 77 | abstract interface class Computed implements Signal {} 78 | 79 | /// A reactive effect that runs side effects in response to signal changes. 80 | /// 81 | /// Effects automatically track their dependencies and re-run when any 82 | /// dependency changes. They are useful for performing side effects like 83 | /// DOM updates, logging, or API calls. 84 | /// 85 | /// Use the [call] method to stop the effect and clean up its subscriptions. 86 | /// 87 | /// Example: 88 | /// ```dart 89 | /// final count = signal(0); 90 | /// final dispose = effect(() { 91 | /// print('Count is: ${count()}'); 92 | /// }); 93 | /// // Later, stop the effect: 94 | /// dispose(); 95 | /// ``` 96 | abstract interface class Effect { 97 | /// Stops this effect and removes it from the reactive system. 98 | /// 99 | /// After calling this method, the effect will no longer respond to 100 | /// changes in its dependencies. 101 | void call(); 102 | } 103 | 104 | /// A scope that manages a collection of effects. 105 | /// 106 | /// EffectScope provides a way to group multiple effects together 107 | /// and dispose of them all at once. Any effects created within 108 | /// the scope will be automatically linked to it. 109 | /// 110 | /// Use the [call] method to stop all effects within this scope. 111 | /// 112 | /// Example: 113 | /// ```dart 114 | /// final scope = effectScope(() { 115 | /// effect(() => print('Effect 1')); 116 | /// effect(() => print('Effect 2')); 117 | /// }); 118 | /// // Later, stop all effects in the scope: 119 | /// scope(); 120 | /// ``` 121 | abstract interface class EffectScope { 122 | /// Stops all effects within this scope. 123 | /// 124 | /// This will recursively stop all child effects and nested scopes, 125 | /// cleaning up all reactive subscriptions. 126 | void call(); 127 | } 128 | 129 | /// Creates a writable signal with the given initial value. 130 | /// 131 | /// Signals are the most basic reactive primitive. They hold a value 132 | /// and notify their dependents when that value changes. 133 | /// 134 | /// The returned signal can be called without arguments to read its value, 135 | /// or with an argument to write a new value. 136 | /// 137 | /// Example: 138 | /// ```dart 139 | /// final count = signal(0); 140 | /// print(count()); // reads: 0 141 | /// count.set(5); // writes: 5 142 | /// print(count()); // reads: 5 143 | /// ``` 144 | /// 145 | /// - Parameter [initialValue]: The initial value of the signal. 146 | /// - Returns: A [WritableSignal] that can be read and written. 147 | @pragma('vm:prefer-inline') 148 | @pragma('dart2js:tryInline') 149 | @pragma('wasm:prefer-inline') 150 | WritableSignal signal(T initialValue) { 151 | return _SignalImpl( 152 | flags: ReactiveFlags.mutable, 153 | currentValue: initialValue, 154 | pendingValue: initialValue); 155 | } 156 | 157 | /// Creates a computed value that derives from other signals. 158 | /// 159 | /// Computed values automatically track the signals they depend on 160 | /// and recalculate when those dependencies change. They are lazily 161 | /// evaluated and cache their results until dependencies change. 162 | /// 163 | /// The getter function receives the previous computed value as its 164 | /// parameter (or `null` on first computation), which can be useful 165 | /// for incremental computations. 166 | /// 167 | /// Example: 168 | /// ```dart 169 | /// final firstName = signal('John'); 170 | /// final lastName = signal('Doe'); 171 | /// final fullName = computed((prev) { 172 | /// return '${firstName()} ${lastName()}'; 173 | /// }); 174 | /// print(fullName()); // "John Doe" 175 | /// lastName('Smith'); 176 | /// print(fullName()); // "John Smith" 177 | /// ``` 178 | /// 179 | /// - Parameter [getter]: A function that computes the value. Receives the 180 | /// previous value as a parameter (null on first run). 181 | /// - Returns: A [Computed] that automatically updates when dependencies change. 182 | @pragma('vm:prefer-inline') 183 | @pragma('dart2js:tryInline') 184 | @pragma('wasm:prefer-inline') 185 | Computed computed(T Function(T?) getter) { 186 | return _ComputedImpl(getter: getter, flags: ReactiveFlags.none); 187 | } 188 | 189 | /// Creates an effect that runs whenever its dependencies change. 190 | /// 191 | /// Effects are functions that run side effects in response to reactive 192 | /// state changes. They automatically track any signals accessed during 193 | /// execution and re-run when those signals change. 194 | /// 195 | /// The effect runs immediately upon creation and then again whenever 196 | /// its dependencies change. 197 | /// 198 | /// The returned [Effect] can be called to stop the effect and clean up 199 | /// its subscriptions. 200 | /// 201 | /// Example: 202 | /// ```dart 203 | /// final count = signal(0); 204 | /// final messages = []; 205 | /// 206 | /// final dispose = effect(() { 207 | /// messages.add('Count is: ${count()}'); 208 | /// }); 209 | /// 210 | /// count(1); // Effect runs again 211 | /// count(2); // Effect runs again 212 | /// 213 | /// dispose(); // Stop the effect 214 | /// count(3); // Effect no longer runs 215 | /// ``` 216 | /// 217 | /// - Parameter [fn]: The function to run as an effect. Will be executed 218 | /// immediately and re-executed when dependencies change. 219 | /// - Returns: An [Effect] that can be called to stop it. 220 | Effect effect(void Function() fn) { 221 | final e = _EffectImpl( 222 | fn: fn, 223 | flags: 6 /* ReactiveFlags.watching | ReactiveFlags.recursedCheck */ 224 | as ReactiveFlags, 225 | ), 226 | prevSub = setActiveSub(e); 227 | if (prevSub != null) link(e, prevSub, 0); 228 | try { 229 | fn(); 230 | return e; 231 | } finally { 232 | activeSub = prevSub; 233 | e.flags &= -5 /*~ ReactiveFlags.recursedCheck */; 234 | } 235 | } 236 | 237 | /// Creates a scope for managing multiple effects. 238 | /// 239 | /// Any effects created within the provided function will be linked 240 | /// to this scope. When the scope is disposed, all linked effects 241 | /// are automatically stopped. 242 | /// 243 | /// This is useful for organizing and cleaning up related effects, 244 | /// such as when a component is unmounted or a feature is disabled. 245 | /// 246 | /// Example: 247 | /// ```dart 248 | /// final count = signal(0); 249 | /// 250 | /// final scope = effectScope(() { 251 | /// effect(() => print('Effect 1: ${count()}')); 252 | /// effect(() => print('Effect 2: ${count() * 2}')); 253 | /// 254 | /// // Nested scopes are also supported 255 | /// effectScope(() { 256 | /// effect(() => print('Nested effect: ${count() * 3}')); 257 | /// }); 258 | /// }); 259 | /// 260 | /// count(1); // All effects run 261 | /// 262 | /// scope(); // Stop all effects in this scope 263 | /// count(2); // No effects run 264 | /// ``` 265 | /// 266 | /// - Parameter [fn]: A function that creates effects. All effects created 267 | /// within this function will be linked to the scope. 268 | /// - Returns: An [EffectScope] that can be called to stop all contained effects. 269 | @pragma('vm:prefer-inline') 270 | @pragma('dart2js:tryInline') 271 | @pragma('wasm:prefer-inline') 272 | EffectScope effectScope(void Function() fn) { 273 | final e = _EffectScopeImpl(flags: ReactiveFlags.none), 274 | prevSub = setActiveSub(e); 275 | if (prevSub != null) link(e, prevSub, 0); 276 | try { 277 | fn(); 278 | return e; 279 | } finally { 280 | activeSub = prevSub; 281 | } 282 | } 283 | 284 | final class _SignalImpl extends SignalNode implements WritableSignal { 285 | _SignalImpl({ 286 | required super.flags, 287 | required super.currentValue, 288 | required super.pendingValue, 289 | }); 290 | 291 | @override 292 | @pragma('vm:prefer-inline') 293 | @pragma('dart2js:tryInline') 294 | @pragma('wasm:prefer-inline') 295 | T call() => get(); 296 | } 297 | 298 | final class _ComputedImpl extends ComputedNode implements Computed { 299 | _ComputedImpl({required super.flags, required super.getter}); 300 | 301 | @override 302 | @pragma('vm:prefer-inline') 303 | @pragma('dart2js:tryInline') 304 | @pragma('wasm:prefer-inline') 305 | T call() => get(); 306 | } 307 | 308 | final class _EffectImpl extends EffectNode implements Effect { 309 | _EffectImpl({required super.flags, required super.fn}); 310 | 311 | @override 312 | @pragma('vm:prefer-inline') 313 | @pragma('dart2js:tryInline') 314 | @pragma('wasm:prefer-inline') 315 | void call() => stop(this); 316 | } 317 | 318 | class _EffectScopeImpl extends ReactiveNode implements EffectScope { 319 | _EffectScopeImpl({required super.flags}); 320 | 321 | @override 322 | @pragma('vm:prefer-inline') 323 | @pragma('dart2js:tryInline') 324 | @pragma('wasm:prefer-inline') 325 | void call() => stop(this); 326 | } 327 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.1.1 2 | 3 | - fix: remove unsupported `@pragma('dart2js:tryInline')` on top-level fields for dart2js 4 | - test: add dart2js compile regression test 5 | 6 | ## 2.1.0 7 | 8 | - **preset**: Rename `SignalNode.update`/`ComputedNode.update` to `didUpdate` 9 | - **preset**: Rename `ComputedNode.value` to `currentValue` 10 | 11 | ## 2.0.1 12 | 13 | - pref: replace bitwise flags with inline comments for clarity 14 | - example: add preset playground example 15 | 16 | ## 2.0.0 17 | 18 | Status: Released (2025-11-26) 19 | 20 | 🚀 **Major Architecture Refactoring** 21 | 22 | Version 2.0 represents a complete architectural overhaul of `alien_signals`, introducing a cleaner separation between the user-facing API and the reactive engine implementation. This release improves performance, maintainability, and developer experience while maintaining core functionality. 23 | 24 | ### 💥 Breaking Changes 25 | 26 | #### WritableSignal API Changes 27 | - **BREAKING**: WritableSignal now uses separate methods for reading and writing 28 | ```dart 29 | // Before (1.x): signal(value) for both read and write 30 | final count = signal(0); 31 | count(5); // Set value 32 | count(5, true); // Set with nulls parameter 33 | final val = count(); // Get value 34 | 35 | // After (2.0): Separate call() for read and set() for write 36 | final count = signal(0); 37 | count.set(5); // Set value - clearer intent 38 | final val = count(); // Get value - unchanged 39 | ``` 40 | - Removed `nulls` parameter - no longer needed with explicit `set()` method 41 | - `set()` method returns void instead of the value 42 | - Clearer separation between read and write operations 43 | 44 | #### API Surface Restructuring 45 | - **BREAKING**: Effect and EffectScope disposal now uses callable syntax `()` instead of `.dispose()` method 46 | ```dart 47 | // Before: effect.dispose() 48 | // After: effect() 49 | ``` 50 | - **BREAKING**: Library exports reorganized into layers: 51 | - Main exports now come from `surface.dart` (Signal, WritableSignal, Computed, Effect, EffectScope, signal, computed, effect, effectScope) 52 | - Batch controls remain in preset exports (startBatch, endBatch, trigger) 53 | - **BREAKING**: Low-level APIs no longer exported by default: 54 | - `getBatchDepth()`, `getActiveSub()`, `setActiveSub()` now require explicit import from `preset.dart` 55 | - Most applications should not need these APIs 56 | 57 | #### Reactive System Changes 58 | - **BREAKING**: `ReactiveSystem` refactored from concrete implementation to abstract class 59 | ```dart 60 | // Before: const ReactiveSystem system = PresetReactiveSystem(); 61 | // After: abstract class ReactiveSystem { ... } 62 | ``` 63 | - `ReactiveSystem` is now an abstract base class for custom implementations 64 | - The preset system internally extends this abstract class 65 | - Enables advanced users to create custom reactive systems by extending `ReactiveSystem` 66 | - Most users won't interact with this directly as it's handled internally by the library 67 | 68 | ### ✨ New Features 69 | 70 | #### Manual Trigger Function 71 | - **NEW**: Added `trigger()` function for imperatively initiating reactive updates 72 | ```dart 73 | trigger(() { 74 | // Signal accesses here will propagate to subscribers 75 | someSignal(); 76 | }); 77 | ``` 78 | - Useful for testing, forced updates, and non-reactive code integration 79 | - Creates temporary reactive context without persistent effects 80 | 81 | ### 🏗️ Architecture Improvements 82 | 83 | #### Layer Separation 84 | - Complete separation into three distinct layers: 85 | - `surface.dart`: High-level user-facing API with clean interfaces 86 | - `preset.dart`: Reactive engine with node implementations (SignalNode, ComputedNode, EffectNode) 87 | - `system.dart`: Core algorithms and data structures (Link, ReactiveNode, ReactiveFlags) 88 | - Better encapsulation with private implementation classes (`_SignalImpl`, `_ComputedImpl`, `_EffectImpl`, `_EffectScopeImpl`) 89 | - Improved inheritance hierarchy with surface implementations properly extending preset nodes 90 | 91 | ### ⚡ Performance Enhancements 92 | 93 | - **Aggressive inlining**: Strategic `@pragma` annotations on hot paths for better performance 94 | - **Optimized dependency tracking**: Improved cycle management and link traversal 95 | - **Reduced allocations**: More efficient memory usage in the reactive graph 96 | - **Better cycle detection**: Enhanced algorithm for circular dependency detection 97 | 98 | ### 📝 Documentation 99 | 100 | - **Comprehensive API documentation**: Added detailed doc comments for all public APIs 101 | - **Migration guide**: Complete guide for upgrading from 1.x to 2.0 102 | - **Code examples**: Updated all examples to use new API patterns 103 | 104 | ### 🔧 Internal Changes 105 | 106 | - Removed legacy code and deprecated patterns 107 | - Improved type safety and null handling 108 | - Cleaner separation of concerns between modules 109 | - More maintainable codebase structure 110 | - Fix trigger dependency cleanup to prevent stale notifications 111 | 112 | ### 📦 Migration 113 | 114 | See [MIGRATION.md](MIGRATION.md#migration-from-1x-to-20) for detailed migration instructions from 1.x. 115 | 116 | Key migration points: 117 | 1. Replace `signal(value)` with `signal.set(value)` for write operations 118 | 2. Replace `.dispose()` with `()` for effects and scopes 119 | 3. Add explicit imports for low-level APIs if needed 120 | 4. Consider using new `trigger()` function for one-time reactive operations 121 | 122 | ### 🙏 Acknowledgments 123 | 124 | Thanks to all contributors and users who provided feedback that shaped this major release. 125 | 126 | --- 127 | 128 | ## 1.0.3 129 | 130 | - Restrict preset developer exports to public API 131 | - Rename update method to shouldUpdated 132 | 133 | ## 1.0.1 134 | 135 | - Change signal interface to use `call()` instead of `.value` getter/setter 136 | 137 | ## 1.0.0 138 | 139 | Status: Released (2025-01-15) 140 | 141 | 🎉 **First Stable Release!** 142 | 143 | After months of development and multiple beta releases, we're excited to announce the first stable version of Alien Signals for Dart! This release brings a mature, high-performance reactive signal library to the Dart ecosystem. 144 | 145 | ### 🚀 What's New in 1.0.0 146 | 147 | - **Stable API**: All APIs are now stable and ready for production use 148 | - **Better Dart Integration**: Redesigned API that feels natural in Dart 149 | - **Enhanced Performance**: Optimized reactive system with cycle-based dependency tracking 150 | - **Comprehensive Documentation**: Complete API documentation and examples 151 | - **Production Ready**: Battle-tested through beta releases and community feedback 152 | 153 | ### 📋 Key Features 154 | 155 | - **Lightweight & Fast**: The lightest signal library for Dart with excellent performance 156 | - **Simple API**: Easy-to-use `signal()`, `computed()`, and `effect()` functions 157 | - **TypeScript Origins**: Based on the excellent [stackblitz/alien-signals](https://github.com/stackblitz/alien-signals) 158 | - **Effect Scopes**: Manage groups of effects with `effectScope()` 159 | - **Batch Updates**: Control reactivity with `startBatch()` and `endBatch()` 160 | 161 | ### 🎯 Getting Started 162 | 163 | ```dart 164 | import 'package:alien_signals/alien_signals.dart'; 165 | 166 | void main() { 167 | // Create a signal 168 | final count = signal(0); 169 | 170 | // Create a computed value 171 | final doubled = computed((_) => count.value * 2); 172 | 173 | // Create an effect 174 | effect(() { 175 | print('Count: ${count.value}, Doubled: ${doubled.value}'); 176 | }); 177 | 178 | // Update the signal 179 | count.value++; // Prints: Count: 1, Doubled: 2 180 | } 181 | ``` 182 | 183 | ### 🔧 Migration from Beta 184 | 185 | If you're upgrading from a beta version, please see our [Migration Guide](MIGRATION.md) for detailed instructions. 186 | 187 | ### 🙏 Acknowledgments 188 | 189 | Special thanks to the StackBlitz team for creating the original alien-signals library and to our community for feedback during the beta period. 190 | 191 | --- 192 | 193 | ## 1.0.0-beta.4 194 | 195 | Status: Released(2025-09-30) 196 | 197 | - **FIX**: remove assertion in effectOper 198 | 199 | ## 1.0.0-beta.3 200 | 201 | Status: Released(2025-09-30) 202 | 203 | - **FEATURE**: Add `preset_developer.dart`, the basics of exporting Preset 204 | 205 | ## 1.0.0-beta.2 206 | 207 | Status: Released(2025-09-29) 208 | 209 | - Have good auto-imports, avoid auto-importing src 210 | - `Effect`/`EffectScope`'s `call()` is renamed to `dispose()` 211 | 212 | ## 1.0.0-beta.1 213 | 214 | Status: Released(2025-09-29) 215 | 216 | ### System 217 | 218 | - **BREAKING CHANGE**: sync [alien-signal](https://github.com/stackblitz/alien-signals) `3.0.0` version 219 | - **BREAKING CHANGE**: `link` add a third version count param 220 | - **BREAKING CHANGE**: remove `startTracking` and `endTracking` API 221 | 222 | #### Preset 223 | 224 | - **BREAKING CHANGE**: remove deprecated `system.dart` entry point export 225 | - **BREAKING CHANGE**: migrate `batchDepth` to `getBatchDepth()` 226 | - **BREAKING CHANGE**: rename `getCurrentSub/setCurrentSub` to `getActiveSub/setActiveSub` 227 | - **BREAKING CHANGE**: remove `getCurrentScope/getCurrentScope`, using `getActiveScope/setActiveScope` 228 | - **BREAKING CHANGE**: remove signal/computed `call()`, using `.value` property 229 | - **FEATURE**: add `Signal`,`WritableSignal`,`Computed`,`Effect` abstract interface 230 | 231 | ## 0.5.5 232 | 233 | Status: Released (2025-09-28) 234 | 235 | - Deprecate library entry point of `package:alien_signals/system.dart` 236 | 237 | ## 0.5.4 238 | 239 | - perf(system): Move dirty flag declaration outside loop 240 | 241 | ## v0.5.3 242 | 243 | > Sync upstream [alien-signals](https://github.com/stackblitz/alien-signals/commit/503c9e6cec6dea3334fefaccf76e4170d5c2da7c)v2.0.7 244 | 245 | - **system**: Optimize isValidLink implementation 246 | - **system**: Optimize reactive system dirty flag propagation loop 247 | - **system**: Refactor reactive system dependency traversal logic 248 | - **system**: Use explicit nullable types for Link variables 249 | - **system**: Optimize reactive system flag checking logic 250 | - **system**: Simplify recursive dependency check 251 | 252 | ## v0.5.2 253 | 254 | - fix: Introduce per-cycle version to dedupe dependency links 255 | 256 | ## v0.5.1 257 | 258 | - fix: Remove non-contiguous dep check 259 | 260 | ## v0.5.0 261 | 262 | - pref: refactor: queue effects in linked effects list 263 | - pref: Add pragma annotations for inlining to startTracking 264 | - **BREAKING CHANGE**: Remove `pauseTracking` and `resumeTracking` 265 | - **BREAKING CHANGE**: Remove ReactiveFlags, change flags to int type 266 | 267 | ## v0.4.4 268 | 269 | - perf: Replace magic number with bitwise operation for clarity 270 | 271 | ## v0.4.3 272 | 273 | - perf: Optimize computed values by using final result value in bit marks calculation (reduces unnecessary computations) 274 | - docs: Add code comments to public API for better documentation 275 | 276 | ## v0.4.2 277 | 278 | - pref: Add prefer-inline pragmas to core reactive methods. (Thx [#17](https://github.com/medz/alien-signals-dart/issues/17) at [@Kypsis](https://github.com/Kypsis)) 279 | 280 | ## v0.4.1 281 | 282 | - refactor: simplifying unlink sub in effect cleanup 283 | - refactor: update pauseTracking and resumeTracking to use setCurrentSub 284 | - refactor(preset): change queuedEffects to map like JS Array 285 | - refactor: remove generic type from effect, effectScope 286 | - refactor: more accurate unwatched handling 287 | - fix: invalidate parent effect when executing effectScope 288 | - test: update untrack tests 289 | - test: use setCurrentSub instead of pauseTracking 290 | 291 | **NOTE**: Sync upstream v2.0.4 version. 292 | 293 | ## v0.4.0 294 | 295 | ### Major Changes 296 | 297 | - Sync with upstream `alien-signal` v2.0.1 298 | - Complete package restructuring and reorganization 299 | - Remove workspace structure in favor of a single package repository 300 | 301 | ### Features 302 | 303 | - Implement improved reactive system architecture 304 | - Add comprehensive signal management capabilities 305 | - Add `signal()` function for creating reactive state 306 | - Add `computed()` function for derived state 307 | - Add `effect()` function for side effects 308 | - Add `effectScope()` for managing groups of effects 309 | - Add batch processing with `startBatch()` and `endBatch()` 310 | - Add tracking control with `pauseTracking()` and `resumeTracking()` 311 | 312 | ### Development 313 | 314 | - Lower minimum Dart SDK requirement to ^3.6.0 (from ^3.7.0) 315 | - Add extensive test suite for reactivity features 316 | - Remove separate packages in favor of a single focused package 317 | - Update CI workflow for multi-SDK testing 318 | - Add comprehensive examples showing signal features 319 | 320 | ### Documentation 321 | 322 | - Expanded example code to demonstrate more signal features 323 | -------------------------------------------------------------------------------- /MIGRATION.md: -------------------------------------------------------------------------------- 1 | # Migration Guide 2 | 3 | This guide helps you migrate from earlier versions of `alien_signals` to latest version 4 | 5 | ## Migration from 1.x to 2.0 6 | 7 | Version 2.0 represents a complete architectural refactoring of `alien_signals` that introduces a cleaner API surface and improved performance characteristics. This guide covers the breaking changes and provides detailed migration instructions. 8 | 9 | ### Overview of Changes 10 | 11 | The 2.0 release restructures the library into two layers: 12 | - **Surface API** (`surface.dart`): The high-level, user-facing API with clean interfaces 13 | - **Preset System** (`preset.dart`): The low-level reactive engine implementation 14 | 15 | This separation provides better encapsulation while maintaining full backward compatibility for core functionality. 16 | 17 | ### Breaking Changes 18 | 19 | #### 1. WritableSignal Read/Write Separation 20 | 21 | The WritableSignal API has been redesigned to separate read and write operations for clearer intent and better type safety. 22 | 23 | ```dart 24 | // ❌ Version 1.x - Single call() method for both operations 25 | final count = signal(0); 26 | count(5); // Set value 27 | count(5, true); // Set with nulls parameter 28 | final val = count(); // Get value 29 | 30 | // ✅ Version 2.0 - Separate methods for clarity 31 | final count = signal(0); 32 | count.set(5); // Set value - explicit write operation 33 | final val = count(); // Get value - read-only operation 34 | ``` 35 | 36 | **Key changes:** 37 | - Write operations now use explicit `set()` method 38 | - Removed confusing `nulls` parameter 39 | - `set()` returns void (previously returned the value) 40 | - `call()` is now read-only for WritableSignal 41 | - Clearer separation of concerns 42 | 43 | **Rationale**: This change eliminates ambiguity about whether `call()` is reading or writing, making code more maintainable and easier to understand. 44 | 45 | #### 2. Effect Disposal Pattern 46 | 47 | The disposal mechanism has been unified to use callable objects instead of methods, aligning with functional programming patterns. 48 | 49 | ```dart 50 | // ❌ Version 1.x 51 | final e = effect(() => print(count())); 52 | e.dispose(); // Method call 53 | 54 | final scope = effectScope(() { /* effects */ }); 55 | scope.dispose(); // Method call 56 | 57 | // ✅ Version 2.0 58 | final e = effect(() => print(count())); 59 | e(); // Callable - stops the effect 60 | 61 | final scope = effectScope(() { /* effects */ }); 62 | scope(); // Callable - stops all effects in scope 63 | ``` 64 | 65 | **Rationale**: This change provides a more concise API and better aligns with Dart's callable object pattern. 66 | 67 | #### 3. Library Export Restructure 68 | 69 | The main library exports have been reorganized to provide a cleaner separation of concerns. 70 | 71 | ```dart 72 | // Version 1.x exports (from alien_signals.dart) 73 | export 'src/preset.dart' show 74 | Signal, WritableSignal, Computed, Effect, EffectScope, // interfaces 75 | signal, computed, effect, effectScope, // factories 76 | getBatchDepth, getActiveSub, setActiveSub, // low-level 77 | startBatch, endBatch; // batch control 78 | 79 | // Version 2.0 exports (from alien_signals.dart) 80 | export 'src/surface.dart'; // All user-facing APIs 81 | export 'src/preset.dart' show startBatch, endBatch, trigger; // Only essential controls 82 | ``` 83 | 84 | **Impact**: Low-level APIs (`getBatchDepth`, `getActiveSub`, `setActiveSub`) are no longer part of the default exports. 85 | 86 | #### 4. Access to Low-Level APIs 87 | 88 | If your code depends on low-level reactive system APIs, you now need explicit imports: 89 | 90 | ```dart 91 | // ❌ Version 1.x - Available by default 92 | import 'package:alien_signals/alien_signals.dart'; 93 | final depth = getBatchDepth(); 94 | final sub = getActiveSub(); 95 | 96 | // ✅ Version 2.0 - Requires explicit import 97 | import 'package:alien_signals/preset.dart' show getBatchDepth, getActiveSub, setActiveSub; 98 | final depth = getBatchDepth(); 99 | final sub = getActiveSub(); 100 | ``` 101 | 102 | #### 5. ReactiveSystem Refactoring 103 | 104 | The `ReactiveSystem` has been refactored from a concrete implementation to an abstract base class: 105 | 106 | ```dart 107 | // ❌ Version 1.x - Concrete class with preset implementation 108 | const ReactiveSystem system = PresetReactiveSystem(); 109 | 110 | // ✅ Version 2.0 - Abstract base class for extensions 111 | abstract class ReactiveSystem { 112 | bool update(ReactiveNode node); 113 | void notify(ReactiveNode node); 114 | void unwatched(ReactiveNode node); 115 | 116 | // Provided implementations: 117 | void link(ReactiveNode dep, ReactiveNode sub, int version) { ... } 118 | Link? unlink(Link link, ReactiveNode sub) { ... } 119 | void propagate(Link link) { ... } 120 | void shallowPropagate(Link link) { ... } 121 | bool checkDirty(Link link, ReactiveNode sub) { ... } 122 | } 123 | ``` 124 | 125 | **Impact**: This is primarily an internal change that most users won't encounter directly. The reactive system is managed internally by the library. However, advanced users can now extend `ReactiveSystem` to create custom reactive behaviors. If you were previously using `PresetReactiveSystem` directly, you'll need to either use the high-level API or create a custom implementation by extending `ReactiveSystem`. 126 | 127 | ### New Features 128 | 129 | #### The `trigger` Function 130 | 131 | Version 2.0 introduces `trigger()` for manually initiating reactive updates without creating persistent effects: 132 | 133 | ```dart 134 | final firstName = signal('John'); 135 | final lastName = signal('Doe'); 136 | final fullName = computed(() => '${firstName()} ${lastName()}'); 137 | 138 | // Manually trigger all fullName subscribers 139 | trigger(() { 140 | fullName(); // Access within trigger causes propagation 141 | }); 142 | ``` 143 | 144 | Use cases: 145 | - Testing reactive flows 146 | - Forcing UI updates 147 | - Integrating with non-reactive code 148 | 149 | ### Architecture Changes 150 | 151 | #### Layer Separation 152 | 153 | The codebase is now organized into distinct layers: 154 | 155 | ``` 156 | ┌─────────────────────────────────────┐ 157 | │ surface.dart │ ← User-facing API 158 | │ (Signal, Computed, Effect, etc.) │ 159 | ├─────────────────────────────────────┤ 160 | │ preset.dart │ ← Reactive engine 161 | │ (SignalNode, ComputedNode, etc.) │ 162 | ├─────────────────────────────────────┤ 163 | │ system.dart │ ← Core algorithms 164 | │ (Link, ReactiveNode, etc.) │ 165 | └─────────────────────────────────────┘ 166 | ``` 167 | 168 | #### Implementation Inheritance 169 | 170 | The surface implementations now properly extend preset nodes: 171 | 172 | ```dart 173 | // Version 2.0 internal structure (simplified) 174 | class SignalNode extends ReactiveNode { /* preset.dart */ } 175 | class _SignalImpl extends SignalNode implements WritableSignal { /* surface.dart */ } 176 | 177 | class ComputedNode extends ReactiveNode { /* preset.dart */ } 178 | class _ComputedImpl extends ComputedNode implements Computed { /* surface.dart */ } 179 | 180 | class EffectNode extends LinkedEffect { /* preset.dart */ } 181 | class _EffectImpl extends EffectNode implements Effect { /* surface.dart */ } 182 | ``` 183 | 184 | This hierarchy provides better code reuse and maintainability. 185 | 186 | ### Migration Guide 187 | 188 | #### Step 1: Update Your Dependencies 189 | 190 | ```yaml 191 | dependencies: 192 | alien_signals: ^2.0.0 193 | ``` 194 | 195 | Run `dart pub upgrade` to fetch the latest version. 196 | 197 | #### Step 2: Update Signal Write Operations 198 | 199 | Search your codebase for signal write operations and update them: 200 | 201 | ```dart 202 | // Find patterns like: 203 | signalInstance(value); 204 | signalInstance(value, true); 205 | signalInstance(null, true); 206 | 207 | // Replace with: 208 | signalInstance.set(value); 209 | signalInstance.set(value); 210 | signalInstance.set(null); 211 | ``` 212 | 213 | **Regular expression for finding:** 214 | ```regex 215 | // Find signal writes (excluding reads) 216 | \b(\w+)\([^)]+\)(?!\s*[;,)]) 217 | ``` 218 | 219 | **Note**: Be careful not to change read operations `signal()` which should remain unchanged. 220 | 221 | #### Step 3: Update All Disposal Calls 222 | 223 | Search your codebase for `.dispose()` calls and replace them: 224 | 225 | ```dart 226 | // Search for this pattern 227 | effectInstance.dispose(); 228 | scopeInstance.dispose(); 229 | 230 | // Replace with 231 | effectInstance(); 232 | scopeInstance(); 233 | ``` 234 | 235 | **Automated approach** (using sed or similar): 236 | ```bash 237 | # For effects 238 | sed -i 's/\.dispose()/()/' **/*.dart 239 | 240 | # Review changes before committing 241 | git diff 242 | ``` 243 | 244 | #### Step 4: Handle Low-Level API Usage 245 | 246 | Audit your codebase for low-level API usage: 247 | 248 | ```dart 249 | // Check for these functions: 250 | getBatchDepth() 251 | getActiveSub() 252 | setActiveSub() 253 | ``` 254 | 255 | For each occurrence, either: 256 | 257 | 1. **Remove if unnecessary** - Most application code doesn't need these 258 | 2. **Add explicit import** - If genuinely required: 259 | ```dart 260 | import 'package:alien_signals/preset.dart' 261 | show getBatchDepth, getActiveSub, setActiveSub; 262 | ``` 263 | 264 | **ReactiveSystem Usage**: If your code directly uses `ReactiveSystem` or `PresetReactiveSystem`, this is likely advanced usage. The system is now an abstract class that can be extended for custom implementations. Consider whether you truly need direct system access or if the high-level API suffices. For custom reactive systems, extend the `ReactiveSystem` abstract class and implement the required methods. 265 | 266 | #### Step 5: Leverage New Features 267 | 268 | Consider adopting the new `trigger()` function where appropriate: 269 | 270 | ```dart 271 | // Replace manual effect creation/disposal patterns 272 | final tempEffect = effect(() => someComputation()); 273 | tempEffect(); // Immediately dispose 274 | 275 | // With the cleaner trigger approach 276 | trigger(() => someComputation()); 277 | ``` 278 | 279 | #### Step 6: Verify Your Application 280 | 281 | 1. **Run tests**: `dart test` 282 | 2. **Check for warnings**: `dart analyze` 283 | 3. **Test reactive flows**: Ensure all signals, computed values, and effects work correctly 284 | 4. **Performance testing**: Verify that performance characteristics meet expectations 285 | 286 | ### Complete Migration Example 287 | 288 | Here's a comprehensive example showing all the changes from 1.x to 2.0: 289 | 290 | **Version 1.x Code:** 291 | ```dart 292 | import 'package:alien_signals/alien_signals.dart'; 293 | 294 | class TodoStore { 295 | final todos = signal>([]); 296 | final filter = signal('all'); 297 | late final Computed> filteredTodos; 298 | late final Effect autoSaveEffect; 299 | late final EffectScope scope; 300 | 301 | TodoStore() { 302 | // Using low-level APIs 303 | print('Batch depth: ${getBatchDepth()}'); 304 | 305 | filteredTodos = computed((_) { 306 | final allTodos = todos(); 307 | final currentFilter = filter(); 308 | 309 | if (currentFilter == 'completed') { 310 | return allTodos.where((t) => t.startsWith('[x]')).toList(); 311 | } 312 | return allTodos; 313 | }); 314 | 315 | scope = effectScope(() { 316 | autoSaveEffect = effect(() { 317 | final items = todos(); 318 | saveToStorage(items); 319 | }); 320 | 321 | effect(() { 322 | print('Filtered todos: ${filteredTodos()}'); 323 | }); 324 | }); 325 | } 326 | 327 | void addTodo(String todo) { 328 | final current = todos(); 329 | todos([...current, todo]); // Write with call() 330 | } 331 | 332 | void setFilter(String newFilter) { 333 | filter(newFilter); // Write with call() 334 | } 335 | 336 | void dispose() { 337 | autoSaveEffect.dispose(); // Method call 338 | scope.dispose(); // Method call 339 | } 340 | 341 | void saveToStorage(List items) { 342 | // Save logic 343 | } 344 | } 345 | ``` 346 | 347 | **Version 2.0 Code:** 348 | ```dart 349 | import 'package:alien_signals/alien_signals.dart'; 350 | // Explicit import for low-level APIs if needed 351 | import 'package:alien_signals/preset.dart' show getBatchDepth; 352 | 353 | class TodoStore { 354 | final todos = signal>([]); 355 | final filter = signal('all'); 356 | late final Computed> filteredTodos; 357 | late final Effect autoSaveEffect; 358 | late final EffectScope scope; 359 | 360 | TodoStore() { 361 | // Using low-level APIs requires explicit import 362 | print('Batch depth: ${getBatchDepth()}'); 363 | 364 | filteredTodos = computed((_) { 365 | final allTodos = todos(); 366 | final currentFilter = filter(); 367 | 368 | if (currentFilter == 'completed') { 369 | return allTodos.where((t) => t.startsWith('[x]')).toList(); 370 | } 371 | return allTodos; 372 | }); 373 | 374 | scope = effectScope(() { 375 | autoSaveEffect = effect(() { 376 | final items = todos(); 377 | saveToStorage(items); 378 | }); 379 | 380 | effect(() { 381 | print('Filtered todos: ${filteredTodos()}'); 382 | }); 383 | }); 384 | } 385 | 386 | void addTodo(String todo) { 387 | final current = todos(); 388 | todos.set([...current, todo]); // Write with set() method 389 | } 390 | 391 | void setFilter(String newFilter) { 392 | filter.set(newFilter); // Write with set() method 393 | } 394 | 395 | void dispose() { 396 | autoSaveEffect(); // Function call 397 | scope(); // Function call 398 | } 399 | 400 | void saveToStorage(List items) { 401 | // Save logic 402 | } 403 | 404 | // New feature: Manual trigger 405 | void forceUpdate() { 406 | trigger(() { 407 | todos(); // Force propagation without creating an effect 408 | }); 409 | } 410 | } 411 | ``` 412 | 413 | **Summary of changes in this example:** 414 | - ✅ Signal writes use `.set()` method instead of call with value 415 | - ✅ Effects and scopes are disposed with `()` instead of `.dispose()` 416 | - ✅ Low-level APIs require explicit import from `preset.dart` 417 | - ✅ Added new `trigger()` function for manual updates 418 | 419 | ### Performance Improvements 420 | 421 | Version 2.0 includes several performance enhancements: 422 | 423 | - **Aggressive inlining**: Strategic use of `@pragma` annotations for hot paths 424 | - **Reduced allocations**: Optimized link management in the dependency graph 425 | - **Better cycle detection**: Improved algorithm for detecting circular dependencies 426 | - **Memory efficiency**: Cleaner separation reduces memory footprint 427 | 428 | ### Troubleshooting 429 | 430 | | Issue | Solution | 431 | |-------|----------| 432 | | `Too many positional arguments` | Replace `signal(value)` with `signal.set(value)` for writes | 433 | | `The method 'dispose' isn't defined` | Replace `.dispose()` with `()` | 434 | | `The getter 'getBatchDepth' isn't defined` | Add `import 'package:alien_signals/preset.dart' show getBatchDepth;` | 435 | | `The getter 'getActiveSub' isn't defined` | Add `import 'package:alien_signals/preset.dart' show getActiveSub;` | 436 | | Effects not triggering | Ensure you're calling the signal within a reactive context | 437 | | Memory leaks | Verify all effects are properly disposed with `()` | 438 | 439 | ### Best Practices for 2.0 440 | 441 | 1. **Use explicit write operations**: Always use `.set()` for signal writes for clarity 442 | 2. **Prefer the surface API**: Use the high-level API unless you have specific low-level requirements 443 | 3. **Dispose effects properly**: Always call `effect()` when an effect is no longer needed 444 | 4. **Use effect scopes**: Group related effects for easier cleanup 445 | 5. **Leverage trigger**: Use `trigger()` for one-time reactive operations 446 | 6. **Avoid low-level APIs**: The surface API should cover most use cases 447 | 448 | ### Further Resources 449 | 450 | - [Complete API Reference](https://pub.dev/documentation/alien_signals/latest/) 451 | - [Performance Benchmarks](https://github.com/medz/dart-reactivity-benchmark) 452 | 453 | --- 454 | 455 | **Need Help?** Open an issue on [GitHub](https://github.com/medz/alien-signals-dart/issues). 456 | -------------------------------------------------------------------------------- /lib/src/preset.dart: -------------------------------------------------------------------------------- 1 | import 'package:alien_signals/system.dart'; 2 | 3 | /// Global version counter for tracking dependency updates. 4 | /// 5 | /// Incremented whenever a reactive computation runs to ensure 6 | /// dependencies are properly tracked and invalidated. 7 | int cycle = 0; 8 | 9 | /// Current depth of nested batch operations. 10 | /// 11 | /// When greater than 0, effect execution is deferred until 12 | /// all batches complete to avoid redundant computations. 13 | int batchDepth = 0; 14 | 15 | /// The currently active subscriber node. 16 | /// 17 | /// Used during reactive computations to automatically track 18 | /// dependencies. When a signal is accessed, it links itself 19 | /// to this active subscriber. 20 | ReactiveNode? activeSub; 21 | 22 | /// Head of the queue of effects waiting to be executed. 23 | /// 24 | /// Effects are queued during propagation and executed 25 | /// together when the batch completes or flush is called. 26 | LinkedEffect? queuedEffects; 27 | 28 | /// Tail of the effects queue for O(1) append operations. 29 | LinkedEffect? queuedEffectsTail; 30 | 31 | @pragma('vm:prefer-inline') 32 | @pragma('wasm:prefer-inline') 33 | const system = PresetReactiveSystem(); 34 | 35 | @pragma('vm:prefer-inline') 36 | @pragma('wasm:prefer-inline') 37 | final link = system.link, 38 | unlink = system.unlink, 39 | propagate = system.propagate, 40 | checkDirty = system.checkDirty, 41 | shallowPropagate = system.shallowPropagate; 42 | 43 | /// A reactive node that can be linked in a queue of effects. 44 | /// 45 | /// Extends [ReactiveNode] to add queueing capabilities, allowing 46 | /// effects to be scheduled and executed in batch for efficiency. 47 | /// 48 | /// Used internally by the effect system to manage execution order 49 | /// and avoid redundant computations during reactive updates. 50 | class LinkedEffect extends ReactiveNode { 51 | /// Next effect in the execution queue. 52 | /// 53 | /// Forms a singly-linked list of effects waiting to be executed. 54 | LinkedEffect? nextEffect; 55 | 56 | LinkedEffect({ 57 | required super.flags, 58 | super.deps, 59 | super.depsTail, 60 | super.subs, 61 | super.subsTail, 62 | }); 63 | } 64 | 65 | /// A reactive signal node that holds a value of type [T]. 66 | /// 67 | /// SignalNode is the core primitive for reactive state. It stores 68 | /// a value that can be read and written, automatically tracking 69 | /// dependencies and notifying subscribers when the value changes. 70 | /// 71 | /// The node maintains both current and pending values to support 72 | /// batched updates and ensure consistency during propagation. 73 | class SignalNode extends ReactiveNode { 74 | /// The current committed value of the signal. 75 | T currentValue; 76 | 77 | /// The pending value to be committed on the next update. 78 | /// 79 | /// Allows for batching multiple changes before propagation. 80 | T pendingValue; 81 | 82 | SignalNode({ 83 | required super.flags, 84 | required this.currentValue, 85 | required this.pendingValue, 86 | }); 87 | 88 | /// Sets a new value for the signal. 89 | /// 90 | /// If the new value differs from the pending value, marks the 91 | /// signal as dirty and propagates changes to all subscribers. 92 | /// 93 | /// If not in a batch (batchDepth == 0), immediately flushes 94 | /// all queued effects. 95 | void set(T newValue) { 96 | if (!identical(pendingValue, newValue)) { 97 | pendingValue = newValue; 98 | flags = 99 | 17 /*ReactiveFlags.mutable | ReactiveFlags.dirty*/ as ReactiveFlags; 100 | if (subs case final Link subs) { 101 | propagate(subs); 102 | if (batchDepth == 0) flush(); 103 | } 104 | } 105 | } 106 | 107 | /// Gets the current value of the signal. 108 | /// 109 | /// If the signal is dirty, updates it first. Automatically 110 | /// establishes a dependency relationship with the active 111 | /// subscriber if one exists. 112 | /// 113 | /// Returns the current committed value. 114 | @pragma('vm:align-loops') 115 | T get() { 116 | if ((flags & ReactiveFlags.dirty) != ReactiveFlags.none) { 117 | if (didUpdate()) { 118 | final subs = this.subs; 119 | if (subs != null) { 120 | shallowPropagate(subs); 121 | } 122 | } 123 | } 124 | ReactiveNode? sub = activeSub; 125 | while (sub != null) { 126 | if ((sub.flags & 127 | 3 /*(ReactiveFlags.mutable | ReactiveFlags.watching)*/) != 128 | ReactiveFlags.none) { 129 | link(this, sub, cycle); 130 | break; 131 | } 132 | sub = sub.subs?.sub; 133 | } 134 | return currentValue; 135 | } 136 | 137 | /// Updates the signal's current value from its pending value. 138 | /// 139 | /// Returns `true` if the value changed, `false` otherwise. 140 | /// Used internally during propagation to commit pending changes. 141 | @pragma('vm:prefer-inline') 142 | @pragma('dart2js:tryInline') 143 | @pragma('wasm:prefer-inline') 144 | bool didUpdate() { 145 | flags = ReactiveFlags.mutable; 146 | return !identical(currentValue, currentValue = pendingValue); 147 | } 148 | } 149 | 150 | /// A reactive computed node that derives its value from other reactive nodes. 151 | /// 152 | /// ComputedNode automatically tracks its dependencies and recalculates 153 | /// its value when any dependency changes. The computation is lazy - 154 | /// it only runs when the value is accessed and dependencies have changed. 155 | /// 156 | /// Computed nodes cannot be directly written to; they always derive 157 | /// their value from the getter function. 158 | class ComputedNode extends ReactiveNode { 159 | /// The function that computes this node's value. 160 | /// 161 | /// Receives the previous value as a parameter, which can be 162 | /// useful for incremental computations. 163 | final T Function(T?) getter; 164 | 165 | /// The cached computed value. 166 | /// 167 | /// Null until first computation or after invalidation. 168 | T? currentValue; 169 | 170 | ComputedNode({required super.flags, required this.getter}); 171 | 172 | /// Gets the computed value, recalculating if necessary. 173 | /// 174 | /// Checks if the value is dirty or pending, and if so, 175 | /// re-runs the getter function. Automatically tracks 176 | /// dependencies accessed during computation. 177 | /// 178 | /// Returns the computed value. 179 | T get() { 180 | final flags = this.flags; 181 | if ((flags & ReactiveFlags.dirty) != ReactiveFlags.none || 182 | ((flags & ReactiveFlags.pending) != ReactiveFlags.none && 183 | (checkDirty(deps!, this) || 184 | identical(this.flags = flags & -33 /*~ReactiveFlags.pending*/, 185 | false)))) { 186 | if (didUpdate()) { 187 | final subs = this.subs; 188 | if (subs != null) { 189 | shallowPropagate(subs); 190 | } 191 | } 192 | } else if (flags == ReactiveFlags.none) { 193 | this.flags = 5 /*ReactiveFlags.mutable | ReactiveFlags.recursedCheck*/ 194 | as ReactiveFlags; 195 | final prevSub = setActiveSub(this); 196 | try { 197 | currentValue = getter(null); 198 | } finally { 199 | activeSub = prevSub; 200 | this.flags &= -5 /*~ReactiveFlags.recursedCheck*/; 201 | } 202 | } 203 | 204 | final sub = activeSub; 205 | if (sub != null) link(this, sub, cycle); 206 | 207 | return currentValue as T; 208 | } 209 | 210 | /// Updates the computed value by re-running the getter. 211 | /// 212 | /// Clears old dependencies, runs the getter with dependency 213 | /// tracking enabled, and returns whether the value changed. 214 | /// 215 | /// Returns `true` if the computed value changed, `false` otherwise. 216 | bool didUpdate() { 217 | ++cycle; 218 | depsTail = null; 219 | flags = ReactiveFlags.mutable | ReactiveFlags.recursedCheck; 220 | final prevSub = setActiveSub(this); 221 | try { 222 | return !identical(currentValue, currentValue = getter(currentValue)); 223 | } finally { 224 | activeSub = prevSub; 225 | flags &= -5 /*~ReactiveFlags.recursedCheck*/; 226 | purgeDeps(this); 227 | } 228 | } 229 | } 230 | 231 | /// A reactive effect node that runs side effects in response to changes. 232 | /// 233 | /// EffectNode extends [LinkedEffect] to add the capability to execute 234 | /// a function when its dependencies change. Effects are the bridge 235 | /// between the reactive system and the outside world, allowing 236 | /// side effects like DOM updates or logging. 237 | class EffectNode extends LinkedEffect { 238 | /// The side effect function to execute. 239 | /// 240 | /// This function is called whenever any of the effect's 241 | /// dependencies change. 242 | final void Function() fn; 243 | 244 | EffectNode({required super.flags, required this.fn}); 245 | } 246 | 247 | /// Default implementation of the reactive system for Alien Signals. 248 | /// 249 | /// PresetReactiveSystem provides the standard reactive behavior used by 250 | /// the alien_signals library. It implements the abstract [ReactiveSystem] 251 | /// methods to manage signal updates, effect scheduling, and dependency cleanup. 252 | /// 253 | /// This implementation features: 254 | /// - **Type-based dispatch**: Uses pattern matching to handle different node types 255 | /// - **Effect batching**: Queues effects for efficient batch execution 256 | /// - **Lazy cleanup**: Delays dependency cleanup for mutable nodes until needed 257 | /// - **Automatic propagation**: Handles change propagation through the reactive graph 258 | /// 259 | /// The system maintains global state including: 260 | /// - Effect queue ([queuedEffects]/[queuedEffectsTail]) 261 | /// - Batch depth tracking ([batchDepth]) 262 | /// - Active subscriber tracking ([activeSub]) 263 | /// - Version tracking ([cycle]) 264 | /// 265 | /// ## Internal Operation 266 | /// 267 | /// When a signal changes: 268 | /// 1. The change propagates through [propagate] to mark dependents 269 | /// 2. Effects are queued via [notify] for batch execution 270 | /// 3. When batch completes, [flush] executes all queued effects 271 | /// 4. Each effect's dependencies are tracked during execution 272 | /// 273 | /// ## Usage 274 | /// 275 | /// This class is used internally by the library and is instantiated as 276 | /// a singleton constant: 277 | /// 278 | /// ```dart 279 | /// const system = PresetReactiveSystem(); 280 | /// ``` 281 | /// 282 | /// Most users don't interact with this class directly - they use the 283 | /// high-level API functions like `signal()`, `computed()`, and `effect()` 284 | /// which internally use this system. 285 | class PresetReactiveSystem extends ReactiveSystem { 286 | const PresetReactiveSystem(); 287 | 288 | /// Updates a reactive node's value. 289 | /// 290 | /// Dispatches to the appropriate update method based on node type. 291 | /// For [ComputedNode] and [SignalNode], calls their update methods. 292 | /// For other node types, returns false (no update needed). 293 | /// 294 | /// Returns `true` if the node's value changed, `false` otherwise. 295 | @override 296 | @pragma('vm:prefer-inline') 297 | @pragma('dart2js:tryInline') 298 | @pragma('wasm:prefer-inline') 299 | bool update(ReactiveNode node) { 300 | return switch (node) { 301 | ComputedNode() => node.didUpdate(), 302 | SignalNode() => node.didUpdate(), 303 | _ => false, 304 | }; 305 | } 306 | 307 | /// Queues an effect for execution. 308 | /// 309 | /// Adds the effect and any watching parent effects to the 310 | /// execution queue. Effects are executed together when 311 | /// [flush] is called or when a batch completes. 312 | /// 313 | /// This batching mechanism prevents redundant computations 314 | /// and ensures effects run in a consistent order. 315 | @override 316 | @pragma('vm:align-loops') 317 | void notify(ReactiveNode effect) { 318 | LinkedEffect? head; 319 | final LinkedEffect tail = effect as LinkedEffect; 320 | 321 | do { 322 | effect.flags &= -3 /*~ReactiveFlags.watching*/; 323 | (effect as LinkedEffect).nextEffect = head; 324 | head = effect; 325 | 326 | final next = effect.subs?.sub; 327 | if (next == null || 328 | ((effect = next).flags & ReactiveFlags.watching) == 329 | ReactiveFlags.none) { 330 | break; 331 | } 332 | } while (true); 333 | 334 | if (queuedEffectsTail == null) { 335 | queuedEffects = queuedEffectsTail = head; 336 | } else { 337 | queuedEffectsTail!.nextEffect = head; 338 | queuedEffectsTail = tail; 339 | } 340 | } 341 | 342 | /// Called when a node no longer has any subscribers. 343 | /// 344 | /// For non-mutable nodes (like effects), stops them completely. 345 | /// For mutable nodes (like signals), marks them as dirty and 346 | /// clears their dependencies for lazy re-evaluation. 347 | @override 348 | void unwatched(ReactiveNode node) { 349 | if ((node.flags & ReactiveFlags.mutable) == ReactiveFlags.none) { 350 | stop(node); 351 | } else if (node.depsTail != null) { 352 | node.depsTail = null; 353 | node.flags = 354 | 17 /*ReactiveFlags.mutable | ReactiveFlags.dirty*/ as ReactiveFlags; 355 | purgeDeps(node); 356 | } 357 | } 358 | } 359 | 360 | /// Gets the currently active subscriber node. 361 | /// 362 | /// The active subscriber is the node currently being computed, 363 | /// which will be linked as a dependency to any signals accessed. 364 | /// 365 | /// Returns the active subscriber or `null` if none is active. 366 | @pragma('vm:prefer-inline') 367 | @pragma('dart2js:tryInline') 368 | @pragma('wasm:prefer-inline') 369 | ReactiveNode? getActiveSub() => activeSub; 370 | 371 | /// Sets the active subscriber node and returns the previous one. 372 | /// 373 | /// Used to establish a reactive context where dependencies 374 | /// are automatically tracked. The previous subscriber should 375 | /// be restored after the reactive computation completes. 376 | /// 377 | /// Returns the previous active subscriber. 378 | @pragma('vm:prefer-inline') 379 | @pragma('dart2js:tryInline') 380 | @pragma('wasm:prefer-inline') 381 | ReactiveNode? setActiveSub([ReactiveNode? sub]) { 382 | final prevSub = activeSub; 383 | activeSub = sub; 384 | return prevSub; 385 | } 386 | 387 | /// Gets the current batch depth. 388 | /// 389 | /// A depth greater than 0 indicates that updates are being 390 | /// batched and effects are deferred. 391 | /// 392 | /// Returns the current batch depth. 393 | @pragma('vm:prefer-inline') 394 | @pragma('dart2js:tryInline') 395 | @pragma('wasm:prefer-inline') 396 | int getBatchDepth() => batchDepth; 397 | 398 | /// Starts a new batch operation. 399 | /// 400 | /// While in a batch, effect execution is deferred to avoid 401 | /// redundant computations. Multiple nested batches are supported. 402 | /// Effects are executed when all batches complete. 403 | @pragma('vm:prefer-inline') 404 | @pragma('dart2js:tryInline') 405 | @pragma('wasm:prefer-inline') 406 | void startBatch() => ++batchDepth; 407 | 408 | /// Ends the current batch operation. 409 | /// 410 | /// Decrements the batch depth. If this was the last batch 411 | /// (depth reaches 0), immediately flushes all queued effects. 412 | @pragma('vm:prefer-inline') 413 | @pragma('dart2js:tryInline') 414 | @pragma('wasm:prefer-inline') 415 | void endBatch() { 416 | if ((--batchDepth) == 0) flush(); 417 | } 418 | 419 | /// Manually triggers reactive updates within a function. 420 | /// 421 | /// Creates a temporary reactive context and executes the given 422 | /// function. Any signals accessed during execution will be 423 | /// tracked, and their subscribers will be notified of changes. 424 | /// 425 | /// Useful for imperatively triggering updates in the reactive 426 | /// system without creating a permanent effect. 427 | /// 428 | /// Example: 429 | /// ```dart 430 | /// final count = signal(0); 431 | /// trigger(() { 432 | /// count(); // Access triggers propagation to subscribers 433 | /// }); 434 | /// ``` 435 | @pragma('vm:align-loops') 436 | void trigger(void Function() fn) { 437 | final sub = ReactiveNode(flags: ReactiveFlags.watching), 438 | prevSub = setActiveSub(sub); 439 | try { 440 | fn(); 441 | } finally { 442 | activeSub = prevSub; 443 | Link? link = sub.deps; 444 | while (link != null) { 445 | final dep = link.dep; 446 | link = unlink(link, sub); 447 | 448 | final subs = dep.subs; 449 | if (subs != null) { 450 | sub.flags = ReactiveFlags.none; 451 | propagate(subs); 452 | shallowPropagate(subs); 453 | } 454 | } 455 | if (batchDepth == 0) flush(); 456 | } 457 | } 458 | 459 | /// Executes an effect node if it needs updating. 460 | /// 461 | /// Checks if the effect is dirty or has pending updates, 462 | /// and if so, runs its function with dependency tracking. 463 | /// Otherwise, just marks it as watching. 464 | /// 465 | /// This is called internally when flushing queued effects. 466 | void run(EffectNode e) { 467 | final flags = e.flags; 468 | if ((flags & ReactiveFlags.dirty) != ReactiveFlags.none || 469 | ((flags & ReactiveFlags.pending) != ReactiveFlags.none && 470 | checkDirty(e.deps!, e))) { 471 | ++cycle; 472 | e.depsTail = null; 473 | e.flags = 6 /*ReactiveFlags.watching | ReactiveFlags.recursedCheck*/ 474 | as ReactiveFlags; 475 | final prevSub = setActiveSub(e); 476 | try { 477 | e.fn(); 478 | } finally { 479 | activeSub = prevSub; 480 | e.flags &= -5 /*~ReactiveFlags.recursedCheck*/; 481 | purgeDeps(e); 482 | } 483 | } else { 484 | e.flags = ReactiveFlags.watching; 485 | } 486 | } 487 | 488 | /// Flushes all queued effects, executing them in order. 489 | /// 490 | /// Processes the queue of effects that have been notified 491 | /// of changes, running each effect function and clearing 492 | /// the queue. This ensures all side effects are synchronized 493 | /// with the current reactive state. 494 | /// 495 | /// Called automatically when a batch completes or can be 496 | /// called manually to force immediate effect execution. 497 | @pragma('vm:align-loops') 498 | @pragma('vm:prefer-inline') 499 | @pragma('dart2js:tryInline') 500 | @pragma('wasm:prefer-inline') 501 | void flush() { 502 | while (queuedEffects != null) { 503 | final effect = queuedEffects as EffectNode; 504 | if ((queuedEffects = effect.nextEffect) != null) { 505 | effect.nextEffect = null; 506 | } else { 507 | queuedEffectsTail = null; 508 | } 509 | run(effect); 510 | } 511 | } 512 | 513 | /// Stops a reactive node and removes it from the reactive system. 514 | /// 515 | /// Clears all dependencies and subscribers of the node, 516 | /// effectively removing it from the dependency graph. 517 | /// After calling this, the node will no longer respond 518 | /// to or trigger reactive updates. 519 | /// 520 | /// This is essential for cleanup to prevent memory leaks. 521 | void stop(ReactiveNode node) { 522 | node.depsTail = null; 523 | node.flags = ReactiveFlags.none; 524 | purgeDeps(node); 525 | final subs = node.subs; 526 | if (subs != null) { 527 | unlink(subs, subs.sub); 528 | } 529 | } 530 | 531 | /// Removes all stale dependencies from a subscriber node. 532 | /// 533 | /// Called after a reactive computation completes to remove 534 | /// dependencies that were not accessed in the latest run. 535 | /// This keeps the dependency graph clean and prevents 536 | /// unnecessary updates from old dependencies. 537 | @pragma('vm:align-loops') 538 | @pragma('vm:prefer-inline') 539 | @pragma('dart2js:tryInline') 540 | @pragma('wasm:prefer-inline') 541 | void purgeDeps(ReactiveNode sub) { 542 | final depsTail = sub.depsTail; 543 | Link? dep = depsTail != null ? depsTail.nextDep : sub.deps; 544 | while (dep != null) { 545 | dep = unlink(dep, sub); 546 | } 547 | } 548 | -------------------------------------------------------------------------------- /lib/src/system.dart: -------------------------------------------------------------------------------- 1 | /// Bit flags that represent the state of reactive nodes in the system. 2 | /// 3 | /// These flags are used to track various states and behaviors of reactive 4 | /// nodes during the dependency tracking and update propagation process. 5 | /// Multiple flags can be combined using bitwise operations. 6 | /// 7 | /// The flags are designed to be efficient and allow for quick state checks 8 | /// using bitwise operations. 9 | extension type const ReactiveFlags._(int _) implements int { 10 | /// No flags set. The default state. 11 | static const none = 0 as ReactiveFlags; 12 | 13 | /// Indicates that this node is mutable (can be written to). 14 | /// Typically set for signals but not for computed values. 15 | static const mutable = 1 as ReactiveFlags; 16 | 17 | /// Indicates that this node is actively watching its dependencies. 18 | /// When set, the node will be notified of dependency changes. 19 | static const watching = 2 as ReactiveFlags; 20 | 21 | /// Used during recursion checking to detect circular dependencies. 22 | /// Temporarily set while checking for recursion. 23 | static const recursedCheck = 4 as ReactiveFlags; 24 | 25 | /// Indicates that this node has been visited during recursion detection. 26 | /// Helps prevent infinite loops in circular dependency scenarios. 27 | static const recursed = 8 as ReactiveFlags; 28 | 29 | /// Indicates that this node's value is outdated and needs recomputation. 30 | /// Set when dependencies change and cleared after successful update. 31 | static const dirty = 16 as ReactiveFlags; 32 | 33 | /// Indicates that this node has pending updates to process. 34 | /// Used during batch updates to mark nodes that need processing. 35 | static const pending = 32 as ReactiveFlags; 36 | 37 | @pragma('vm:prefer-inline') 38 | @pragma('dart2js:tryInline') 39 | @pragma('wasm:prefer-inline') 40 | ReactiveFlags operator |(int other) => _ | other as ReactiveFlags; 41 | 42 | @pragma('vm:prefer-inline') 43 | @pragma('dart2js:tryInline') 44 | @pragma('wasm:prefer-inline') 45 | ReactiveFlags operator &(int other) => _ & other as ReactiveFlags; 46 | 47 | @pragma('vm:prefer-inline') 48 | @pragma('dart2js:tryInline') 49 | @pragma('wasm:prefer-inline') 50 | ReactiveFlags operator ~() => ~_ as ReactiveFlags; 51 | } 52 | 53 | /// Base class for all reactive nodes in the dependency tracking system. 54 | /// 55 | /// A ReactiveNode represents any value that can participate in the reactive 56 | /// dependency graph. This includes signals, computed values, and effects. 57 | /// 58 | /// Each node maintains two sets of connections: 59 | /// - Dependencies (deps): Other nodes that this node depends on 60 | /// - Subscribers (subs): Other nodes that depend on this node 61 | /// 62 | /// The dependency tracking system uses doubly-linked lists to efficiently 63 | /// manage these relationships, allowing for O(1) insertion and removal. 64 | /// 65 | /// Example node types that extend ReactiveNode: 66 | /// - SignalNode: Holds a mutable value 67 | /// - ComputedNode: Derives its value from other nodes 68 | /// - EffectNode: Runs side effects when dependencies change 69 | class ReactiveNode { 70 | /// Bit flags representing the current state of this node. 71 | /// See [ReactiveFlags] for possible values. 72 | ReactiveFlags flags; 73 | 74 | /// Head of the linked list of dependencies (nodes this node depends on). 75 | /// Null if this node has no dependencies. 76 | Link? deps; 77 | 78 | /// Tail of the linked list of dependencies for O(1) append operations. 79 | /// Points to the last dependency link. 80 | Link? depsTail; 81 | 82 | /// Head of the linked list of subscribers (nodes that depend on this node). 83 | /// Null if no other nodes depend on this one. 84 | Link? subs; 85 | 86 | /// Tail of the linked list of subscribers for O(1) append operations. 87 | /// Points to the last subscriber link. 88 | Link? subsTail; 89 | 90 | ReactiveNode({ 91 | required this.flags, 92 | this.deps, 93 | this.depsTail, 94 | this.subs, 95 | this.subsTail, 96 | }); 97 | } 98 | 99 | /// Represents a dependency relationship between two reactive nodes. 100 | /// 101 | /// A Link connects a dependency (dep) to a subscriber (sub), forming an edge 102 | /// in the reactive dependency graph. Each link is part of two doubly-linked 103 | /// lists: 104 | /// - The dependency list of the subscriber node 105 | /// - The subscriber list of the dependency node 106 | /// 107 | /// This dual-list structure allows for efficient traversal and modification 108 | /// of the dependency graph from both directions. 109 | /// 110 | /// The version tracking ensures that stale dependencies are properly updated 111 | /// or removed during reactive computations. 112 | final class Link { 113 | /// Version number for tracking staleness of this dependency relationship. 114 | /// Used to determine if the link is still valid or needs updating. 115 | int version; 116 | 117 | /// The dependency node (the node being depended upon). 118 | /// This is the source of data that the subscriber reads from. 119 | ReactiveNode dep; 120 | 121 | /// The subscriber node (the node that depends on dep). 122 | /// This node will be notified when dep changes. 123 | ReactiveNode sub; 124 | 125 | /// Previous link in the subscriber list of the dependency node. 126 | /// Used for traversing all subscribers of a dependency. 127 | Link? prevSub; 128 | 129 | /// Next link in the subscriber list of the dependency node. 130 | /// Used for traversing all subscribers of a dependency. 131 | Link? nextSub; 132 | 133 | /// Previous link in the dependency list of the subscriber node. 134 | /// Used for traversing all dependencies of a subscriber. 135 | Link? prevDep; 136 | 137 | /// Next link in the dependency list of the subscriber node. 138 | /// Used for traversing all dependencies of a subscriber. 139 | Link? nextDep; 140 | 141 | Link({ 142 | required this.version, 143 | required this.dep, 144 | required this.sub, 145 | this.prevSub, 146 | this.nextSub, 147 | this.prevDep, 148 | this.nextDep, 149 | }); 150 | } 151 | 152 | final class Stack { 153 | T value; 154 | Stack? prev; 155 | 156 | Stack({required this.value, this.prev}); 157 | } 158 | 159 | /// Abstract base class for implementing a reactive system. 160 | /// 161 | /// The ReactiveSystem manages the core operations for maintaining a reactive 162 | /// dependency graph, including linking nodes, propagating changes, and 163 | /// checking for updates. 164 | /// 165 | /// This class provides the fundamental infrastructure for reactive state 166 | /// management, using a push-pull hybrid approach: 167 | /// - **Push phase**: Changes propagate through the graph to mark affected nodes 168 | /// - **Pull phase**: Values are lazily computed only when accessed 169 | /// 170 | /// ## Implementation Requirements 171 | /// 172 | /// Subclasses must implement three key methods: 173 | /// - [update]: Updates a node's value and returns whether it changed 174 | /// - [notify]: Schedules a node for processing (e.g., queuing an effect) 175 | /// - [unwatched]: Handles cleanup when a node loses all subscribers 176 | /// 177 | /// ## Provided Operations 178 | /// 179 | /// The class provides complete implementations of: 180 | /// - [link]: Establishes dependency relationships between nodes 181 | /// - [unlink]: Removes dependency relationships 182 | /// - [propagate]: Recursively propagates changes through the graph 183 | /// - [shallowPropagate]: Propagates changes to immediate subscribers only 184 | /// - [checkDirty]: Determines if a node needs updating 185 | /// - [isValidLink]: Validates link integrity 186 | /// 187 | /// ## Example Implementation 188 | /// 189 | /// ```dart 190 | /// class MyReactiveSystem extends ReactiveSystem { 191 | /// @override 192 | /// bool update(ReactiveNode node) { 193 | /// // Update node value, return true if changed 194 | /// return node.updateValue(); 195 | /// } 196 | /// 197 | /// @override 198 | /// void notify(ReactiveNode node) { 199 | /// // Queue node for processing 200 | /// queueEffect(node); 201 | /// } 202 | /// 203 | /// @override 204 | /// void unwatched(ReactiveNode node) { 205 | /// // Clean up node when no longer watched 206 | /// node.cleanup(); 207 | /// } 208 | /// } 209 | /// ``` 210 | abstract class ReactiveSystem { 211 | const ReactiveSystem(); 212 | 213 | /// Updates a reactive node's value. 214 | /// 215 | /// This method should recompute the node's value if necessary and 216 | /// return `true` if the value changed, `false` otherwise. 217 | /// 218 | /// Typically called during dependency checking and propagation to 219 | /// ensure nodes have current values. 220 | bool update(ReactiveNode node); 221 | 222 | /// Notifies that a reactive node needs processing. 223 | /// 224 | /// This method is called when a node (typically an effect) needs to 225 | /// be scheduled for execution. The implementation should queue the 226 | /// node for later processing or execute it immediately depending on 227 | /// the system's batching strategy. 228 | void notify(ReactiveNode node); 229 | 230 | /// Handles cleanup when a node loses all subscribers. 231 | /// 232 | /// Called when a dependency node no longer has any nodes depending on it. 233 | /// This is an opportunity to perform cleanup, stop computations, or 234 | /// release resources associated with the unwatched node. 235 | void unwatched(ReactiveNode node); 236 | 237 | /// Creates or updates a dependency link between two nodes. 238 | /// 239 | /// Establishes that [sub] depends on [dep], creating a bidirectional 240 | /// link in the dependency graph. The [version] parameter tracks the 241 | /// freshness of this dependency relationship. 242 | /// 243 | /// This method efficiently handles: 244 | /// - Deduplication: Avoids creating duplicate links 245 | /// - Version updates: Updates existing links with new versions 246 | /// - Reordering: Moves accessed dependencies to the tail for optimization 247 | /// 248 | /// The implementation maintains two doubly-linked lists: 249 | /// - deps/depsTail on the subscriber for its dependencies 250 | /// - subs/subsTail on the dependency for its subscribers 251 | void link(final ReactiveNode dep, final ReactiveNode sub, final int version) { 252 | final prevDep = sub.depsTail; 253 | if (prevDep != null && identical(prevDep.dep, dep)) { 254 | return; 255 | } 256 | final nextDep = prevDep != null ? prevDep.nextDep : sub.deps; 257 | if (nextDep != null && identical(nextDep.dep, dep)) { 258 | nextDep.version = version; 259 | sub.depsTail = nextDep; 260 | return; 261 | } 262 | final prevSub = dep.subsTail; 263 | if (prevSub != null && 264 | prevSub.version == version && 265 | identical(prevSub.sub, sub)) { 266 | return; 267 | } 268 | final newLink = sub.depsTail = dep.subsTail = Link( 269 | version: version, 270 | dep: dep, 271 | sub: sub, 272 | prevDep: prevDep, 273 | nextDep: nextDep, 274 | prevSub: prevSub, 275 | nextSub: null, 276 | ); 277 | if (nextDep != null) { 278 | nextDep.prevDep = newLink; 279 | } 280 | if (prevDep != null) { 281 | prevDep.nextDep = newLink; 282 | } else { 283 | sub.deps = newLink; 284 | } 285 | if (prevSub != null) { 286 | prevSub.nextSub = newLink; 287 | } else { 288 | dep.subs = newLink; 289 | } 290 | } 291 | 292 | /// Removes a dependency link from the graph. 293 | /// 294 | /// Disconnects the relationship represented by [link], removing it from 295 | /// both the dependency's subscriber list and the subscriber's dependency list. 296 | /// 297 | /// If the dependency node has no remaining subscribers after unlinking, 298 | /// [unwatched] is called to handle cleanup. 299 | /// 300 | /// Returns the next link in the subscriber's dependency list, or `null` 301 | /// if this was the last dependency. 302 | Link? unlink(final Link link, final ReactiveNode sub) { 303 | final dep = link.dep, 304 | prevDep = link.prevDep, 305 | nextDep = link.nextDep, 306 | nextSub = link.nextSub, 307 | prevSub = link.prevSub; 308 | if (nextDep != null) { 309 | nextDep.prevDep = prevDep; 310 | } else { 311 | sub.depsTail = prevDep; 312 | } 313 | if (prevDep != null) { 314 | prevDep.nextDep = nextDep; 315 | } else { 316 | sub.deps = nextDep; 317 | } 318 | if (nextSub != null) { 319 | nextSub.prevSub = prevSub; 320 | } else { 321 | dep.subsTail = prevSub; 322 | } 323 | if (prevSub != null) { 324 | prevSub.nextSub = nextSub; 325 | } else if ((dep.subs = nextSub) == null) { 326 | unwatched(dep); 327 | } 328 | return nextDep; 329 | } 330 | 331 | /// Propagates changes recursively through the dependency graph. 332 | /// 333 | /// Starting from [link], traverses the graph depth-first to mark all 334 | /// affected nodes as dirty or pending. Handles circular dependencies 335 | /// and recursion detection. 336 | /// 337 | /// This method: 338 | /// - Marks dependent nodes as pending or dirty 339 | /// - Notifies watching effects for scheduling 340 | /// - Recursively propagates through mutable nodes 341 | /// - Uses an explicit stack to avoid call stack overflow 342 | /// 343 | /// The propagation stops at non-mutable nodes (like effects) or when 344 | /// circular dependencies are detected. 345 | @pragma('vm:align-loops') 346 | void propagate(Link link) { 347 | Link? next = link.nextSub; 348 | Stack? stack; 349 | 350 | top: 351 | do { 352 | final sub = link.sub; 353 | ReactiveFlags flags = sub.flags; 354 | 355 | if ((flags & 356 | 60 /*ReactiveFlags.recursedCheck | ReactiveFlags.recursed | ReactiveFlags.dirty | ReactiveFlags.pending*/ 357 | ) == 358 | ReactiveFlags.none) { 359 | sub.flags = flags | ReactiveFlags.pending; 360 | } else if ((flags & 361 | 12 /*ReactiveFlags.recursedCheck | ReactiveFlags.recursed*/) == 362 | ReactiveFlags.none) { 363 | flags = ReactiveFlags.none; 364 | } else if ((flags & ReactiveFlags.recursedCheck) == ReactiveFlags.none) { 365 | sub.flags = 366 | (flags & -9 /*~ReactiveFlags.recursed*/) | ReactiveFlags.pending; 367 | } else if ((flags & 48 /*ReactiveFlags.dirty | ReactiveFlags.pending*/) == 368 | ReactiveFlags.none && 369 | isValidLink(link, sub)) { 370 | sub.flags = 371 | flags | 40 /*(ReactiveFlags.recursed | ReactiveFlags.pending)*/; 372 | flags &= ReactiveFlags.mutable; 373 | } else { 374 | flags = ReactiveFlags.none; 375 | } 376 | 377 | if ((flags & ReactiveFlags.watching) != ReactiveFlags.none) { 378 | notify(sub); 379 | } 380 | 381 | if ((flags & ReactiveFlags.mutable) != ReactiveFlags.none) { 382 | final subSubs = sub.subs; 383 | if (subSubs != null) { 384 | final nextSub = (link = subSubs).nextSub; 385 | if (nextSub != null) { 386 | stack = Stack(value: next, prev: stack); 387 | next = nextSub; 388 | } 389 | continue; 390 | } 391 | } 392 | 393 | if (next != null) { 394 | link = next; 395 | next = link.nextSub; 396 | continue; 397 | } 398 | 399 | while (stack != null) { 400 | final Stack(:value, :prev) = stack; 401 | stack = prev; 402 | if (value != null) { 403 | link = value; 404 | next = link.nextSub; 405 | continue top; 406 | } 407 | } 408 | 409 | break; 410 | } while (true); 411 | } 412 | 413 | /// Propagates changes to immediate subscribers only. 414 | /// 415 | /// Unlike [propagate], this method only marks direct subscribers as dirty 416 | /// without recursive traversal. Used when a node's value has been confirmed 417 | /// to change and all immediate dependents need notification. 418 | /// 419 | /// Typically called after successful updates to notify immediate subscribers 420 | /// that their dependency has changed. 421 | @pragma('vm:align-loops') 422 | void shallowPropagate(Link link) { 423 | Link? curr = link; 424 | do { 425 | final sub = curr!.sub, flags = sub.flags; 426 | if ((flags & 48 /*(ReactiveFlags.pending | ReactiveFlags.dirty)*/) == 427 | ReactiveFlags.pending) { 428 | sub.flags = flags | ReactiveFlags.dirty; 429 | if ((flags & 430 | 6 /*(ReactiveFlags.watching | ReactiveFlags.recursedCheck)*/) == 431 | ReactiveFlags.watching) { 432 | notify(sub); 433 | } 434 | } 435 | } while ((curr = curr.nextSub) != null); 436 | } 437 | 438 | /// Checks if a node is dirty by examining its dependencies. 439 | /// 440 | /// Traverses [sub]'s dependencies starting from [link] to determine if 441 | /// any have changed. If a dependency is dirty or pending, recursively 442 | /// checks its dependencies. 443 | /// 444 | /// This pull-based checking ensures values are only recomputed when 445 | /// actually needed, avoiding unnecessary calculations. 446 | /// 447 | /// Returns `true` if any dependency has changed, requiring [sub] to update, 448 | /// or `false` if all dependencies are clean. 449 | @pragma('vm:align-loops') 450 | bool checkDirty(Link link, ReactiveNode sub) { 451 | Stack? stack; 452 | int checkDepth = 0; 453 | bool dirty = false; 454 | 455 | top: 456 | do { 457 | final dep = link.dep, flags = dep.flags; 458 | 459 | if ((sub.flags & ReactiveFlags.dirty) != ReactiveFlags.none) { 460 | dirty = true; 461 | } else if ((flags & 462 | 17 /*(ReactiveFlags.mutable | ReactiveFlags.dirty)*/) == 463 | 17 /*(ReactiveFlags.mutable | ReactiveFlags.dirty)*/) { 464 | if (update(dep)) { 465 | final subs = dep.subs!; 466 | if (subs.nextSub != null) { 467 | shallowPropagate(subs); 468 | } 469 | dirty = true; 470 | } 471 | } else if ((flags & 472 | 33 /*(ReactiveFlags.mutable | ReactiveFlags.pending)*/) == 473 | 33 /*(ReactiveFlags.mutable | ReactiveFlags.pending)*/) { 474 | if (link.nextSub != null || link.prevSub != null) { 475 | stack = Stack(value: link, prev: stack); 476 | } 477 | link = dep.deps!; 478 | sub = dep; 479 | ++checkDepth; 480 | continue; 481 | } 482 | 483 | if (!dirty) { 484 | final nextDep = link.nextDep; 485 | if (nextDep != null) { 486 | link = nextDep; 487 | continue; 488 | } 489 | } 490 | 491 | while ((checkDepth--) > 0) { 492 | final firstSub = sub.subs!, hasMultipleSubs = firstSub.nextSub != null; 493 | 494 | if (hasMultipleSubs) { 495 | link = stack!.value; 496 | stack = stack.prev; 497 | } else { 498 | link = firstSub; 499 | } 500 | if (dirty) { 501 | if (update(sub)) { 502 | if (hasMultipleSubs) { 503 | shallowPropagate(firstSub); 504 | } 505 | sub = link.sub; 506 | continue; 507 | } 508 | dirty = false; 509 | } else { 510 | sub.flags &= -33 /*~ReactiveFlags.pending*/; 511 | } 512 | sub = link.sub; 513 | final nextDep = link.nextDep; 514 | if (nextDep != null) { 515 | link = nextDep; 516 | continue top; 517 | } 518 | } 519 | 520 | return dirty; 521 | } while (true); 522 | } 523 | 524 | /// Validates that a link belongs to a node's dependency list. 525 | /// 526 | /// Checks if [checkLink] is present in [sub]'s dependency chain. 527 | /// Used during propagation to ensure link integrity and detect 528 | /// stale references. 529 | /// 530 | /// Returns `true` if the link is valid, `false` otherwise. 531 | @pragma('vm:align-loops') 532 | bool isValidLink(final Link checkLink, final ReactiveNode sub) { 533 | Link? link = sub.depsTail; 534 | while (link != null) { 535 | if (identical(link, checkLink)) return true; 536 | link = link.prevDep; 537 | } 538 | return false; 539 | } 540 | } 541 | --------------------------------------------------------------------------------