diff --git a/packages/hydrated_bloc/README.md b/packages/hydrated_bloc/README.md index e96f0e64271..6ff02401a42 100644 --- a/packages/hydrated_bloc/README.md +++ b/packages/hydrated_bloc/README.md @@ -102,6 +102,36 @@ class CounterBloc extends HydratedBloc { Now the `CounterCubit` and `CounterBloc` will automatically persist/restore their state. We can increment the counter value, hot restart, kill the app, etc... and the previous state will be retained. +### Cache Expiration + +You can optionally specify an `expirationDuration` for cached state. When specified, the cached state will be automatically cleared and the default state will be used if the cache has expired. + +```dart +class CounterCubit extends HydratedCubit { + CounterCubit() + : super( + 0, + // Cached state will expire after 7 days + expirationDuration: const Duration(days: 7), + ); + + void increment() => emit(state + 1); + + @override + int fromJson(Map json) => json['value'] as int; + + @override + Map toJson(int state) => {'value': state}; +} +``` + +This is useful for scenarios where: +- You want to ensure users don't see stale data after a certain period +- You have session-based data that should expire +- You want to limit how long cached data is kept + +**Note:** Expired state is automatically cleared from storage to free up space. + ### HydratedMixin ```dart diff --git a/packages/hydrated_bloc/lib/src/hydrated_bloc.dart b/packages/hydrated_bloc/lib/src/hydrated_bloc.dart index 85cc5b2ad48..40c90569245 100644 --- a/packages/hydrated_bloc/lib/src/hydrated_bloc.dart +++ b/packages/hydrated_bloc/lib/src/hydrated_bloc.dart @@ -65,8 +65,13 @@ abstract class HydratedBloc extends Bloc State state, { Storage? storage, OnHydrationError onHydrationError = defaultOnHydrationError, + Duration? expirationDuration, }) : super(state) { - hydrate(storage: storage, onError: onHydrationError); + hydrate( + storage: storage, + onError: onHydrationError, + expirationDuration: expirationDuration, + ); } static Storage? _storage; @@ -111,8 +116,13 @@ abstract class HydratedCubit extends Cubit State state, { Storage? storage, OnHydrationError onHydrationError = defaultOnHydrationError, + Duration? expirationDuration, }) : super(state) { - hydrate(storage: storage, onError: onHydrationError); + hydrate( + storage: storage, + onError: onHydrationError, + expirationDuration: expirationDuration, + ); } } @@ -145,6 +155,7 @@ mixin HydratedMixin on BlocBase { late final Storage __storage; HydrationErrorBehavior? _errorBehavior; var _onErrorCallbackInProgress = false; + Duration? _expirationDuration; /// Populates the internal state storage with the latest state. /// This should be called when using the [HydratedMixin] @@ -179,11 +190,25 @@ mixin HydratedMixin on BlocBase { void hydrate({ Storage? storage, OnHydrationError onError = defaultOnHydrationError, + Duration? expirationDuration, }) { __storage = storage ??= HydratedBloc.storage; + _expirationDuration = expirationDuration; + var wasExpired = false; try { final stateJson = __storage.read(storageToken) as Map?; - _state = stateJson != null ? _fromJson(stateJson) : super.state; + if (stateJson != null) { + final result = _fromJsonWithExpiration(stateJson); + wasExpired = result.wasExpired; + _state = result.state ?? super.state; + + // Clear expired data from storage if it was expired + if (wasExpired) { + __storage.delete(storageToken).then((_) {}, onError: this.onError); + } + } else { + _state = super.state; + } _errorBehavior = null; } catch (error, stackTrace) { this.onError(error, stackTrace); @@ -234,14 +259,56 @@ mixin HydratedMixin on BlocBase { } } - State? _fromJson(dynamic json) { + _HydrationResult _fromJsonWithExpiration(dynamic json) { final dynamic traversedJson = _traverseRead(json); final castJson = _cast>(traversedJson); - return fromJson(castJson ?? {}); + if (castJson == null) { + return _HydrationResult(fromJson({}), wasExpired: false); + } + + // Check if this is wrapped data with expiration metadata + if (castJson.containsKey('__hydrated_state__')) { + final timestamp = castJson['__hydrated_timestamp__'] as int?; + final stateData = castJson['__hydrated_state__']; + + if (timestamp != null && _expirationDuration != null) { + final savedTime = DateTime.fromMillisecondsSinceEpoch(timestamp); + final expirationTime = savedTime.add(_expirationDuration!); + final now = DateTime.now(); + + // If expired, return null to use default state + if (now.isAfter(expirationTime)) { + return _HydrationResult(null, wasExpired: true); + } + } + + // Not expired or no timestamp, unwrap the state + final unwrappedJson = _cast>(stateData); + return _HydrationResult( + fromJson(unwrappedJson ?? {}), + wasExpired: false, + ); + } + + // Legacy format without expiration wrapper + return _HydrationResult(fromJson(castJson), wasExpired: false); } + Map? _toJson(State state) { - return _cast>(_traverseWrite(toJson(state)).value); + final stateJson = + _cast>(_traverseWrite(toJson(state)).value); + if (stateJson == null) return null; + + // If expiration is enabled, wrap state with timestamp + if (_expirationDuration != null) { + return { + '__hydrated_state__': stateJson, + '__hydrated_timestamp__': DateTime.now().millisecondsSinceEpoch, + }; + } + + return stateJson; } dynamic _traverseRead(dynamic value) { @@ -488,3 +555,9 @@ class _Traversed { final _Outcome outcome; final dynamic value; } + +class _HydrationResult { + _HydrationResult(this.state, {required this.wasExpired}); + final T? state; + final bool wasExpired; +} diff --git a/packages/hydrated_bloc/test/cache_expiration_test.dart b/packages/hydrated_bloc/test/cache_expiration_test.dart new file mode 100644 index 00000000000..07b30657c70 --- /dev/null +++ b/packages/hydrated_bloc/test/cache_expiration_test.dart @@ -0,0 +1,291 @@ +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class MockStorage extends Mock implements Storage {} + +class ExpiringCounterCubit extends HydratedCubit { + ExpiringCounterCubit({Storage? storage, Duration? expirationDuration}) + : super(0, storage: storage, expirationDuration: expirationDuration); + + void increment() => emit(state + 1); + + @override + int? fromJson(Map json) => json['value'] as int?; + + @override + Map? toJson(int state) => {'value': state}; +} + +class NonExpiringCounterCubit extends HydratedCubit { + NonExpiringCounterCubit({Storage? storage}) : super(0, storage: storage); + + void increment() => emit(state + 1); + + @override + int? fromJson(Map json) => json['value'] as int?; + + @override + Map? toJson(int state) => {'value': state}; +} + +void main() { + group('HydratedBloc Cache Expiration', () { + late Storage storage; + + setUp(() { + storage = MockStorage(); + when(() => storage.write(any(), any())).thenAnswer((_) async {}); + when(() => storage.delete(any())).thenAnswer((_) async {}); + }); + + group('ExpiringCounterCubit', () { + test('saves state with timestamp when expiration is enabled', () async { + when(() => storage.read(any())).thenReturn(null); + + final cubit = ExpiringCounterCubit( + storage: storage, + expirationDuration: const Duration(days: 7), + )..increment(); + await Future.delayed(Duration.zero); + + final captured = verify( + () => storage.write('ExpiringCounterCubit', captureAny()), + ).captured; + + expect(captured, hasLength(2)); // Initial state + increment + final savedData = captured.last as Map; + expect(savedData.containsKey('__hydrated_state__'), isTrue); + expect(savedData.containsKey('__hydrated_timestamp__'), isTrue); + expect(savedData['__hydrated_state__'], {'value': 1}); + expect(savedData['__hydrated_timestamp__'], isA()); + + await cubit.close(); + }); + + test('restores non-expired state successfully', () async { + final now = DateTime.now(); + final savedData = { + '__hydrated_state__': {'value': 42}, + '__hydrated_timestamp__': now.millisecondsSinceEpoch, + }; + when(() => storage.read(any())).thenReturn(savedData); + + final cubit = ExpiringCounterCubit( + storage: storage, + expirationDuration: const Duration(days: 7), + ); + + expect(cubit.state, 42); + + await cubit.close(); + }); + + test('does not restore expired state and uses default state', () async { + final pastTime = DateTime.now().subtract(const Duration(days: 10)); + final expiredData = { + '__hydrated_state__': {'value': 42}, + '__hydrated_timestamp__': pastTime.millisecondsSinceEpoch, + }; + when(() => storage.read(any())).thenReturn(expiredData); + + final cubit = ExpiringCounterCubit( + storage: storage, + expirationDuration: const Duration(days: 7), + ); + + // Should use default state (0) instead of expired state (42) + expect(cubit.state, 0); + + await cubit.close(); + }); + + test('deletes expired data from storage', () async { + final pastTime = DateTime.now().subtract(const Duration(days: 10)); + final expiredData = { + '__hydrated_state__': {'value': 42}, + '__hydrated_timestamp__': pastTime.millisecondsSinceEpoch, + }; + when(() => storage.read(any())).thenReturn(expiredData); + + final cubit = ExpiringCounterCubit( + storage: storage, + expirationDuration: const Duration(days: 7), + ); + + // Verify that delete was called to clear expired data + await Future.delayed(const Duration(milliseconds: 100)); + verify(() => storage.delete('ExpiringCounterCubit')).called(1); + + await cubit.close(); + }); + + test('handles legacy data without expiration wrapper', () async { + // Old format data without wrapper + final legacyData = {'value': 99}; + when(() => storage.read(any())).thenReturn(legacyData); + + final cubit = ExpiringCounterCubit( + storage: storage, + expirationDuration: const Duration(days: 7), + ); + + // Should restore legacy data successfully + expect(cubit.state, 99); + + await cubit.close(); + }); + + test('state on exact expiration boundary is considered expired', + () async { + final saveTime = + DateTime.now().subtract(const Duration(days: 7, seconds: 1)); + final boundaryData = { + '__hydrated_state__': {'value': 42}, + '__hydrated_timestamp__': saveTime.millisecondsSinceEpoch, + }; + when(() => storage.read(any())).thenReturn(boundaryData); + + final cubit = ExpiringCounterCubit( + storage: storage, + expirationDuration: const Duration(days: 7), + ); + + // Should be expired + expect(cubit.state, 0); + + await cubit.close(); + }); + }); + + group('NonExpiringCounterCubit (backward compatibility)', () { + test('saves state without timestamp wrapper', () async { + when(() => storage.read(any())).thenReturn(null); + + final cubit = NonExpiringCounterCubit(storage: storage)..increment(); + await Future.delayed(Duration.zero); + + final captured = verify( + () => storage.write('NonExpiringCounterCubit', captureAny()), + ).captured; + + expect(captured, hasLength(2)); + final savedData = captured.last as Map; + // Should NOT have expiration wrapper + expect(savedData.containsKey('__hydrated_state__'), isFalse); + expect(savedData.containsKey('__hydrated_timestamp__'), isFalse); + expect(savedData, {'value': 1}); + + await cubit.close(); + }); + + test('restores state normally without expiration check', () async { + final savedData = {'value': 42}; + when(() => storage.read(any())).thenReturn(savedData); + + final cubit = NonExpiringCounterCubit(storage: storage); + + expect(cubit.state, 42); + + await cubit.close(); + }); + + test('does not delete data even if it has old timestamp', () async { + // Data with very old timestamp but no expiration configured + final veryOldTime = DateTime.now().subtract(const Duration(days: 365)); + final oldData = { + '__hydrated_state__': {'value': 42}, + '__hydrated_timestamp__': veryOldTime.millisecondsSinceEpoch, + }; + when(() => storage.read(any())).thenReturn(oldData); + + final cubit = NonExpiringCounterCubit(storage: storage); + + // Should still restore the data since no expiration is configured + expect(cubit.state, 42); + + // Should not attempt to delete + await Future.delayed(const Duration(milliseconds: 100)); + verifyNever(() => storage.delete('NonExpiringCounterCubit')); + + await cubit.close(); + }); + }); + + group('Edge cases', () { + test('handles missing timestamp in wrapped data gracefully', () async { + final malformedData = { + '__hydrated_state__': {'value': 42}, + // Missing __hydrated_timestamp__ + }; + when(() => storage.read(any())).thenReturn(malformedData); + + final cubit = ExpiringCounterCubit( + storage: storage, + expirationDuration: const Duration(days: 7), + ); + + // Should restore the state since timestamp is missing + expect(cubit.state, 42); + + await cubit.close(); + }); + + test('handles null state data in wrapper', () async { + final nullStateData = { + '__hydrated_state__': null, + '__hydrated_timestamp__': DateTime.now().millisecondsSinceEpoch, + }; + when(() => storage.read(any())).thenReturn(nullStateData); + + final cubit = ExpiringCounterCubit( + storage: storage, + expirationDuration: const Duration(days: 7), + ); + + // Should use default state + expect(cubit.state, 0); + + await cubit.close(); + }); + + test('different expiration durations work correctly', () async { + // Test with 1 hour expiration + final oneHourAgo = DateTime.now().subtract(const Duration(minutes: 30)); + final recentData = { + '__hydrated_state__': {'value': 100}, + '__hydrated_timestamp__': oneHourAgo.millisecondsSinceEpoch, + }; + when(() => storage.read(any())).thenReturn(recentData); + + final cubit = ExpiringCounterCubit( + storage: storage, + expirationDuration: const Duration(hours: 1), + ); + + // Should restore since only 30 minutes passed + expect(cubit.state, 100); + + await cubit.close(); + + // Now test with expired + final twoHoursAgo = DateTime.now().subtract(const Duration(hours: 2)); + final oldData = { + '__hydrated_state__': {'value': 200}, + '__hydrated_timestamp__': twoHoursAgo.millisecondsSinceEpoch, + }; + when(() => storage.read(any())).thenReturn(oldData); + + final cubit2 = ExpiringCounterCubit( + storage: storage, + expirationDuration: const Duration(hours: 1), + ); + + // Should use default state since > 1 hour passed + expect(cubit2.state, 0); + + await cubit2.close(); + }); + }); + }); +}