Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/signals_core/lib/src/async/computed.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ import 'future.dart';
/// ```
///
/// Since all dependencies are passed in as arguments there is no need to worry about calling the signals before any async gaps with await.
///
/// If async signals need to be tracked across an async gap and they are being awaited via their `.future`,
/// then use their `.completion` for the dependencies rather than the async signal itself.
/// This way the `computedFrom` will be reset when the tracked signal completes, effectively ignoring their transition into a loading state.
///
/// ```dart
/// final count = asyncSignal(AsyncData(0));
///
/// final s = computedFrom([count.completion], (_) async => await count.future);
///
/// await s.future; // 0
/// count.value = AsyncLoading(); // ignored by the computedFrom
/// count.value = AsyncData(1);
/// await s.future; // 1
/// ```
FutureSignal<T> computedFrom<T, A>(
List<ReadonlySignal<A>> signals,
Future<T> Function(List<A> args) fn, {
Expand Down
18 changes: 18 additions & 0 deletions packages/signals_core/lib/src/async/future.dart
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,24 @@ class FutureSignal<T> extends StreamSignal<T> {
/// count.value = 1; // resets the future
/// s.value; // state with count 1
/// ```
///
/// If other async signals need to be tracked across an async gap and they are being awaited via their `.future`,
/// then use their `.completion` for the dependencies rather than the async signal itself.
/// This way the `futureSignal` will be reset when the tracked signal completes, effectively ignoring their transition into a loading state.
///
/// ```dart
/// final count = asyncSignal(AsyncData(0));
///
/// final s = futureSignal(
/// () async => await count.future,
/// dependencies: [count.completion],
/// );
///
/// await s.future; // 0
/// count.value = AsyncLoading(); // ignored by the future signal
/// count.value = AsyncData(1);
/// await s.future; // 1
/// ```
/// @link https://dartsignals.dev/async/future
/// {@endtemplate}
FutureSignal(
Expand Down
139 changes: 110 additions & 29 deletions packages/signals_core/lib/src/async/signal.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import 'dart:async';

import 'package:meta/meta.dart';

import '../core/signals.dart';
import '../mixins/event_sink.dart';
import 'state.dart';
Expand Down Expand Up @@ -173,60 +171,77 @@ class AsyncSignal<T> extends Signal<AsyncState<T>>
super.value, {
super.debugLabel,
super.autoDispose,
}) : _initialValue = value;
}) : _initialValue = value,
_completion = _AsyncCompletionSignal<T>(
initialValue: value,
debugLabel: debugLabel != null ? '${debugLabel}_completion' : null,
);

@override
void dispose() {
_completion.dispose();
super.dispose();
}

final AsyncState<T> _initialValue;
bool _initialized = false;

/// Internal Completer for values
@internal
Completer<bool> completer = Completer<bool>();
final _AsyncCompletionSignal<T> _completion;

/// Tracks the async completion of this signal.
///
/// Notifies if the value of this signal changes to [AsyncData] or [AsyncError],
/// but not if it changes to [AsyncLoading].
///
/// Intended to be used for tracking dependencies across async gaps,
/// when awaiting the [future] of this signal.
///
/// ```dart
/// computedFrom([someAsyncSignal.completion], (_) async {
/// await Future.delayed(const Duration(seconds: 1));
/// return await someAsyncSignal.future;
/// });
/// ```
ReadonlySignal<Future<T>> get completion => _completion;

/// The future of the signal completer
Future<T> get future async {
value;
await completer.future;
return value.requireValue;
}
Future<T> get future => completion.value;

/// Returns true if the signal is completed an error or data
bool get isCompleted {
value;
return completer.isCompleted;
future;
return _completion.isCompleted;
}

@override
bool set(AsyncState<T> val, {bool force = false}) {
return batch(() {
_completion.setValue(val);
return super.set(val, force: force);
});
}

/// Set the error with optional stackTrace to [AsyncError]
void setError(Object error, [StackTrace? stackTrace]) {
batch(() {
value = AsyncState.error(error, stackTrace);
if (completer.isCompleted) completer = Completer<bool>();
completer.complete(true);
});
set(AsyncState.error(error, stackTrace));
}

/// Set the value to [AsyncData]
void setValue(T value) {
batch(() {
this.value = AsyncState.data(value);
if (completer.isCompleted) completer = Completer<bool>();
completer.complete(true);
});
set(AsyncState.data(value));
}

/// Set the loading state to [AsyncLoading]
void setLoading([AsyncState<T>? state]) {
batch(() {
value = state ?? AsyncState.loading();
completer = Completer<bool>();
});
set(state ?? AsyncState.loading());
}

/// Reset the signal to the initial value
void reset([AsyncState<T>? value]) {
batch(() {
this.value = value ?? _initialValue;
super.value = value ?? _initialValue;
_initialized = false;
if (completer.isCompleted) completer = Completer<bool>();
_completion.setValue(const AsyncLoading());
});
}

Expand Down Expand Up @@ -435,3 +450,69 @@ AsyncSignal<T> asyncSignal<T>(
autoDispose: autoDispose,
);
}

