diff --git a/packages/hydrated_bloc/lib/src/hydrated_bloc.dart b/packages/hydrated_bloc/lib/src/hydrated_bloc.dart index 85cc5b2ad48..0ad74868fca 100644 --- a/packages/hydrated_bloc/lib/src/hydrated_bloc.dart +++ b/packages/hydrated_bloc/lib/src/hydrated_bloc.dart @@ -324,10 +324,8 @@ mixin HydratedMixin on BlocBase { _checkCycle(object); final map = {}; object.forEach((dynamic key, dynamic value) { - final castKey = _cast(key); - if (castKey != null) { - map[castKey] = _traverseWrite(value).value; - } + final castKey = key?.toString(); + if (castKey != null) map[castKey] = _traverseWrite(value).value; }); _removeSeen(object); return map; diff --git a/packages/hydrated_bloc/test/cubits/cubits.dart b/packages/hydrated_bloc/test/cubits/cubits.dart index f1905d95c97..cf0d258054e 100644 --- a/packages/hydrated_bloc/test/cubits/cubits.dart +++ b/packages/hydrated_bloc/test/cubits/cubits.dart @@ -5,4 +5,5 @@ export 'from_json_state_cubit.dart'; export 'json_serializable_cubit.dart'; export 'list_cubit.dart'; export 'manual_cubit.dart'; +export 'season_palette_cubit.dart'; export 'simple_cubit.dart'; diff --git a/packages/hydrated_bloc/test/cubits/season_palette_cubit.dart b/packages/hydrated_bloc/test/cubits/season_palette_cubit.dart new file mode 100644 index 00000000000..a77d44bd8f0 --- /dev/null +++ b/packages/hydrated_bloc/test/cubits/season_palette_cubit.dart @@ -0,0 +1,91 @@ +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:meta/meta.dart'; + +/// A cubit that has a state which uses int key values in its serialized form. +/// https://github.com/felangel/bloc/issues/3983 +class SeasonPaletteCubit extends HydratedCubit { + SeasonPaletteCubit() : super(const SeasonPalette({})); + + void update(SeasonPalette palette) => emit(palette); + + @override + Map toJson(SeasonPalette state) => state.toJson(); + + @override + SeasonPalette fromJson(Map json) { + return SeasonPalette.fromJson(json); + } +} + +@immutable +class SeasonPalette { + const SeasonPalette(this.colors); + + factory SeasonPalette.fromJson(Map json) { + final raw = json['colors'] as Map? ?? {}; + return SeasonPalette( + raw.map( + (key, value) => MapEntry( + Season.fromJson(int.parse(key)), + value as String, + ), + ), + ); + } + + final Map colors; + + Map toJson() { + return { + 'colors': colors.map( + (key, value) => MapEntry(key.toJson(), value), + ), + }; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! SeasonPalette) return false; + if (colors.length != other.colors.length) return false; + for (final entry in colors.entries) { + if (other.colors[entry.key] != entry.value) return false; + } + return true; + } + + @override + int get hashCode => colors.hashCode; +} + +@immutable +class Season { + const Season._(this.index, this.name); + + static const spring = Season._(0, 'spring'); + static const summer = Season._(1, 'summer'); + static const autumn = Season._(2, 'autumn'); + static const winter = Season._(3, 'winter'); + + static const values = [spring, summer, autumn, winter]; + + final int index; + final String name; + + int toJson() => index; + + static Season fromJson(int value) { + return values.firstWhere((e) => e.index == value); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || other is Season && other.index == index; + } + + @override + int get hashCode => index.hashCode; + + @override + String toString() => 'Season.$name'; +} diff --git a/packages/hydrated_bloc/test/e2e_test.dart b/packages/hydrated_bloc/test/e2e_test.dart index a1816297f28..43a3917e7d4 100644 --- a/packages/hydrated_bloc/test/e2e_test.dart +++ b/packages/hydrated_bloc/test/e2e_test.dart @@ -383,6 +383,24 @@ void main() { }); }); + group('SeasonPaletteCubit', () { + test( + 'persists and restores state ' + 'when serialized state uses non-string keys', () async { + final palette = SeasonPalette({ + Season.spring: 'green', + Season.summer: 'yellow', + Season.autumn: 'orange', + Season.winter: 'white', + }); + final cubit = SeasonPaletteCubit(); + expect(cubit.state, const SeasonPalette({})); + cubit.update(palette); + await sleep(); + expect(SeasonPaletteCubit().state, palette); + }); + }); + group('FromJsonStateCubit', () { test('does not throw StackOverflow ', () async { final fromJsonCalls = []; diff --git a/packages/hydrated_bloc/test/hydrated_cubit_test.dart b/packages/hydrated_bloc/test/hydrated_cubit_test.dart index 421f12f5f34..d9f770105e9 100644 --- a/packages/hydrated_bloc/test/hydrated_cubit_test.dart +++ b/packages/hydrated_bloc/test/hydrated_cubit_test.dart @@ -91,6 +91,21 @@ class MyMultiHydratedCubit extends HydratedCubit { int? fromJson(dynamic json) => json['value'] as int?; } +class MyIntKeyMapCubit extends HydratedCubit> { + MyIntKeyMapCubit() : super(const {}); + + @override + Map toJson(Map state) { + return {'data': state}; + } + + @override + Map fromJson(Map json) { + final raw = json['data'] as Map? ?? {}; + return raw.map((key, value) => MapEntry(int.parse(key), value as String)); + } +} + class MyHydratedCubitWithCustomStorage extends HydratedCubit { MyHydratedCubitWithCustomStorage(Storage storage) : super(0, storage: storage); @@ -396,6 +411,28 @@ void main() { }); }); + group('MyIntKeyMapCubit', () { + test('serializes non-string keys', () { + final cubit = MyIntKeyMapCubit(); + const data = {0: 'a', 1: 'b', 2: 'c'}; + const change = Change(currentState: {}, nextState: data); + cubit.onChange(change); + verify( + () => storage.write('MyIntKeyMapCubit', { + 'data': {'0': 'a', '1': 'b', '2': 'c'}, + }), + ).called(1); + }); + + test('restores state when cache has stringified int keys', () { + when(() => storage.read(any())).thenReturn({ + 'data': {'0': 'a', '1': 'b'}, + }); + final cubit = MyIntKeyMapCubit(); + expect(cubit.state, {0: 'a', 1: 'b'}); + }); + }); + group('MyHydratedCubitWithCustomStorage', () { setUp(() { HydratedBloc.storage = null;