diff --git a/lib/features/lists/cubit/my_lists_cubit.dart b/lib/features/lists/cubit/my_lists_cubit.dart index be69a47..0b950d2 100644 --- a/lib/features/lists/cubit/my_lists_cubit.dart +++ b/lib/features/lists/cubit/my_lists_cubit.dart @@ -12,13 +12,18 @@ class MyListsCubit extends Cubit { super(const MyListsState.initial()); final ListRepository _listRepository; + int _requestId = 0; Future load({required String actor, int limit = 50}) async { - emit(MyListsState.loading(actor: actor, limit: limit)); + final requestId = _beginRequest(); + _emitIfOpen(MyListsState.loading(actor: actor, limit: limit)); try { final result = await _listRepository.getLists(actor: actor, limit: limit); - emit( + if (!_isCurrentRequest(requestId)) { + return; + } + _emitIfOpen( MyListsState.loaded( actor: actor, lists: result.lists, @@ -28,30 +33,42 @@ class MyListsCubit extends Cubit { ), ); } catch (error) { - emit(MyListsState.error(message: 'Failed to load lists: $error', actor: actor, limit: limit)); + if (!_isCurrentRequest(requestId)) { + return; + } + _emitIfOpen(MyListsState.error(message: 'Failed to load lists: $error', actor: actor, limit: limit)); } } Future refresh() async { - if (state.actor == null) { + final actor = state.actor; + if (actor == null) { return; } + final limit = state.limit; + final requestId = _beginRequest(); - emit(state.copyWith(isRefreshing: true, errorMessage: null)); + _emitIfOpen(state.copyWith(isRefreshing: true, errorMessage: null)); try { - final result = await _listRepository.getLists(actor: state.actor!, limit: state.limit); - emit( + final result = await _listRepository.getLists(actor: actor, limit: limit); + if (!_isCurrentRequest(requestId)) { + return; + } + _emitIfOpen( MyListsState.loaded( - actor: state.actor!, + actor: actor, lists: result.lists, cursor: result.cursor, hasMore: result.cursor != null, - limit: state.limit, + limit: limit, ), ); } catch (error) { - emit(state.copyWith(isRefreshing: false, errorMessage: 'Failed to refresh lists: $error')); + if (!_isCurrentRequest(requestId)) { + return; + } + _emitIfOpen(state.copyWith(isRefreshing: false, errorMessage: 'Failed to refresh lists: $error')); } } @@ -92,21 +109,47 @@ class MyListsCubit extends Cubit { return; } - emit(state.copyWith(isLoadingMore: true)); + final actor = state.actor!; + final cursor = state.cursor; + final limit = state.limit; + final lists = state.lists; + final requestId = _beginRequest(); + + _emitIfOpen(state.copyWith(isLoadingMore: true)); try { - final result = await _listRepository.getLists(actor: state.actor!, cursor: state.cursor, limit: state.limit); + final result = await _listRepository.getLists(actor: actor, cursor: cursor, limit: limit); + if (!_isCurrentRequest(requestId)) { + return; + } - emit( + _emitIfOpen( state.copyWith( - lists: [...state.lists, ...result.lists], + lists: [...lists, ...result.lists], cursor: result.cursor, hasMore: result.cursor != null, isLoadingMore: false, ), ); } catch (_) { - emit(state.copyWith(isLoadingMore: false, hasMore: false)); + if (!_isCurrentRequest(requestId)) { + return; + } + _emitIfOpen(state.copyWith(isLoadingMore: false, hasMore: false)); + } + } + + int _beginRequest() { + _requestId += 1; + return _requestId; + } + + bool _isCurrentRequest(int requestId) => !isClosed && requestId == _requestId; + + void _emitIfOpen(MyListsState nextState) { + if (isClosed) { + return; } + emit(nextState); } } diff --git a/test/features/lists/cubit/my_lists_cubit_test.dart b/test/features/lists/cubit/my_lists_cubit_test.dart index c355be2..b1de744 100644 --- a/test/features/lists/cubit/my_lists_cubit_test.dart +++ b/test/features/lists/cubit/my_lists_cubit_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bloc_test/bloc_test.dart'; import 'package:atproto_core/atproto_core.dart' show AtUri, BlobRef; import 'package:bluesky/app_bsky_actor_defs.dart'; @@ -211,6 +213,70 @@ void main() { ), ], ); + + test('ignores completed load responses after the cubit is closed', () async { + final cubit = MyListsCubit(listRepository: mockListRepository); + final pendingResult = Completer(); + + when( + () => mockListRepository.getLists( + actor: actor, + cursor: any(named: 'cursor'), + limit: 50, + includeReference: any(named: 'includeReference'), + ), + ).thenAnswer((_) => pendingResult.future); + + final loadFuture = cubit.load(actor: actor); + await Future.delayed(Duration.zero); + await cubit.close(); + pendingResult.complete(ListsResult(lists: [curationList], cursor: null)); + await loadFuture; + + expect(cubit.isClosed, isTrue); + }); + + test('keeps the newest load result when responses arrive out of order', () async { + final cubit = MyListsCubit(listRepository: mockListRepository); + const newerActor = 'did:plc:newer'; + final newerList = _buildList( + uri: AtUri.parse('at://did:plc:newer/app.bsky.graph.list/newer'), + purpose: KnownListPurpose.appBskyGraphDefsCuratelist, + name: 'Newest', + ); + + final firstResult = Completer(); + final secondResult = Completer(); + + when( + () => mockListRepository.getLists( + actor: actor, + cursor: any(named: 'cursor'), + limit: 50, + includeReference: any(named: 'includeReference'), + ), + ).thenAnswer((_) => firstResult.future); + when( + () => mockListRepository.getLists( + actor: newerActor, + cursor: any(named: 'cursor'), + limit: 50, + includeReference: any(named: 'includeReference'), + ), + ).thenAnswer((_) => secondResult.future); + + final olderLoad = cubit.load(actor: actor); + final newerLoad = cubit.load(actor: newerActor); + + secondResult.complete(ListsResult(lists: [newerList], cursor: null)); + await newerLoad; + firstResult.complete(ListsResult(lists: [curationList], cursor: null)); + await olderLoad; + + expect(cubit.state.status, MyListsStatus.loaded); + expect(cubit.state.actor, newerActor); + expect(cubit.state.lists, [newerList]); + }); }); }