Skip to content

Commit a067d1b

Browse files
committed
fix: don't notify signals tracking AsyncSignal.future when transitioning to loading state
1 parent 5ac7164 commit a067d1b

File tree

6 files changed

+656
-29
lines changed

6 files changed

+656
-29
lines changed

packages/signals_core/lib/src/async/computed.dart

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,21 @@ import 'future.dart';
1414
/// ```
1515
///
1616
/// Since all dependencies are passed in as arguments there is no need to worry about calling the signals before any async gaps with await.
17+
///
18+
/// If async signals need to be tracked across an async gap and they are being awaited via their `.future`,
19+
/// then use their `.completion` for the dependencies rather than the async signal itself.
20+
/// This way the `computedFrom` will be reset when the tracked signal completes, effectively ignoring their transition into a loading state.
21+
///
22+
/// ```dart
23+
/// final count = asyncSignal(AsyncData(0));
24+
///
25+
/// final s = computedFrom([count.completion], (_) async => await count.future);
26+
///
27+
/// await s.future; // 0
28+
/// count.value = AsyncLoading(); // ignored by the computedFrom
29+
/// count.value = AsyncData(1);
30+
/// await s.future; // 1
31+
/// ```
1732
FutureSignal<T> computedFrom<T, A>(
1833
List<ReadonlySignal<A>> signals,
1934
Future<T> Function(List<A> args) fn, {

packages/signals_core/lib/src/async/future.dart

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,24 @@ class FutureSignal<T> extends StreamSignal<T> {
170170
/// count.value = 1; // resets the future
171171
/// s.value; // state with count 1
172172
/// ```
173+
///
174+
/// If other async signals need to be tracked across an async gap and they are being awaited via their `.future`,
175+
/// then use their `.completion` for the dependencies rather than the async signal itself.
176+
/// This way the `futureSignal` will be reset when the tracked signal completes, effectively ignoring their transition into a loading state.
177+
///
178+
/// ```dart
179+
/// final count = asyncSignal(AsyncData(0));
180+
///
181+
/// final s = futureSignal(
182+
/// () async => await count.future,
183+
/// dependencies: [count.completion],
184+
/// );
185+
///
186+
/// await s.future; // 0
187+
/// count.value = AsyncLoading(); // ignored by the future signal
188+
/// count.value = AsyncData(1);
189+
/// await s.future; // 1
190+
/// ```
173191
/// @link https://dartsignals.dev/async/future
174192
/// {@endtemplate}
175193
FutureSignal(

packages/signals_core/lib/src/async/signal.dart

Lines changed: 110 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import 'dart:async';
22

3-
import 'package:meta/meta.dart';
4-
53
import '../core/signals.dart';
64
import '../mixins/event_sink.dart';
75
import 'state.dart';
@@ -173,60 +171,77 @@ class AsyncSignal<T> extends Signal<AsyncState<T>>
173171
super.value, {
174172
super.debugLabel,
175173
super.autoDispose,
176-
}) : _initialValue = value;
174+
}) : _initialValue = value,
175+
_completion = _AsyncCompletionSignal<T>(
176+
initialValue: value,
177+
debugLabel: debugLabel != null ? '${debugLabel}_completion' : null,
178+
);
179+
180+
@override
181+
void dispose() {
182+
_completion.dispose();
183+
super.dispose();
184+
}
177185

178186
final AsyncState<T> _initialValue;
179187
bool _initialized = false;
180188

181-
/// Internal Completer for values
182-
@internal
183-
Completer<bool> completer = Completer<bool>();
189+
final _AsyncCompletionSignal<T> _completion;
190+
191+
/// Tracks the async completion of this signal.
192+
///
193+
/// Notifies if the value of this signal changes to [AsyncData] or [AsyncError],
194+
/// but not if it changes to [AsyncLoading].
195+
///
196+
/// Intended to be used for tracking dependencies across async gaps,
197+
/// when awaiting the [future] of this signal.
198+
///
199+
/// ```dart
200+
/// computedFrom([someAsyncSignal.completion], (_) async {
201+
/// await Future.delayed(const Duration(seconds: 1));
202+
/// return await someAsyncSignal.future;
203+
/// });
204+
/// ```
205+
ReadonlySignal<Future<T>> get completion => _completion;
184206

185207
/// The future of the signal completer
186-
Future<T> get future async {
187-
value;
188-
await completer.future;
189-
return value.requireValue;
190-
}
208+
Future<T> get future => completion.value;
191209

192210
/// Returns true if the signal is completed an error or data
193211
bool get isCompleted {
194-
value;
195-
return completer.isCompleted;
212+
future;
213+
return _completion.isCompleted;
214+
}
215+
216+
@override
217+
bool set(AsyncState<T> val, {bool force = false}) {
218+
return batch(() {
219+
_completion.setValue(val);
220+
return super.set(val, force: force);
221+
});
196222
}
197223

198224
/// Set the error with optional stackTrace to [AsyncError]
199225
void setError(Object error, [StackTrace? stackTrace]) {
200-
batch(() {
201-
value = AsyncState.error(error, stackTrace);
202-
if (completer.isCompleted) completer = Completer<bool>();
203-
completer.complete(true);
204-
});
226+
set(AsyncState.error(error, stackTrace));
205227
}
206228

207229
/// Set the value to [AsyncData]
208230
void setValue(T value) {
209-
batch(() {
210-
this.value = AsyncState.data(value);
211-
if (completer.isCompleted) completer = Completer<bool>();
212-
completer.complete(true);
213-
});
231+
set(AsyncState.data(value));
214232
}
215233

216234
/// Set the loading state to [AsyncLoading]
217235
void setLoading([AsyncState<T>? state]) {
218-
batch(() {
219-
value = state ?? AsyncState.loading();
220-
completer = Completer<bool>();
221-
});
236+
set(state ?? AsyncState.loading());
222237
}
223238

224239
/// Reset the signal to the initial value
225240
void reset([AsyncState<T>? value]) {
226241
batch(() {
227-
this.value = value ?? _initialValue;
242+
super.value = value ?? _initialValue;
228243
_initialized = false;
229-
if (completer.isCompleted) completer = Completer<bool>();
244+
_completion.setValue(const AsyncLoading());
230245
});
231246
}
232247

@@ -435,3 +450,69 @@ AsyncSignal<T> asyncSignal<T>(
435450
autoDispose: autoDispose,
436451
);
437452
}
453+
454+
class _AsyncCompletionSignal<T> extends Signal<Future<T>> {
455+
_AsyncCompletionSignal._(
456+
Completer<T> completer, {
457+
required super.debugLabel,
458+
}) : _completer = completer,
459+
super(_ignoreUncaughtErrors(completer.future));
460+
461+
factory _AsyncCompletionSignal({
462+
required AsyncState<T> initialValue,
463+
required String? debugLabel,
464+
}) {
465+
final completer = Completer<T>();
466+
467+
switch (initialValue) {
468+
case AsyncLoading():
469+
break;
470+
471+
case AsyncData(:final value):
472+
completer.complete(value);
473+
break;
474+
475+
case AsyncError(:final error, :final stackTrace):
476+
completer.completeError(error, stackTrace);
477+
break;
478+
}
479+
480+
return _AsyncCompletionSignal._(completer, debugLabel: debugLabel);
481+
}
482+
483+
Completer<T> _completer;
484+
485+
bool _initialized = false;
486+
bool get isCompleted => _initialized && _completer.isCompleted;
487+
488+
void setValue(AsyncState<T> value) {
489+
if (_completer.isCompleted) {
490+
_completer = Completer<T>();
491+
this.value = _ignoreUncaughtErrors(_completer.future);
492+
}
493+
494+
switch (value) {
495+
case AsyncLoading():
496+
return;
497+
498+
case AsyncData(:final value):
499+
_initialized = true;
500+
_completer.complete(value);
501+
return;
502+
503+
case AsyncError(:final error, :final stackTrace):
504+
_initialized = true;
505+
_completer.completeError(error, stackTrace);
506+
return;
507+
}
508+
}
509+
510+
static Future<T> _ignoreUncaughtErrors<T>(Future<T> future) {
511+
// Makes sure the future never reports an uncaught error to
512+
// the current zone. Seems to be necessary to avoid uncaught
513+
// errors to be reported when async signals are being used
514+
// synchronously, i.e. when their .future is not used.
515+
future.ignore();
516+
return future;
517+
}
518+
}

