mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: handle request completion and state for current user/self lists

+124 -15
+58 -15
lib/features/lists/cubit/my_lists_cubit.dart
··· 12 12 super(const MyListsState.initial()); 13 13 14 14 final ListRepository _listRepository; 15 + int _requestId = 0; 15 16 16 17 Future<void> load({required String actor, int limit = 50}) async { 17 - emit(MyListsState.loading(actor: actor, limit: limit)); 18 + final requestId = _beginRequest(); 19 + _emitIfOpen(MyListsState.loading(actor: actor, limit: limit)); 18 20 19 21 try { 20 22 final result = await _listRepository.getLists(actor: actor, limit: limit); 21 - emit( 23 + if (!_isCurrentRequest(requestId)) { 24 + return; 25 + } 26 + _emitIfOpen( 22 27 MyListsState.loaded( 23 28 actor: actor, 24 29 lists: result.lists, ··· 28 33 ), 29 34 ); 30 35 } catch (error) { 31 - emit(MyListsState.error(message: 'Failed to load lists: $error', actor: actor, limit: limit)); 36 + if (!_isCurrentRequest(requestId)) { 37 + return; 38 + } 39 + _emitIfOpen(MyListsState.error(message: 'Failed to load lists: $error', actor: actor, limit: limit)); 32 40 } 33 41 } 34 42 35 43 Future<void> refresh() async { 36 - if (state.actor == null) { 44 + final actor = state.actor; 45 + if (actor == null) { 37 46 return; 38 47 } 48 + final limit = state.limit; 49 + final requestId = _beginRequest(); 39 50 40 - emit(state.copyWith(isRefreshing: true, errorMessage: null)); 51 + _emitIfOpen(state.copyWith(isRefreshing: true, errorMessage: null)); 41 52 42 53 try { 43 - final result = await _listRepository.getLists(actor: state.actor!, limit: state.limit); 44 - emit( 54 + final result = await _listRepository.getLists(actor: actor, limit: limit); 55 + if (!_isCurrentRequest(requestId)) { 56 + return; 57 + } 58 + _emitIfOpen( 45 59 MyListsState.loaded( 46 - actor: state.actor!, 60 + actor: actor, 47 61 lists: result.lists, 48 62 cursor: result.cursor, 49 63 hasMore: result.cursor != null, 50 - limit: state.limit, 64 + limit: limit, 51 65 ), 52 66 ); 53 67 } catch (error) { 54 - emit(state.copyWith(isRefreshing: false, errorMessage: 'Failed to refresh lists: $error')); 68 + if (!_isCurrentRequest(requestId)) { 69 + return; 70 + } 71 + _emitIfOpen(state.copyWith(isRefreshing: false, errorMessage: 'Failed to refresh lists: $error')); 55 72 } 56 73 } 57 74 ··· 92 109 return; 93 110 } 94 111 95 - emit(state.copyWith(isLoadingMore: true)); 112 + final actor = state.actor!; 113 + final cursor = state.cursor; 114 + final limit = state.limit; 115 + final lists = state.lists; 116 + final requestId = _beginRequest(); 117 + 118 + _emitIfOpen(state.copyWith(isLoadingMore: true)); 96 119 97 120 try { 98 - final result = await _listRepository.getLists(actor: state.actor!, cursor: state.cursor, limit: state.limit); 121 + final result = await _listRepository.getLists(actor: actor, cursor: cursor, limit: limit); 122 + if (!_isCurrentRequest(requestId)) { 123 + return; 124 + } 99 125 100 - emit( 126 + _emitIfOpen( 101 127 state.copyWith( 102 - lists: [...state.lists, ...result.lists], 128 + lists: [...lists, ...result.lists], 103 129 cursor: result.cursor, 104 130 hasMore: result.cursor != null, 105 131 isLoadingMore: false, 106 132 ), 107 133 ); 108 134 } catch (_) { 109 - emit(state.copyWith(isLoadingMore: false, hasMore: false)); 135 + if (!_isCurrentRequest(requestId)) { 136 + return; 137 + } 138 + _emitIfOpen(state.copyWith(isLoadingMore: false, hasMore: false)); 110 139 } 140 + } 141 + 142 + int _beginRequest() { 143 + _requestId += 1; 144 + return _requestId; 145 + } 146 + 147 + bool _isCurrentRequest(int requestId) => !isClosed && requestId == _requestId; 148 + 149 + void _emitIfOpen(MyListsState nextState) { 150 + if (isClosed) { 151 + return; 152 + } 153 + emit(nextState); 111 154 } 112 155 }
+66
test/features/lists/cubit/my_lists_cubit_test.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:bloc_test/bloc_test.dart'; 2 4 import 'package:atproto_core/atproto_core.dart' show AtUri, BlobRef; 3 5 import 'package:bluesky/app_bsky_actor_defs.dart'; ··· 211 213 ), 212 214 ], 213 215 ); 216 + 217 + test('ignores completed load responses after the cubit is closed', () async { 218 + final cubit = MyListsCubit(listRepository: mockListRepository); 219 + final pendingResult = Completer<ListsResult>(); 220 + 221 + when( 222 + () => mockListRepository.getLists( 223 + actor: actor, 224 + cursor: any(named: 'cursor'), 225 + limit: 50, 226 + includeReference: any(named: 'includeReference'), 227 + ), 228 + ).thenAnswer((_) => pendingResult.future); 229 + 230 + final loadFuture = cubit.load(actor: actor); 231 + await Future<void>.delayed(Duration.zero); 232 + await cubit.close(); 233 + pendingResult.complete(ListsResult(lists: [curationList], cursor: null)); 234 + await loadFuture; 235 + 236 + expect(cubit.isClosed, isTrue); 237 + }); 238 + 239 + test('keeps the newest load result when responses arrive out of order', () async { 240 + final cubit = MyListsCubit(listRepository: mockListRepository); 241 + const newerActor = 'did:plc:newer'; 242 + final newerList = _buildList( 243 + uri: AtUri.parse('at://did:plc:newer/app.bsky.graph.list/newer'), 244 + purpose: KnownListPurpose.appBskyGraphDefsCuratelist, 245 + name: 'Newest', 246 + ); 247 + 248 + final firstResult = Completer<ListsResult>(); 249 + final secondResult = Completer<ListsResult>(); 250 + 251 + when( 252 + () => mockListRepository.getLists( 253 + actor: actor, 254 + cursor: any(named: 'cursor'), 255 + limit: 50, 256 + includeReference: any(named: 'includeReference'), 257 + ), 258 + ).thenAnswer((_) => firstResult.future); 259 + when( 260 + () => mockListRepository.getLists( 261 + actor: newerActor, 262 + cursor: any(named: 'cursor'), 263 + limit: 50, 264 + includeReference: any(named: 'includeReference'), 265 + ), 266 + ).thenAnswer((_) => secondResult.future); 267 + 268 + final olderLoad = cubit.load(actor: actor); 269 + final newerLoad = cubit.load(actor: newerActor); 270 + 271 + secondResult.complete(ListsResult(lists: [newerList], cursor: null)); 272 + await newerLoad; 273 + firstResult.complete(ListsResult(lists: [curationList], cursor: null)); 274 + await olderLoad; 275 + 276 + expect(cubit.state.status, MyListsStatus.loaded); 277 + expect(cubit.state.actor, newerActor); 278 + expect(cubit.state.lists, [newerList]); 279 + }); 214 280 }); 215 281 } 216 282