Repository: felangel/bloc

Issue:
fix: Hydrated bloc looses stored state when error on `fromJson`
**Description**
When an exception happens on the `fromJson` of a hydrated cubit/bloc, the stored data is lost and reset to the initial data.

**Steps To Reproduce**

Here is a simple reproducible code:

```dart
import 'package:hydrated_bloc/hydrated_bloc.dart';

class CountCubit extends HydratedCubit<int> {

  CountCubit() : super(0);

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

  void decrement() => emit(state - 1);

  @override
  int? fromJson(Map<String, dynamic> json) {
    // throw Exception('error');
    return json['data'];
  }

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

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  HydratedBloc.storage = await HydratedStorage.build(
    storageDirectory:
        kIsWeb
            ? HydratedStorageDirectory.web
            : HydratedStorageDirectory((await getTemporaryDirectory()).path),
  );
  runApp(
    MaterialApp(
      home: Scaffold(
        body: BlocProvider(
          create: (_) => CountCubit(),

          child: BlocBuilder<CountCubit, int>(
            builder:
                (context, state) => Center(
                  child: Row(
                    children: [
                      ElevatedButton(
                        child: Text('Increment'),
                        onPressed: () => context.read<CountCubit>().increment(),
                      ),
                      Text('$state'),
                      ElevatedButton(
                        child: Text('Decrement'),
                        onPressed: () => context.read<CountCubit>().decrement(),
                      ),
                    ],
                  ),
                ),
          ),
        ),
      ),
    ),
  );
}
```
1- Run the app
2- Interact with it by incrementing and decrementing
3- Hot restart and the data should still be there
4- Uncomment the throw exception in the `fromJson` method and hot restart
5- No exceptions where throw and the state is back at the initial one
6- Comment/Remove the throw, and hot restart
7- See how the state were lost.


**Expected Behavior**
I am not sure if this is a bug or not, probably just a specific use case?

Either way, it would be nice to have a way to check if the stored state is valid or not, so we could show the user an error or something, so we don't lose the stored state.

In my project I did this work around:

Created this `LazyHydratedCubit` which don't instantly call the hydrate method on the constructor: 

```dart
abstract class LazyHydratedCubit<State> extends Cubit<State>
    with HydratedMixin<State> {
  LazyHydratedCubit(super.state);

  Map<String, dynamic> get storagedValue {
    final storage = HydratedBloc.storage;
    final json = storage.read(storageToken) as Map<dynamic, dynamic>?;
    final dynamic traversedJson = _traverseRead(json);
    return _cast<Map<String, dynamic>>(traversedJson) ?? <String, dynamic>{};
  }

  dynamic _traverseRead(dynamic value) {
    if (value is Map) {
      return value.map<String, dynamic>((dynamic key, dynamic value) {
        return MapEntry<String, dynamic>(
          _cast<String>(key) ?? '',
          _traverseRead(value),
        );
      });
    }
    if (value is List) {
      for (var i = 0; i < value.length; i++) {
        value[i] = _traverseRead(value[i]);
      }
    }
    return value;
  }

  T? _cast<T>(dynamic x) => x is T ? x : null;
}```

Then on the page I did something like this:

```dart
class _GamePageState extends State<GamePage> {
  late final gameCubit = GameCubit(
    shipyardRepository: context.read(),
  );

  bool hasStateError = false;

  @override
  void initState() {
    super.initState();

    final storagedValue = gameCubit.storagedValue;

    try {
      GameState.fromJson((storagedValue as Map).cast<String, dynamic>());
      gameCubit.hydrate();
    } catch (e, s) {
      if (kDebugMode) {
        // TODO(erickzanardo): Make a better way to save logs
        print('Error while loading game state\n$e\n$s');
      }
      hasStateError = true;
    }
  }

  @override
  Widget build(BuildContext context) {
    // omitted
   }
}
```
Maybe having a way for the bloc to not hydrated automatically would be enough? for this?

**Additional Context**

Happy to work on a PR if you think this use case would bring value



Discussion: Hey @erickzanardo 👋 
Thanks for opening an issue!

This is working as intended because `fromJson` is always responsible to convert the stored json into a valid state. If an exception occurs that means the bloc was not able to hydrated from the cached state and so it's assumed that the cached state is corrupt.

> Either way, it would be nice to have a way to check if the stored state is valid or not, so we could show the user an error or something, so we don't lose the stored state.