packages/signals_core/lib/src/async/stream.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,17 @@ class StreamSignal<T> extends AsyncSignal<T> {
356356
return super.value;
357357
}
358358

359+
@override
360+
Future<T> get future {
361+
untracked(() {
362+
// make sure the stream is exectuted,
363+
// so the returned future will be completed eventually
364+
value;
365+
});
366+
367+
return super.future;
368+
}
369+
359370
@override
360371
void setError(Object error, [StackTrace? stackTrace]) {
361372
super.setError(error, stackTrace);

packages/signals_core/test/async/computed_test.dart

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,29 @@ void main() {
2727
expect(result, 10);
2828
});
2929

30+
test('computedAsync with .future dependencies', () async {
31+
var calls = 0;
32+
final count = asyncSignal(AsyncData(0));
33+
34+
final s = computedAsync(() async {
35+
calls++;
36+
final future = count.future;
37+
await Future.delayed(const Duration(milliseconds: 5));
38+
return await future;
39+
});
40+
41+
await expectLater(s.future, completion(0));
42+
expect(calls, 1);
43+
44+
count.value = AsyncLoading();
45+
final loadingExpectation = expectLater(s.future, completion(1));
46+
47+
count.value = AsyncData(1);
48+
await expectLater(s.future, completion(1));
49+
expect(calls, 2);
50+
await loadingExpectation;
51+
});
52+
3053
test('computedFrom', () async {
3154
Future<int> future(List<int> ids) async {
3255
await Future.delayed(const Duration(milliseconds: 5));
@@ -49,6 +72,28 @@ void main() {
4972
expect(result, 10);
5073
});
5174

75+
test('computedFrom with .completion dependencies', () async {
76+
var calls = 0;
77+
final count = asyncSignal(AsyncData(0));
78+
79+
final s = computedFrom([count.completion], (_) async {
80+
calls++;
81+
await Future.delayed(const Duration(milliseconds: 5));
82+
return await count.future;
83+
});
84+
85+
await expectLater(s.future, completion(0));
86+
expect(calls, 1);
87+
88+
count.value = AsyncLoading();
89+
final loadingExpectation = expectLater(s.future, completion(1));
90+
91+
count.value = AsyncData(1);
92+
await expectLater(s.future, completion(1));
93+
expect(calls, 2);
94+
await loadingExpectation;
95+
});
96+
5297
test('check repeated calls', () async {
5398
int calls = 0;
5499

0 commit comments

Comments
 (0)