From 999f58e6016997321973b2ccddfb58142471ca13 Mon Sep 17 00:00:00 2001 From: Sergey Dmitriev <51058739+0niel@users.noreply.github.com> Date: Tue, 21 Jan 2025 17:43:00 +0300 Subject: [PATCH] feat(hydrated_bloc): add ttl and shouldPersistOnEvent to HydratedBloc and HydratedCubit --- .../hydrated_bloc/lib/src/hydrated_bloc.dart | 114 +++++++++++++--- .../test/hydrated_bloc_test.dart | 127 ++++++++++++++++++ .../test/hydrated_cubit_test.dart | 28 ++++ 3 files changed, 250 insertions(+), 19 deletions(-) diff --git a/packages/hydrated_bloc/lib/src/hydrated_bloc.dart b/packages/hydrated_bloc/lib/src/hydrated_bloc.dart index 36fe393cbbf..54d9c4c5efa 100644 --- a/packages/hydrated_bloc/lib/src/hydrated_bloc.dart +++ b/packages/hydrated_bloc/lib/src/hydrated_bloc.dart @@ -22,6 +22,14 @@ import 'package:meta/meta.dart'; /// } /// /// @override +/// bool shouldPersistOnEvent(CounterEvent? event) { +/// // Persist state only on increment and decrement events +/// final shouldPersist = event is CounterIncrementPressed || +/// event is CounterDecrementPressed; +/// return shouldPersist; +/// } +/// +/// @override /// int fromJson(Map json) => json['value'] as int; /// /// @override @@ -33,8 +41,12 @@ import 'package:meta/meta.dart'; abstract class HydratedBloc extends Bloc with HydratedMixin { /// {@macro hydrated_bloc} - HydratedBloc(State state, {Storage? storage}) : super(state) { - hydrate(storage: storage); + HydratedBloc( + State state, { + Storage? storage, + Duration? ttl, + }) : super(state) { + hydrate(storage: storage, ttl: ttl); } static Storage? _storage; @@ -49,6 +61,26 @@ abstract class HydratedBloc extends Bloc if (_storage == null) throw const StorageNotFound(); return _storage!; } + + /// Determines whether to persist the state based on the event. + /// + /// By default, all events trigger state persistence. + /// Override this method to customize which events should persist state. + bool shouldPersistOnEvent(Event? event) => true; + + @override + bool shouldPersistOnChange(Change change) { + return shouldPersistOnEvent(_currentEvent); + } + + /// The current event being processed. + Event? _currentEvent; + + @override + void onEvent(Event event) { + super.onEvent(event); + _currentEvent = event; + } } /// {@template hydrated_cubit} @@ -75,8 +107,12 @@ abstract class HydratedBloc extends Bloc abstract class HydratedCubit extends Cubit with HydratedMixin { /// {@macro hydrated_cubit} - HydratedCubit(State state, [Storage? storage]) : super(state) { - hydrate(storage: storage); + HydratedCubit( + State state, [ + Storage? storage, + Duration? ttl, + ]) : super(state) { + hydrate(storage: storage, ttl: ttl); } } @@ -106,6 +142,8 @@ abstract class HydratedCubit extends Cubit mixin HydratedMixin on BlocBase { late final Storage __storage; + static const String _updatedAtKey = '_updatedAt'; + /// Populates the internal state storage with the latest state. /// This should be called when using the [HydratedMixin] /// directly within the constructor body. @@ -118,11 +156,30 @@ mixin HydratedMixin on BlocBase { /// ... /// } /// ``` - void hydrate({Storage? storage}) { + void hydrate({Storage? storage, Duration? ttl}) { __storage = storage ??= HydratedBloc.storage; + _ttl = ttl; + try { - final stateJson = __storage.read(storageToken) as Map?; - _state = stateJson != null ? _fromJson(stateJson) : super.state; + final stateJson = __storage.read(storageToken) as Map?; + if (stateJson != null) { + if (_ttl != null && stateJson.containsKey(_updatedAtKey)) { + final updatedAt = + DateTime.tryParse(stateJson[_updatedAtKey] as String); + if (updatedAt != null && + updatedAt.add(_ttl!).isBefore(DateTime.now())) { + // Persisted state is expired + _state = super.state; + __storage.delete(storageToken); + } else { + _state = _fromJson(stateJson); + } + } else { + _state = _fromJson(stateJson); + } + } else { + _state = super.state; + } } catch (error, stackTrace) { onError(error, stackTrace); _state = super.state; @@ -131,6 +188,9 @@ mixin HydratedMixin on BlocBase { try { final stateJson = _toJson(state); if (stateJson != null) { + if (_ttl != null) { + stateJson[_updatedAtKey] = DateTime.now().toIso8601String(); + } __storage.write(storageToken, stateJson).then((_) {}, onError: onError); } } catch (error, stackTrace) { @@ -148,18 +208,32 @@ mixin HydratedMixin on BlocBase { void onChange(Change change) { super.onChange(change); final state = change.nextState; - try { - final stateJson = _toJson(state); - if (stateJson != null) { - __storage.write(storageToken, stateJson).then((_) {}, onError: onError); + if (shouldPersistOnChange(change)) { + try { + final stateJson = _toJson(state); + if (stateJson != null) { + if (_ttl != null) { + stateJson[_updatedAtKey] = DateTime.now().toIso8601String(); + } + __storage.write(storageToken, stateJson).then( + (_) {}, + onError: onError, + ); + } + } catch (error, stackTrace) { + onError(error, stackTrace); + rethrow; } - } catch (error, stackTrace) { - onError(error, stackTrace); - rethrow; } _state = state; } + /// Determines whether to persist the state based on the state change. + /// + /// By default, all state changes are persisted. + /// Override this method to customize persistence logic. + bool shouldPersistOnChange(Change change) => true; + State? _fromJson(dynamic json) { final dynamic traversedJson = _traverseRead(json); final castJson = _cast>(traversedJson); @@ -190,18 +264,18 @@ mixin HydratedMixin on BlocBase { T? _cast(dynamic x) => x is T ? x : null; _Traversed _traverseWrite(Object? value) { - final dynamic traversedAtomicJson = _traverseAtomicJson(value); + final traversedAtomicJson = _traverseAtomicJson(value); if (traversedAtomicJson is! NIL) { return _Traversed.atomic(traversedAtomicJson); } - final dynamic traversedComplexJson = _traverseComplexJson(value); + final traversedComplexJson = _traverseComplexJson(value); if (traversedComplexJson is! NIL) { return _Traversed.complex(traversedComplexJson); } try { _checkCycle(value); - final dynamic customJson = _toEncodable(value); - final dynamic traversedCustomJson = _traverseJson(customJson); + final customJson = _toEncodable(value); + final traversedCustomJson = _traverseJson(customJson); if (traversedCustomJson is NIL) { throw HydratedUnsupportedError(value); } @@ -262,7 +336,7 @@ mixin HydratedMixin on BlocBase { } dynamic _traverseJson(dynamic object) { - final dynamic traversedAtomicJson = _traverseAtomicJson(object); + final traversedAtomicJson = _traverseAtomicJson(object); return traversedAtomicJson is! NIL ? traversedAtomicJson : _traverseComplexJson(object); @@ -288,6 +362,8 @@ mixin HydratedMixin on BlocBase { _seen.removeLast(); } + Duration? _ttl; + /// [id] is used to uniquely identify multiple instances /// of the same [HydratedBloc] type. /// In most cases it is not necessary; diff --git a/packages/hydrated_bloc/test/hydrated_bloc_test.dart b/packages/hydrated_bloc/test/hydrated_bloc_test.dart index 949c14e2a17..e825d8ce1e9 100644 --- a/packages/hydrated_bloc/test/hydrated_bloc_test.dart +++ b/packages/hydrated_bloc/test/hydrated_bloc_test.dart @@ -43,6 +43,10 @@ abstract class CounterEvent {} class Increment extends CounterEvent {} +class Decrement extends CounterEvent {} + +class DoNothing extends CounterEvent {} + class MyCallbackHydratedBloc extends HydratedBloc { MyCallbackHydratedBloc({this.onFromJsonCalled}) : super(0) { on((event, emit) => emit(state + 1)); @@ -129,6 +133,39 @@ class MyErrorThrowingBloc extends HydratedBloc { } } +class ConditionalPersistHydratedBloc extends HydratedBloc { + ConditionalPersistHydratedBloc() : super(0) { + on((event, emit) => emit(state + 1)); + on((event, emit) => emit(state - 1)); + on((event, emit) => emit(state + 1000)); + } + + @override + Map? toJson(int state) => {'value': state}; + + @override + int? fromJson(Map json) => json['value'] as int?; + + // Only persist on Increment/Decrement, not on DoNothing + @override + bool shouldPersistOnEvent(CounterEvent? event) { + return event is Increment || event is Decrement; + } +} + +class TtlHydratedBloc extends HydratedBloc { + TtlHydratedBloc({Storage? storage, Duration? ttl}) + : super(0, storage: storage, ttl: ttl) { + on((event, emit) => emit(state + 1)); + } + + @override + int? fromJson(Map json) => json['value'] as int?; + + @override + Map? toJson(int state) => {'value': state}; +} + void main() { group('HydratedBloc', () { late Storage storage; @@ -561,4 +598,94 @@ void main() { }); }); }); + + group('ConditionalPersistHydratedBloc', () { + late Storage storage; + + setUp(() { + storage = MockStorage(); + when(() => storage.read(any())).thenReturn(null); + when(() => storage.write(any(), any())).thenAnswer((_) async {}); + when(() => storage.delete(any())).thenAnswer((_) async {}); + when(() => storage.clear()).thenAnswer((_) async {}); + HydratedBloc.storage = storage; + }); + + test('persists only on Increment/Decrement events', () async { + final bloc = ConditionalPersistHydratedBloc(); + verify(() => storage.read('ConditionalPersistHydratedBloc')).called(1); + verify( + () => storage.write('ConditionalPersistHydratedBloc', {'value': 0}), + ).called(1); + + bloc.add(Increment()); + + await expectLater(bloc.stream, emits(1)); + + verify( + () => storage.write('ConditionalPersistHydratedBloc', {'value': 1}), + ).called(1); + + bloc.add(DoNothing()); + + await expectLater(bloc.stream, emits(1001)); + + verifyNever( + () => storage.write('ConditionalPersistHydratedBloc', {'value': 1001}), + ); + + bloc.add(Decrement()); + + await expectLater(bloc.stream, emits(1000)); + + verify( + () => storage.write('ConditionalPersistHydratedBloc', {'value': 1000}), + ).called(1); + + verifyNoMoreInteractions(storage); + }); + }); + + group('TtlHydratedBloc', () { + late Storage storage; + + setUp(() { + storage = MockStorage(); + when(() => storage.read(any())).thenReturn(null); + when(() => storage.write(any(), any())).thenAnswer((_) async {}); + when(() => storage.delete(any())).thenAnswer((_) async {}); + when(() => storage.clear()).thenAnswer((_) async {}); + HydratedBloc.storage = storage; + }); + + test('restores state when not expired', () { + final updatedAt = DateTime.now().subtract(const Duration(seconds: 5)); + const ttl = Duration(seconds: 10); + + when(() => storage.read('TtlHydratedBloc')).thenReturn({ + 'value': 123, + '_updatedAt': updatedAt.toIso8601String(), + }); + + final bloc = TtlHydratedBloc(storage: storage, ttl: ttl); + expect(bloc.state, 123); + + verifyNever(() => storage.delete('TtlHydratedBloc')); + }); + + test('does not restore state when expired and deletes from storage', () { + final updatedAt = DateTime.now().subtract(const Duration(days: 2)); + const ttl = Duration(days: 1); + + when(() => storage.read('TtlHydratedBloc')).thenReturn({ + 'value': 999, + '_updatedAt': updatedAt.toIso8601String(), + }); + + final bloc = TtlHydratedBloc(storage: storage, ttl: ttl); + expect(bloc.state, 0); + + verify(() => storage.delete('TtlHydratedBloc')).called(1); + }); + }); } diff --git a/packages/hydrated_bloc/test/hydrated_cubit_test.dart b/packages/hydrated_bloc/test/hydrated_cubit_test.dart index 11ec154e321..48d77a233ab 100644 --- a/packages/hydrated_bloc/test/hydrated_cubit_test.dart +++ b/packages/hydrated_bloc/test/hydrated_cubit_test.dart @@ -88,6 +88,22 @@ class MyMultiHydratedCubit extends HydratedCubit { int? fromJson(dynamic json) => json['value'] as int?; } +class NoPersistHydratedCubit extends HydratedCubit { + NoPersistHydratedCubit() : super(0); + + @override + Map? toJson(int state) => {'value': state}; + + @override + int? fromJson(Map json) => json['value'] as int?; + + // Always return false to skip persisting. + @override + bool shouldPersistOnChange(Change change) => false; + + void increment() => emit(state + 1); +} + void main() { group('HydratedCubit', () { late Storage storage; @@ -379,5 +395,17 @@ void main() { expect(initialStateB, cachedState); }); }); + + test('should not persist state when shouldPersistOnChange returns false', + () { + final cubit = NoPersistHydratedCubit(); + // The first write happens once in the constructor + // due to immediate hydration attempt. + verify(() => storage.write('NoPersistHydratedCubit', any())) + .called(1); + + cubit.increment(); + verifyNever(() => storage.write('NoPersistHydratedCubit', {'value': 1})); + }); }); }