Why would a user care about this? An error during hydration isn't really something the user should know or care about and I don't think the user would be able to take any action to actually fix this error. If hydration is failing that just means the developer didn't handle migrating from old cache state.

Curious to hear more about your use case, thanks!
> If hydration is failing that just means the developer didn't handle migrating from old cache state.

Yeah, but also that there is a bug in the migration? Which is what has been happening in my case.

I think that by default, the current behavior is all good, but when we have a more complex state or a state which is a more sensitive data, we would rather show the user an error screen, than making them lose the data.

I was wondering that is there were a way to mark the bloc/cubit to not hydrate immediately upon instantiation, and we had a method to check if the stored state is valid before I call the hydrate method, that would be great!
To put a bit more of context, this is the screen that I implemented in my app and what I am trying to achieve

<img width="993" alt="Image" src="https://github.com/user-attachments/assets/bcdd3111-a3dc-4339-9801-578b9db964aa" />

@erickzanardo what are your thoughts on the following API:

```dart
// Describes the various behaviors when a hydration error occurs.
enum HydrationErrorBehavior {
  // Retain the cached state when a hydration error occurs.
  retain,
  // Reset the cached state when a hydration error occurs.
  reset,
}

class GameCubit extends Cubit<GameState> with HydratedMixin {
  GameCubit() : super(GameState()) {
    hydrate(
      onError: (error, stackTrace) {
        // Emit an error state for subscribers to react to.
        emit(GameHydrationFailure());
        // Would default to `HydrationErrorBehavior.reset`.
        return HydrationErrorBehavior.retain;
      },
    );
  }
}
```

This would allow you to configure what happens to the cached state when a hydration error occurs while also giving you access to the specific error and stacktrace.
@felangel Really like this, since with this I am also able to trigger the error from within the `BlocBuilder`!



Fix patch:
diff --git a/packages/hydrated_bloc/README.md b/packages/hydrated_bloc/README.md
index a417fd34d63..9f6e2f3d582 100644
--- a/packages/hydrated_bloc/README.md
+++ b/packages/hydrated_bloc/README.md
@@ -137,6 +137,26 @@ class CounterCubit extends HydratedCubit<int> {
 }
 ```
 
+## Handling Hydration Errors
+
+You can optionally pass a custom `onError` callback to `hydrate` in order to handle any hydration errors and/or customize the caching behavior when a hydration error occurs.
+
+```dart
+class CounterBloc extends Bloc<CounterEvent, int> with HydratedMixin {
+  CounterBloc() : super(0) {
+    hydrate(
+      onError: (error, stackTrace) {
+        // Do something in response to hydration errors.
+        // Must return a `HydrationErrorBehavior` to specify whether subsequent
+        // state changes should be persisted.
+        return HydrationErrorBehavior.retain; // Retain the previous state.
+      }
+    );
+  }
+  ...
+}
+```
+
 ## Custom Storage Directory
 
 Any `storageDirectory` can be used when creating an instance of `HydratedStorage`:
diff --git a/packages/hydrated_bloc/lib/src/hydrated_bloc.dart b/packages/hydrated_bloc/lib/src/hydrated_bloc.dart
index 36fe393cbbf..40acfdb216e 100644
--- a/packages/hydrated_bloc/lib/src/hydrated_bloc.dart
+++ b/packages/hydrated_bloc/lib/src/hydrated_bloc.dart
@@ -5,6 +5,34 @@ import 'dart:async';
 import 'package:hydrated_bloc/hydrated_bloc.dart';
 import 'package:meta/meta.dart';
 
+/// Describes the various caching behaviors when a hydration error occurs.
+enum HydrationErrorBehavior {
+  /// Overwrite the cached state when a hydration error occurs.
+  /// Any newly emitted states will be persisted which means previously cached
+  /// state will be overwritten. This is the default behavior.
+  overwrite,
+
+  /// Retain the cached state when a hydration error occurs.
+  /// Any newly emitted states will not be persisted until hydrate succeeds
+  /// which means the previously cached state will be retained.
+  retain,
+}
+
+/// Signature of the `onError` callback during `hydrate`.
+typedef OnHydrationError = HydrationErrorBehavior Function(
+  Object error,
+  StackTrace stackTrace,
+);
+
+const _defaultHydrationErrorBehavior = HydrationErrorBehavior.overwrite;
+
+HydrationErrorBehavior _defaultOnHydrationError(
+  Object error,
+  StackTrace stackTrace,
+) {
+  return _defaultHydrationErrorBehavior;
+}
+
 /// {@template hydrated_bloc}
 /// Specialized [Bloc] which handles initializing the [Bloc] state
 /// based on the persisted state. This allows state to be persisted
@@ -102,9 +130,13 @@ abstract class HydratedCubit<State> extends Cubit<State>
 ///
 /// * [HydratedBloc] to enable automatic state persistence/restoration with [Bloc]
 /// * [HydratedCubit] to enable automatic state persistence/restoration with [Cubit]
+/// * [HydrationErrorBehavior] to customize state persistence during hydration
+///   errors.
 ///
 mixin HydratedMixin<State> on BlocBase<State> {
   late final Storage __storage;
+  HydrationErrorBehavior? _errorBehavior;
+  var _onErrorCallbackInProgress = false;
 
   /// Populates the internal state storage with the latest state.
   /// This should be called when using the [HydratedMixin]
@@ -118,23 +150,53 @@ mixin HydratedMixin<State> on BlocBase<State> {
   ///  ...
   /// }
   /// ```
