From 3637f521298779cd6754b324ec9438c079a6f950 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 19:25:26 +0000 Subject: [PATCH] feat: add force flag to emit in Bloc and Cubit Adds an optional `force` parameter (defaults to `false`) to `emit` so a state can be emitted even when it is equal to the current state. - `Emittable.emit`, `BlocBase.emit` and `Bloc.emit` accept `{bool force = false}` - `Emitter.call` forwards `force`, so `emit(state, force: true)` works inside event handlers and still triggers `onTransition`/`onChange` - `ReplayCubitMixin.emit` and `ReplayBlocMixin.emit` forward `force` Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sy5uFShMuySLy6T8kGaXor --- packages/bloc/lib/src/bloc.dart | 13 ++-- packages/bloc/lib/src/bloc_base.dart | 17 ++++- packages/bloc/lib/src/emitter.dart | 17 +++-- packages/bloc/test/bloc_test.dart | 57 +++++++++++++++ .../bloc/test/blocs/seeded/seeded_bloc.dart | 19 ++++- packages/bloc/test/cubit_test.dart | 70 +++++++++++++++++++ packages/bloc/test/cubits/seeded_cubit.dart | 2 + packages/replay_bloc/lib/src/replay_bloc.dart | 8 +-- .../replay_bloc/lib/src/replay_cubit.dart | 8 +-- 9 files changed, 190 insertions(+), 21 deletions(-) diff --git a/packages/bloc/lib/src/bloc.dart b/packages/bloc/lib/src/bloc.dart index c32956fc5ea..602799aef06 100644 --- a/packages/bloc/lib/src/bloc.dart +++ b/packages/bloc/lib/src/bloc.dart @@ -145,10 +145,15 @@ abstract class Bloc extends BlocBase /// Updates the state of the bloc to the provided [state]. /// A bloc's state should only be updated by `emitting` a new `state` /// from an [EventHandler] in response to an incoming event. + /// + /// Set [force] to `true` to emit the [state] even if it is equal to the + /// current [state]. /// {@endtemplate} @visibleForTesting @override - void emit(State state) => super.emit(state); + void emit(State state, {bool force = false}) { + super.emit(state, force: force); + } /// Register event handler for an event of type `E`. /// There should only ever be one event handler per event type `E`. @@ -195,9 +200,9 @@ abstract class Bloc extends BlocBase final subscription = (transformer ?? _eventTransformer)( _eventController.stream.where((event) => event is E).cast(), (dynamic event) { - void onEmit(State state) { + void onEmit(State state, {bool force = false}) { if (super.isClosed) return; - if (this.state == state && _emitted) return; + if (!force && this.state == state && _emitted) return; onTransition( Transition( currentState: this.state, @@ -205,7 +210,7 @@ abstract class Bloc extends BlocBase nextState: state, ), ); - emit(state); + emit(state, force: force); } final emitter = _Emitter(onEmit); diff --git a/packages/bloc/lib/src/bloc_base.dart b/packages/bloc/lib/src/bloc_base.dart index f4c6af0cddd..86b31a6a819 100644 --- a/packages/bloc/lib/src/bloc_base.dart +++ b/packages/bloc/lib/src/bloc_base.dart @@ -32,7 +32,10 @@ abstract class Closable { // ignore: one_member_abstracts abstract class Emittable { /// Emits a new [state]. - void emit(State state); + /// + /// If [force] is `true`, the [state] is emitted even if it is equal + /// to the current state. + void emit(State state, {bool force = false}); } /// A generic destination for errors. @@ -90,16 +93,24 @@ abstract class BlocBase /// emitting a state which is equal to the initial state is allowed as long /// as it is the first thing emitted by the instance. /// + /// Set [force] to `true` to emit the [state] even if it is equal to the + /// current [state]. This will notify listeners and trigger [onChange] + /// just like any other state change. + /// + /// ```dart + /// emit(state, force: true); + /// ``` + /// /// * Throws a [StateError] if the bloc is closed. @protected @visibleForTesting @override - void emit(State state) { + void emit(State state, {bool force = false}) { try { if (_stateController.isClosed) { throw StateError('Cannot emit new states after calling close'); } - if (state == _state && _emitted) return; + if (!force && state == _state && _emitted) return; onChange(Change(currentState: this.state, nextState: state)); _state = state; _stateController.add(_state); diff --git a/packages/bloc/lib/src/emitter.dart b/packages/bloc/lib/src/emitter.dart index 0b5a102a43c..17bb2fd0ce8 100644 --- a/packages/bloc/lib/src/emitter.dart +++ b/packages/bloc/lib/src/emitter.dart @@ -58,13 +58,22 @@ abstract class Emitter { bool get isDone; /// Emits the provided [state]. - void call(State state); + /// + /// Set [force] to `true` to emit the [state] even if it is equal to the + /// current state. + /// + /// ```dart + /// on((event, emit) { + /// emit(state, force: true); + /// }); + /// ``` + void call(State state, {bool force = false}); } class _Emitter implements Emitter { _Emitter(this._emit); - final void Function(State state) _emit; + final void Function(State state, {bool force}) _emit; final _completer = Completer(); final _disposables = Function()>[]; @@ -109,7 +118,7 @@ class _Emitter implements Emitter { } @override - void call(State state) { + void call(State state, {bool force = false}) { assert( !_isCompleted, ''' @@ -132,7 +141,7 @@ ensure the event handler has not completed. }); ''', ); - if (!_isCanceled) _emit(state); + if (!_isCanceled) _emit(state, force: force); } @override diff --git a/packages/bloc/test/bloc_test.dart b/packages/bloc/test/bloc_test.dart index 29478eccbd9..87f0d8b1d76 100644 --- a/packages/bloc/test/bloc_test.dart +++ b/packages/bloc/test/bloc_test.dart @@ -915,6 +915,63 @@ void main() { ..add('event') ..close(); }); + + test('emits repeated states when force is true', () { + final seededBloc = SeededBloc( + seed: 0, + states: [1, 2, 1, 1], + force: true, + ); + final expectedStates = [1, 2, 1, 1, emitsDone]; + + expectLater(seededBloc.stream, emitsInOrder(expectedStates)); + + seededBloc + ..add('event') + ..close(); + }); + + test('emits the seed state when force is true', () { + final seededBloc = SeededBloc(seed: 0, states: [0, 0], force: true); + final expectedStates = [0, 0, emitsDone]; + + expectLater(seededBloc.stream, emitsInOrder(expectedStates)); + + seededBloc + ..add('event') + ..close(); + }); + + test('emits duplicate states across events when force is true', () { + final seededBloc = SeededBloc(seed: 0, states: [1], force: true); + final expectedStates = [1, 1, 1, emitsDone]; + + expectLater(seededBloc.stream, emitsInOrder(expectedStates)); + + seededBloc + ..add('eventA') + ..add('eventB') + ..add('eventC') + ..close(); + }); + + test('notifies onTransition for each forced emit', () async { + final transitions = >[]; + final seededBloc = SeededBloc( + seed: 0, + states: [1, 1], + force: true, + onTransitionCallback: transitions.add, + )..add('event'); + + await tick(); + await seededBloc.close(); + + expect(transitions, const [ + Transition(currentState: 0, event: 'event', nextState: 1), + Transition(currentState: 1, event: 'event', nextState: 1), + ]); + }); }); group('StreamBloc', () { diff --git a/packages/bloc/test/blocs/seeded/seeded_bloc.dart b/packages/bloc/test/blocs/seeded/seeded_bloc.dart index 6ee713583bd..a98b60c54bf 100644 --- a/packages/bloc/test/blocs/seeded/seeded_bloc.dart +++ b/packages/bloc/test/blocs/seeded/seeded_bloc.dart @@ -1,12 +1,27 @@ import 'package:bloc/bloc.dart'; class SeededBloc extends Bloc { - SeededBloc({required this.seed, required this.states}) : super(seed) { + SeededBloc({ + required this.seed, + required this.states, + this.force = false, + this.onTransitionCallback, + }) : super(seed) { on((event, emit) { - states.forEach(emit.call); + for (final state in states) { + emit(state, force: force); + } }); } final List states; final int seed; + final bool force; + final void Function(Transition transition)? onTransitionCallback; + + @override + void onTransition(Transition transition) { + super.onTransition(transition); + onTransitionCallback?.call(transition); + } } diff --git a/packages/bloc/test/cubit_test.dart b/packages/bloc/test/cubit_test.dart index 72946706b6d..48a67c6200e 100644 --- a/packages/bloc/test/cubit_test.dart +++ b/packages/bloc/test/cubit_test.dart @@ -136,6 +136,20 @@ void main() { ), ).called(1); }); + + test('is called for each forced emit of an identical state', () async { + final cubit = SeededCubit(initialState: 0) + ..emitStateForced(1) + ..emitStateForced(1); + await cubit.close(); + verify( + // ignore: invalid_use_of_protected_member + () => observer.onChange( + cubit, + const Change(currentState: 1, nextState: 1), + ), + ).called(1); + }); }); group('emit', () { @@ -216,6 +230,62 @@ void main() { await subscription.cancel(); expect(states, [1, 2, 3]); }); + + test('emits duplicate states when force is true', () async { + final states = []; + final cubit = SeededCubit(initialState: 0); + final subscription = cubit.stream.listen(states.add); + cubit + ..emitStateForced(1) + ..emitStateForced(1) + ..emitStateForced(2) + ..emitStateForced(2); + await cubit.close(); + await subscription.cancel(); + expect(states, [1, 1, 2, 2]); + }); + + test('emits the current state when force is true', () async { + final states = []; + final cubit = SeededCubit(initialState: 0); + final subscription = cubit.stream.listen(states.add); + cubit + ..emitStateForced(0) + ..emitStateForced(0); + await cubit.close(); + await subscription.cancel(); + expect(states, [0, 0]); + expect(cubit.state, equals(0)); + }); + + test('force does not affect subsequent emits', () async { + final states = []; + final cubit = SeededCubit(initialState: 0); + final subscription = cubit.stream.listen(states.add); + cubit + ..emitStateForced(1) + ..emitState(1) + ..emitState(2); + await cubit.close(); + await subscription.cancel(); + expect(states, [1, 2]); + }); + + test('throws StateError when force is true and cubit is closed', + () async { + final cubit = SeededCubit(initialState: 0); + await cubit.close(); + expect( + () => cubit.emitStateForced(0), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Cannot emit new states after calling close', + ), + ), + ); + }); }); group('listen', () { diff --git a/packages/bloc/test/cubits/seeded_cubit.dart b/packages/bloc/test/cubits/seeded_cubit.dart index 07bcfcc4efc..c6be7c7608a 100644 --- a/packages/bloc/test/cubits/seeded_cubit.dart +++ b/packages/bloc/test/cubits/seeded_cubit.dart @@ -4,4 +4,6 @@ class SeededCubit extends Cubit { SeededCubit({required T initialState}) : super(initialState); void emitState(T state) => emit(state); + + void emitStateForced(T state) => emit(state, force: true); } diff --git a/packages/replay_bloc/lib/src/replay_bloc.dart b/packages/replay_bloc/lib/src/replay_bloc.dart index 5ad6298f404..8ad52bf24df 100644 --- a/packages/replay_bloc/lib/src/replay_bloc.dart +++ b/packages/replay_bloc/lib/src/replay_bloc.dart @@ -94,7 +94,7 @@ mixin ReplayBlocMixin on Bloc { } @override - void emit(State state) { + void emit(State state, {bool force = false}) { _changeStack.add( _Change( this.state, @@ -110,7 +110,7 @@ mixin ReplayBlocMixin on Bloc { ), ); // ignore: invalid_use_of_visible_for_testing_member - super.emit(state); + super.emit(state, force: force); }, (val) { final event = _Undo(); @@ -123,12 +123,12 @@ mixin ReplayBlocMixin on Bloc { ), ); // ignore: invalid_use_of_visible_for_testing_member - super.emit(val); + super.emit(val, force: force); }, ), ); // ignore: invalid_use_of_visible_for_testing_member - super.emit(state); + super.emit(state, force: force); } /// Undo the last change. diff --git a/packages/replay_bloc/lib/src/replay_cubit.dart b/packages/replay_bloc/lib/src/replay_cubit.dart index 6c430cfcfed..47fe2d3c58f 100644 --- a/packages/replay_bloc/lib/src/replay_cubit.dart +++ b/packages/replay_bloc/lib/src/replay_cubit.dart @@ -63,16 +63,16 @@ mixin ReplayCubitMixin on Cubit { set limit(int limit) => _changeStack.limit = limit; @override - void emit(State state) { + void emit(State state, {bool force = false}) { _changeStack.add( _Change( this.state, state, - () => super.emit(state), - (val) => super.emit(val), + () => super.emit(state, force: force), + (val) => super.emit(val, force: force), ), ); - super.emit(state); + super.emit(state, force: force); } /// Undo the last change.