class _AsyncCompletionSignal<T> extends Signal<Future<T>> {
_AsyncCompletionSignal._(
Completer<T> completer, {
required super.debugLabel,
}) : _completer = completer,
super(_ignoreUncaughtErrors(completer.future));

factory _AsyncCompletionSignal({
required AsyncState<T> initialValue,
required String? debugLabel,
}) {
final completer = Completer<T>();

switch (initialValue) {
case AsyncLoading():
break;

case AsyncData(:final value):
completer.complete(value);
break;

case AsyncError(:final error, :final stackTrace):
completer.completeError(error, stackTrace);
break;
}

return _AsyncCompletionSignal._(completer, debugLabel: debugLabel);
}

Completer<T> _completer;

bool _initialized = false;
bool get isCompleted => _initialized && _completer.isCompleted;
Comment on lines +485 to +486
Copy link
Contributor Author

@Yegair Yegair Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _initialized flag is needed to make an existing test for simple AsyncSignals work, in case they are created in a completed state.

test('isCompleted', () async {
final s = asyncSignal(AsyncState<int>.data(0));
expect(s.isCompleted, false);
s.setValue(1);
expect(s.isCompleted, true);
});

Didn't manage to figure out why an async signal is considered not completed after creating with data, so added this edge case in order to not break things.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep that sounds good!


void setValue(AsyncState<T> value) {
if (_completer.isCompleted) {
_completer = Completer<T>();
this.value = _ignoreUncaughtErrors(_completer.future);
}

switch (value) {
case AsyncLoading():
return;

case AsyncData(:final value):
_initialized = true;
_completer.complete(value);
return;

case AsyncError(:final error, :final stackTrace):
_initialized = true;
_completer.completeError(error, stackTrace);
return;
}
}

static Future<T> _ignoreUncaughtErrors<T>(Future<T> future) {
// Makes sure the future never reports an uncaught error to
// the current zone. Seems to be necessary to avoid uncaught
// errors to be reported when async signals are being used
// synchronously, i.e. when their .future is not used.
future.ignore();
return future;
}
Comment on lines +510 to +517
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here I'm really not sure if this is a good idea, but IMHO we have no other choice. Otherwise users might see a lot of uncaught errors even when they are handled synchronously through AsyncError

}
11 changes: 11 additions & 0 deletions packages/signals_core/lib/src/async/stream.dart
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,17 @@ class StreamSignal<T> extends AsyncSignal<T> {
return super.value;
}

@override
Future<T> get future {
untracked(() {
// make sure the stream is exectuted,
// so the returned future will be completed eventually
value;
});

return super.future;
}

@override
void setError(Object error, [StackTrace? stackTrace]) {
super.setError(error, stackTrace);
Expand Down
45 changes: 45 additions & 0 deletions packages/signals_core/test/async/computed_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,29 @@ void main() {
expect(result, 10);
});

test('computedAsync with .future dependencies', () async {
var calls = 0;
final count = asyncSignal(AsyncData(0));

final s = computedAsync(() async {
calls++;
final future = count.future;
await Future.delayed(const Duration(milliseconds: 5));
return await future;
});

await expectLater(s.future, completion(0));
expect(calls, 1);

count.value = AsyncLoading();
final loadingExpectation = expectLater(s.future, completion(1));

count.value = AsyncData(1);
await expectLater(s.future, completion(1));
expect(calls, 2);
await loadingExpectation;
});

test('computedFrom', () async {
Future<int> future(List<int> ids) async {
await Future.delayed(const Duration(milliseconds: 5));
Expand All @@ -49,6 +72,28 @@ void main() {
expect(result, 10);
});

test('computedFrom with .completion dependencies', () async {
var calls = 0;
final count = asyncSignal(AsyncData(0));

final s = computedFrom([count.completion], (_) async {
calls++;
await Future.delayed(const Duration(milliseconds: 5));
return await count.future;
});

await expectLater(s.future, completion(0));
expect(calls, 1);

count.value = AsyncLoading();
final loadingExpectation = expectLater(s.future, completion(1));

count.value = AsyncData(1);
await expectLater(s.future, completion(1));
expect(calls, 2);
await loadingExpectation;
});

test('check repeated calls', () async {
int calls = 0;

Expand Down
Loading