-  void hydrate({Storage? storage}) {
+  ///
+  /// Optionally, pass a custom `onError` callback to handle hydration errors:
+  ///
+  /// ```dart
+  /// class CounterBloc extends Bloc<CounterEvent, int> with HydratedMixin {
+  ///  CounterBloc() : super(0) {
+  ///    hydrate(
+  ///      onError: (error, stackTrace) {
+  ///        // Do something in response to hydration errors.
+  ///        // Must return a `HydrationErrorBehavior` to specify whether subsequent
+  ///        // state changes should be persisted.
+  ///        return HydrationErrorBehavior.retain; // Retain the previous state.
+  ///      }
+  ///    );
+  ///  }
+  ///  ...
+  /// }
+  /// ```
+  void hydrate({
+    Storage? storage,
+    OnHydrationError onError = _defaultOnHydrationError,
+  }) {
     __storage = storage ??= HydratedBloc.storage;
     try {
       final stateJson = __storage.read(storageToken) as Map<dynamic, dynamic>?;
       _state = stateJson != null ? _fromJson(stateJson) : super.state;
+      _errorBehavior = null;
     } catch (error, stackTrace) {
-      onError(error, stackTrace);
+      this.onError(error, stackTrace);
       _state = super.state;
+      _onErrorCallbackInProgress = true;
+      _errorBehavior = onError(error, stackTrace);
+    } finally {
+      _onErrorCallbackInProgress = false;
     }
 
+    if (_errorBehavior == HydrationErrorBehavior.retain) return;
+
     try {
       final stateJson = _toJson(state);
       if (stateJson != null) {
-        __storage.write(storageToken, stateJson).then((_) {}, onError: onError);
+        __storage
+            .write(storageToken, stateJson)
+            .then((_) {}, onError: this.onError);
       }
     } catch (error, stackTrace) {
-      onError(error, stackTrace);
+      this.onError(error, stackTrace);
       if (error is StorageNotFound) rethrow;
     }
   }
@@ -148,6 +210,11 @@ mixin HydratedMixin<State> on BlocBase<State> {
   void onChange(Change<State> change) {
     super.onChange(change);
     final state = change.nextState;
+    _state = state;
+
+    if (_onErrorCallbackInProgress) return;
+    if (_errorBehavior == HydrationErrorBehavior.retain) return;
+
     try {
       final stateJson = _toJson(state);
       if (stateJson != null) {
@@ -157,7 +224,6 @@ mixin HydratedMixin<State> on BlocBase<State> {
       onError(error, stackTrace);
       rethrow;
     }
-    _state = state;
   }
 
   State? _fromJson(dynamic json) {


Test patch:
diff --git a/packages/hydrated_bloc/test/hydrated_mixin_test.dart b/packages/hydrated_bloc/test/hydrated_mixin_test.dart
new file mode 100644
index 00000000000..6e7b5a92bf1
--- /dev/null
+++ b/packages/hydrated_bloc/test/hydrated_mixin_test.dart
@@ -0,0 +1,144 @@
+import 'package:hydrated_bloc/hydrated_bloc.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:test/test.dart';
+
+class MockStorage extends Mock implements Storage {}
+
+class MyCubit extends Cubit<int> with HydratedMixin<int> {
+  MyCubit() : super(0);
+
+  @override
+  int fromJson(Map<String, dynamic> json) => json['value'] as int;
+
+  @override
+  Map<String, dynamic> toJson(int state) => {'value': state};
+}
+
+void main() {
+  group('HydratedMixin', () {
+    late Storage storage;
+
+    setUp(() {
+      storage = MockStorage();
+    });
+
+    group('hydrate', () {
+      test('restores state from cache', () {
+        final cubit = MyCubit();
+        when(() => storage.read('MyCubit')).thenReturn({'value': 42});
+        cubit.hydrate(storage: storage);
+        expect(cubit.state, 42);
+      });
+
+      test('uses initial state on error by default', () {
+        when(() => storage.read('MyCubit')).thenReturn({'value': '42'});
+        final cubit = MyCubit()..hydrate(storage: storage);
+        expect(cubit.state, 0);
+        verify(() => storage.write('MyCubit', {'value': 0})).called(1);
+      });
+
+      group('onError', () {
+        test('is called when an exception occurs during hydration', () {
+          final cubit = MyCubit();
+          var onErrorCallCount = 0;
+
+          when(() => storage.read('MyCubit')).thenReturn({'value': '42'});
+
+          cubit.hydrate(
+            storage: storage,
+            onError: (_, __) {
+              onErrorCallCount++;
+              return HydrationErrorBehavior.overwrite;
+            },
+          );
+          expect(onErrorCallCount, equals(1));
+        });
+
+        group('when error behavior is HydrationErrorBehavior.overwrite', () {
+          test('storage.write is always called', () {
+            final cubit = MyCubit();
+            when(() => storage.read('MyCubit')).thenReturn({'value': '42'});
+            cubit.hydrate(
+              storage: storage,
+              onError: (_, __) => HydrationErrorBehavior.overwrite,
+            );
+            expect(cubit.state, 0);
+            verify(() => storage.write('MyCubit', {'value': 0})).called(1);
+          });
+
+          test('states emitted in onError are persisted', () {
+            final cubit = MyCubit();
+            when(() => storage.read('MyCubit')).thenReturn({'value': '42'});
+
+            cubit.hydrate(
+              storage: storage,
+              onError: (_, __) {
+                cubit.emit(-1);
+                return HydrationErrorBehavior.overwrite;
+              },
+            );
+            expect(cubit.state, -1);
+            verify(() => storage.write('MyCubit', {'value': -1})).called(1);
+          });
+        });
+
+        group('when error behavior is HydrationErrorBehavior.retain', () {
+          test('storage.write is never called', () {
+            final cubit = MyCubit();
+            when(() => storage.read('MyCubit')).thenReturn({'value': '42'});
+            cubit.hydrate(
+              storage: storage,
+              onError: (_, __) => HydrationErrorBehavior.retain,
+            );
+            expect(cubit.state, 0);
+            verifyNever(() => storage.write(any(), any<dynamic>()));
+          });
+
+          test('states emitted in onError are not persisted', () {
+            final cubit = MyCubit();
+            when(() => storage.read('MyCubit')).thenReturn({'value': '42'});
+
+            cubit.hydrate(
+              storage: storage,
+              onError: (_, __) {
+                cubit.emit(-1);
+                return HydrationErrorBehavior.retain;
+              },
+            );
+
+            expect(cubit.state, -1);
+            verifyNever(() => storage.write(any(), any<dynamic>()));
+          });
+
+          test('state changes are not persisted until hydration succeeds', () {
+            when(() => storage.read('MyCubit')).thenReturn({'value': '42'});
+            var cubit = MyCubit();
+
+            cubit
+              ..hydrate(
+                storage: storage,
+                onError: (_, __) {
+                  cubit.emit(-1);
+                  return HydrationErrorBehavior.retain;
+                },
+              )
+              ..emit(10);
+
+            expect(cubit.state, 10);
+            verifyNever(() => storage.write(any(), any<dynamic>()));
+
+            when(() => storage.read('MyCubit')).thenReturn({'value': 42});
+            cubit = MyCubit()
+              ..hydrate(
+                storage: storage,
+                onError: (_, __) => HydrationErrorBehavior.retain,
+              );
+
+            expect(cubit.state, 42);
+            verify(() => storage.write('MyCubit', {'value': 42})).called(1);
+          });
+        });
+      });
+    });
+  });
+}