From 1b3874173879e0a554cdc512c0d10aee87ba6110 Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Sat, 24 Oct 2026 15:39:48 +0200 Subject: [PATCH 1/2] Remove the deprecated stream extension AdvancedFieldController.stream was the migration bridge for 0.1.x cubit streams, deprecated in 0.2.0 with removal announced for 0.3.0. This is 0.3.0. The migration guide keeps a recipe for the one case that needs a stream: wrap addListener/removeListener in a broadcast StreamController and keep the stream in a field. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + MIGRATION.md | 21 ++++-- docs/faq.mdx | 6 +- docs/migration.mdx | 2 +- lib/advanced_forms.dart | 1 - .../advanced_field_controller_extensions.dart | 42 ----------- .../field_controller_extensions_test.dart | 69 ------------------- 7 files changed, 22 insertions(+), 120 deletions(-) delete mode 100644 lib/src/field/advanced_field_controller_extensions.dart delete mode 100644 test/src/field/field_controller_extensions_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 53a27bf9..c386961c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ * New built-in mode `ValidationMode.afterFirstUnfocus`: quiet while the user types, validates when the field is first left, and from then on validates every edit, so an error disappears the moment the value is fixed. * The mode pickers in the docs demos are pills that wrap, so every mode name is readable on a phone. * Breaking: `ValidationMode` is no longer an enum. The constants `ValidationMode.manual` and the rest still work; `ValidationMode.values` and `.name` are gone, and a `switch` over the modes matches on the classes behind the constants (`ManualValidationMode()` and so on) with `CustomValidationMode()` as the last case. +* Removed the deprecated `AdvancedFieldController.stream` extension, as announced; listen with `addListener`, `subscribeToFields` or the builder widgets, and wrap a listener in a `StreamController` where a pipeline needs a stream. ## 0.2.4 diff --git a/MIGRATION.md b/MIGRATION.md index a47d688e..5e1a0cf1 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -49,7 +49,7 @@ | `form.onValuesChangedStream` (`Stream`) | `form.onValuesChanged` (`Listenable`) | | `form.onStatusChangedStream` (`Stream`) | `form.onStatusChanged` (`Listenable`, no payload) | | `BlocBuilder` | `ValueListenableBuilder` — there is no `AdvancedFormBuilder` | -| `FieldCubit.stream` (inherited from `Cubit`) | `AdvancedFieldController.stream` — deprecated extension, removed in 0.3.0 ([section 9](#9-migrating-a-custom-field)) | +| `FieldCubit.stream` (inherited from `Cubit`) | none; a listener, or a stream you wrap around one ([section 9](#9-migrating-a-custom-field)) | | `Future close()` | `void dispose()` | | `addDisposable(...)`, the `Disposable` mixin | **Removed** — override `dispose()` ([section 7](#7-lifecycle-close--dispose)) | @@ -470,15 +470,28 @@ addRelation( Outside a form, a raw listener works the same way with the comparison written by hand and a `removeListener` in `dispose()`. -**Code that composes streams** — rxdart operators, `await for`, merging fields into a pipeline. The deprecated `stream` extension bridges a field to a broadcast stream so helpers written against `FieldCubit.stream` keep compiling: +**Code that composes streams** — rxdart operators, `await for`, merging fields into a pipeline. A field has no stream (the deprecated `stream` bridge was removed in 0.3.0). Where a pipeline needs one, wrap the listener yourself and keep the stream in a field, so it is created once: ```dart -final subscription = quantityField.stream +Stream> statesOf( + AdvancedFieldController field, +) { + late final StreamController> controller; + void listener() => controller.add(field.value); + controller = StreamController.broadcast( + onListen: () => field.addListener(listener), + onCancel: () => field.removeListener(listener), + ); + return controller.stream; +} + +final quantities = statesOf(quantityField); +final subscription = quantities .map((state) => state.value.amount) .listen(recalculateTotals); ``` -Three caveats: every read allocates a new `StreamController`, so store it rather than re-reading the getter; the controller is never closed, so unlike `Cubit.stream` it does not terminate an `await for` loop; and it goes away in 0.3.0. +Unlike `Cubit.stream`, this controller is never closed, so it does not terminate an `await for` loop; cancel the subscription in `dispose()`. --- diff --git a/docs/faq.mdx b/docs/faq.mdx index ea24b3da..9fef492f 100644 --- a/docs/faq.mdx +++ b/docs/faq.mdx @@ -175,9 +175,9 @@ needs a fresh controller. Consider `setValidationEnabled(false)` instead of deta 0.1.x controllers were cubits, so they had a `stream`. 0.2 is `Listenable`-based: `addListener`, -`subscribeToFields`, `onValuesChanged`, `onStatusChanged`, and the builder widgets. A deprecated -`AdvancedFieldController.stream` extension exists as a migration bridge and is removed in 0.3.0. See -[Migration](./migration.mdx). +`subscribeToFields`, `onValuesChanged`, `onStatusChanged`, and the builder widgets. The deprecated +`AdvancedFieldController.stream` bridge was removed in 0.3.0; a pipeline that needs a stream wraps a listener in a +`StreamController` (the recipe is in the repo's `MIGRATION.md`, section 9). See [Migration](./migration.mdx). diff --git a/docs/migration.mdx b/docs/migration.mdx index 34c2fcbd..4736978e 100644 --- a/docs/migration.mdx +++ b/docs/migration.mdx @@ -93,7 +93,7 @@ until the user edits it; where it must, call `validate()` on it from a listener | `removeSubform(form, close: …)` | `removeSubform(form)` — detaches only, returns `void` | | `Future close()`, `addDisposable`, `Disposable` | `void dispose()` | | `BlocBuilder` | `ValueListenableBuilder` | -| `FieldCubit.stream` | `AdvancedFieldController.stream` — deprecated bridge, removed in 0.3.0 | +| `FieldCubit.stream` | none; a listener, or a stream you wrap around one (see the repo's `MIGRATION.md`, section 9) | ## Behaviour changes that are not renames diff --git a/lib/advanced_forms.dart b/lib/advanced_forms.dart index b502844d..09998bba 100644 --- a/lib/advanced_forms.dart +++ b/lib/advanced_forms.dart @@ -6,7 +6,6 @@ library; export 'src/field/advanced_boolean_field_controller.dart'; export 'src/field/advanced_field_controller.dart'; -export 'src/field/advanced_field_controller_extensions.dart'; export 'src/field/advanced_field_state.dart'; export 'src/field/advanced_multi_select_field_controller.dart'; export 'src/field/advanced_single_select_field_controller.dart'; diff --git a/lib/src/field/advanced_field_controller_extensions.dart b/lib/src/field/advanced_field_controller_extensions.dart deleted file mode 100644 index d6b03d23..00000000 --- a/lib/src/field/advanced_field_controller_extensions.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'dart:async'; - -import 'package:advanced_forms/src/field/advanced_field_controller.dart'; -import 'package:advanced_forms/src/field/advanced_field_state.dart'; - -/// Deprecated migration helper for [AdvancedFieldController]. -/// -/// This is **not** part of the listening model of this library — that model is -/// `Listenable`: `addListener`/`removeListener`, -/// [AdvancedFieldController.subscribeToFields], and builder widgets. It exists -/// only so that codebases migrating from 0.1.x, which built listening patterns -/// on cubit streams, do not have to rewrite them in the same pass. -extension AdvancedFieldControllerListen - on AdvancedFieldController { - /// Bridges this controller to a broadcast [Stream] of states. - /// - /// In 0.1.x, `FieldCubit` extended `Cubit`, so `stream` was inherited public - /// API — both this library and downstream code composed fields through it. - /// This getter replaces it, so stream-based helpers written against - /// `FieldCubit.stream` keep compiling until they can be rewritten. - /// - /// Note: every call to this getter allocates a new [StreamController] — - /// store the stream in a variable instead of reading the getter repeatedly. - /// Listeners are attached lazily on first subscription and detached when - /// the last subscription is cancelled. - @Deprecated( - 'Migration bridge for 0.1.x cubit streams, to be removed in 0.3.0. ' - 'Use addListener, subscribeToFields, or the builder widgets instead.', - ) - Stream> get stream { - StreamController>? controller; - - void listener() => controller?.add(value); - - controller = StreamController.broadcast( - onListen: () => addListener(listener), - onCancel: () => removeListener(listener), - ); - - return controller.stream; - } -} diff --git a/test/src/field/field_controller_extensions_test.dart b/test/src/field/field_controller_extensions_test.dart deleted file mode 100644 index 1e6857dc..00000000 --- a/test/src/field/field_controller_extensions_test.dart +++ /dev/null @@ -1,69 +0,0 @@ -// The stream bridge is deprecated, but stays under test until it is removed. -// ignore_for_file: deprecated_member_use_from_same_package - -import 'package:advanced_forms/advanced_forms.dart'; -import 'package:flutter_test/flutter_test.dart'; - -enum _Error { malformed } - -typedef _Value = ({String unit, int? amount}); - -typedef _Field = AdvancedFieldController<_Value, _Error>; - -void main() { - late _Field field; - - setUp(() { - field = AdvancedFieldController<_Value, _Error>( - initialValue: (unit: 'kg', amount: 1), - ); - }); - - tearDown(() { - field.dispose(); - }); - - group('convenience getters', () { - test('fieldValue is a shortcut for value.value', () { - expect(field.fieldValue, (unit: 'kg', amount: 1)); - field.setValue((unit: 'kg', amount: 2)); - expect(field.fieldValue, field.value.value); - }); - - test('error is a shortcut for value.error', () { - expect(field.error, isNull); - field.setError(_Error.malformed); - expect(field.error, _Error.malformed); - expect(field.error, field.value.error); - }); - }); - - group('stream', () { - test('emits states after subscription', () async { - final emissions = >[]; - final subscription = field.stream.listen(emissions.add); - // Broadcast stream attaches its listener on onListen, which is - // synchronous here, but events are delivered asynchronously. - field.setValue((unit: 'kg', amount: 2)); - await pumpEventQueue(); - - expect(emissions, [ - const AdvancedFieldState<_Value, _Error>( - value: (unit: 'kg', amount: 2), - ), - ]); - - await subscription.cancel(); - }); - - test('detaches from the controller after the last cancel', () async { - final subscription = field.stream.listen((_) {}); - await subscription.cancel(); - - // If the bridge leaked its listener, this would throw on dispose - // in debug mode ("used after being disposed") or keep notifying. - field.setValue((unit: 'kg', amount: 2)); - expect(field.fieldValue.amount, 2); - }); - }); -} From 20894ec814c5479bafb8a78d0af76ebca7fda0e1 Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Sat, 24 Oct 2026 15:49:04 +0200 Subject: [PATCH 2/2] Review: the stream recipe can be closed A bare stream-returning function left no way to close the controller, so an await-for loop never ended. The recipe is now a small adapter with close(), called from dispose(), which is what Cubit.close() did. Co-Authored-By: Claude Fable 5.1 --- MIGRATION.md | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 5e1a0cf1..3de21c6b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -470,28 +470,39 @@ addRelation( Outside a form, a raw listener works the same way with the comparison written by hand and a `removeListener` in `dispose()`. -**Code that composes streams** — rxdart operators, `await for`, merging fields into a pipeline. A field has no stream (the deprecated `stream` bridge was removed in 0.3.0). Where a pipeline needs one, wrap the listener yourself and keep the stream in a field, so it is created once: +**Code that composes streams** — rxdart operators, `await for`, merging fields into a pipeline. A field has no stream (the deprecated `stream` bridge was removed in 0.3.0). Where a pipeline needs one, own a small adapter next to the field and close it when the field goes: ```dart -Stream> statesOf( - AdvancedFieldController field, -) { - late final StreamController> controller; - void listener() => controller.add(field.value); - controller = StreamController.broadcast( - onListen: () => field.addListener(listener), - onCancel: () => field.removeListener(listener), - ); - return controller.stream; +/// A field's states as a broadcast stream. Close it from dispose(). +class FieldStates { + FieldStates(this._field) { + _controller = StreamController.broadcast( + onListen: () => _field.addListener(_emit), + onCancel: () => _field.removeListener(_emit), + ); + } + + final AdvancedFieldController _field; + late final StreamController> _controller; + + void _emit() => _controller.add(_field.value); + + Stream> get stream => _controller.stream; + + /// Ends every subscription and any `await for` over [stream]. + Future close() => _controller.close(); } -final quantities = statesOf(quantityField); -final subscription = quantities +final quantities = FieldStates(quantityField); +final subscription = quantities.stream .map((state) => state.value.amount) .listen(recalculateTotals); + +// In dispose(), before the field: +await quantities.close(); ``` -Unlike `Cubit.stream`, this controller is never closed, so it does not terminate an `await for` loop; cancel the subscription in `dispose()`. +`close()` is what `Cubit.close()` used to do for you: it completes the stream, so an `await for` loop ends and listeners are detached. ---