Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(hydrated_bloc): add ttl and shouldPersistOnEvent to HydratedBloc… #4344

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 95 additions & 19 deletions packages/hydrated_bloc/lib/src/hydrated_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic> json) => json['value'] as int;
///
/// @override
Expand All @@ -33,8 +41,12 @@ import 'package:meta/meta.dart';
abstract class HydratedBloc<Event, State> extends Bloc<Event, State>
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;
Expand All @@ -49,6 +61,26 @@ abstract class HydratedBloc<Event, State> extends Bloc<Event, State>
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<State> change) {
return shouldPersistOnEvent(_currentEvent);
}

/// The current event being processed.
Event? _currentEvent;

@override
void onEvent(Event event) {
super.onEvent(event);
_currentEvent = event;
}
}

/// {@template hydrated_cubit}
Expand All @@ -75,8 +107,12 @@ abstract class HydratedBloc<Event, State> extends Bloc<Event, State>
abstract class HydratedCubit<State> extends Cubit<State>
with HydratedMixin<State> {
/// {@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);
}
}

Expand Down Expand Up @@ -106,6 +142,8 @@ abstract class HydratedCubit<State> extends Cubit<State>
mixin HydratedMixin<State> on BlocBase<State> {
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.
Expand All @@ -118,11 +156,30 @@ mixin HydratedMixin<State> on BlocBase<State> {
/// ...
/// }
/// ```
void hydrate({Storage? storage}) {
void hydrate({Storage? storage, Duration? ttl}) {
__storage = storage ??= HydratedBloc.storage;
_ttl = ttl;

try {
final stateJson = __storage.read(storageToken) as Map<dynamic, dynamic>?;
_state = stateJson != null ? _fromJson(stateJson) : super.state;
final stateJson = __storage.read(storageToken) as Map<String, dynamic>?;
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;
Expand All @@ -131,6 +188,9 @@ mixin HydratedMixin<State> on BlocBase<State> {
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) {
Expand All @@ -148,18 +208,32 @@ mixin HydratedMixin<State> on BlocBase<State> {
void onChange(Change<State> 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<State> change) => true;

State? _fromJson(dynamic json) {
final dynamic traversedJson = _traverseRead(json);
final castJson = _cast<Map<String, dynamic>>(traversedJson);
Expand Down Expand Up @@ -190,18 +264,18 @@ mixin HydratedMixin<State> on BlocBase<State> {
T? _cast<T>(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);
}
Expand Down Expand Up @@ -262,7 +336,7 @@ mixin HydratedMixin<State> on BlocBase<State> {
}

dynamic _traverseJson(dynamic object) {
final dynamic traversedAtomicJson = _traverseAtomicJson(object);
final traversedAtomicJson = _traverseAtomicJson(object);
return traversedAtomicJson is! NIL
? traversedAtomicJson
: _traverseComplexJson(object);
Expand All @@ -288,6 +362,8 @@ mixin HydratedMixin<State> on BlocBase<State> {
_seen.removeLast();
}

Duration? _ttl;

/// [id] is used to uniquely identify multiple instances
/// of the same [HydratedBloc] type.
/// In most cases it is not necessary;
Expand Down
127 changes: 127 additions & 0 deletions packages/hydrated_bloc/test/hydrated_bloc_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ abstract class CounterEvent {}

class Increment extends CounterEvent {}

class Decrement extends CounterEvent {}

class DoNothing extends CounterEvent {}

class MyCallbackHydratedBloc extends HydratedBloc<CounterEvent, int> {
MyCallbackHydratedBloc({this.onFromJsonCalled}) : super(0) {
on<Increment>((event, emit) => emit(state + 1));
Expand Down Expand Up @@ -129,6 +133,39 @@ class MyErrorThrowingBloc extends HydratedBloc<Object, int> {
}
}

class ConditionalPersistHydratedBloc extends HydratedBloc<CounterEvent, int> {
ConditionalPersistHydratedBloc() : super(0) {
on<Increment>((event, emit) => emit(state + 1));
on<Decrement>((event, emit) => emit(state - 1));
on<DoNothing>((event, emit) => emit(state + 1000));
}

@override
Map<String, int>? toJson(int state) => {'value': state};

@override
int? fromJson(Map<String, dynamic> 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<CounterEvent, int> {
TtlHydratedBloc({Storage? storage, Duration? ttl})
: super(0, storage: storage, ttl: ttl) {
on<Increment>((event, emit) => emit(state + 1));
}

@override
int? fromJson(Map<String, dynamic> json) => json['value'] as int?;

@override
Map<String, dynamic>? toJson(int state) => {'value': state};
}

void main() {
group('HydratedBloc', () {
late Storage storage;
Expand Down Expand Up @@ -561,4 +598,94 @@ void main() {
});
});
});

group('ConditionalPersistHydratedBloc', () {
late Storage storage;

setUp(() {
storage = MockStorage();
when<dynamic>(() => storage.read(any())).thenReturn(null);
when(() => storage.write(any(), any<dynamic>())).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<dynamic>(() => storage.read(any())).thenReturn(null);
when(() => storage.write(any(), any<dynamic>())).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<dynamic>(() => 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<dynamic>(() => 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);
});
});
}
28 changes: 28 additions & 0 deletions packages/hydrated_bloc/test/hydrated_cubit_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,22 @@ class MyMultiHydratedCubit extends HydratedCubit<int> {
int? fromJson(dynamic json) => json['value'] as int?;
}

class NoPersistHydratedCubit extends HydratedCubit<int> {
NoPersistHydratedCubit() : super(0);

@override
Map<String, int>? toJson(int state) => {'value': state};

@override
int? fromJson(Map<String, dynamic> json) => json['value'] as int?;

// Always return false to skip persisting.
@override
bool shouldPersistOnChange(Change<int> change) => false;

void increment() => emit(state + 1);
}

void main() {
group('HydratedCubit', () {
late Storage storage;
Expand Down Expand Up @@ -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<dynamic>()))
.called(1);

cubit.increment();
verifyNever(() => storage.write('NoPersistHydratedCubit', {'value': 1}));
});
});
}