diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bf67eb..3d82d6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 4.1.0 + +- **Changed** — `DisposableBag.dispose` invokes disposers in registration order but awaits `Future`-returning ones together, so disposal takes as long as the slowest disposer instead of the sum, a stalled async disposer no longer starves the ones after it, and synchronous disposers queued behind an async one now run inside `dispose()` rather than after it settles. Steps that must wait for a previous async step (flush, then close) belong in one closure. `DisposableBagException.errors` stays in registration order. + ## 4.0.0 Strips every API whose job the framework or core libraries already do — fluiver keeps only genuine gaps. diff --git a/lib/src/helpers/disposable_bag.dart b/lib/src/helpers/disposable_bag.dart index e7ea03e..c10110e 100644 --- a/lib/src/helpers/disposable_bag.dart +++ b/lib/src/helpers/disposable_bag.dart @@ -15,9 +15,15 @@ part of '../../fluiver.dart'; /// bag.dispose(); /// ``` /// -/// Closures run in registration order. [dispose] is idempotent — calling it -/// twice runs the closures once. Adding after dispose runs the closure -/// immediately and does not retain it. +/// Closures are invoked in registration order. A synchronous disposer +/// completes before the next one starts; disposers that return a [Future] +/// are started in order but awaited together, so [dispose] settles when the +/// slowest one does and a stalled disposer never starves the ones after it. +/// A step that must wait for a previous async step (flush, then close) +/// belongs in one closure: `() async { await flush(); await close(); }`. +/// +/// [dispose] is idempotent — calling it twice runs the closures once. Adding +/// after dispose runs the closure immediately and does not retain it. class DisposableBag { final List Function()> _disposers = []; bool _disposed = false; @@ -53,25 +59,31 @@ class DisposableBag { disposers.forEach(add); } - /// Runs every registered disposer in order, then clears the bag. - /// Safe to call multiple times. + /// Invokes every registered disposer in order, awaits the ones that return + /// a [Future] together, then clears the bag. Throws [DisposableBagException] + /// listing every error in registration order. Safe to call multiple times. Future dispose() async { if (_disposed) { return; } _disposed = true; - final errors = []; - for (final disposer in _disposers) { + final slots = List.filled(_disposers.length, null); + final futures = >[]; + for (var i = 0; i < _disposers.length; i++) { try { - final result = disposer(); + final result = _disposers[i](); if (result is Future) { - await result; + futures.add(result.then((_) {}, onError: (Object e) => slots[i] = e)); } } on Object catch (e) { - errors.add(e); + slots[i] = e; } } _disposers.clear(); + if (futures.isNotEmpty) { + await Future.wait(futures); + } + final errors = slots.nonNulls.toList(); if (errors.isNotEmpty) { throw DisposableBagException(errors); } diff --git a/test/disposable_bag_test.dart b/test/disposable_bag_test.dart index 981768b..ef71ea0 100644 --- a/test/disposable_bag_test.dart +++ b/test/disposable_bag_test.dart @@ -107,6 +107,65 @@ void main() { await check(bag.dispose()).throws(); check(calls).deepEquals(['a', 'c']); }); + + test('starts async disposers in order without awaiting each', () async { + final order = []; + final a = Completer(); + final b = Completer(); + final bag = DisposableBag() + ..add(() async { + order.add('start-a'); + await a.future; + order.add('end-a'); + }) + ..add(() async { + order.add('start-b'); + await b.future; + order.add('end-b'); + }); + final done = bag.dispose(); + check(order).deepEquals(['start-a', 'start-b']); + b.complete(); + a.complete(); + await done; + check(order).deepEquals(['start-a', 'start-b', 'end-b', 'end-a']); + }); + + test('collects every async disposer error', () async { + final bag = DisposableBag() + ..add(() async => throw Exception('async error 1')) + ..add(() async => throw Exception('async error 2')); + await check(bag.dispose()).throws( + (e) => e.has((e) => e.errors, 'errors').length.equals(2), + ); + }); + + test('collects sync and async errors in registration order', () async { + final a = Completer(); + final b = Completer(); + final bag = DisposableBag() + ..add(() async { + await a.future; + throw Exception('first'); + }) + ..add(() async { + await b.future; + throw Exception('second'); + }) + ..add(() => throw Exception('third')); + final done = bag.dispose(); + b.complete(); + a.complete(); + await check(done).throws( + (e) => e + .has((e) => e.errors.map((o) => o.toString()).toList(), 'errors') + .deepEquals([ + 'Exception: first', + 'Exception: second', + 'Exception: third', + ]), + ); + }); }); group('DisposableBagException', () {