diff --git a/flutter_client/lib/cache/cache_first.dart b/flutter_client/lib/cache/cache_first.dart index 8d24d11b..274b285a 100644 --- a/flutter_client/lib/cache/cache_first.dart +++ b/flutter_client/lib/cache/cache_first.dart @@ -48,9 +48,22 @@ Stream cacheFirst({ // drift re-emission (otherwise the populate cycles forever). var revalidated = false; + // Tracks whether we've already tried a cold-cache fetch on this + // subscription. Without this, providers can spin forever in the + // rows-empty branch when fetchAndPopulate writes rows that don't + // match THIS filter — e.g. three liked-tab streams sharing one + // populate: the track populate writes track-typed rows, drift + // watch fires for the cached_likes table, the album stream + // re-emits with rows-empty (no album likes), the rows-empty + // branch re-fires populate, repeats forever. Setting this guard + // makes the first fetch attempt also the last for any given + // subscription. + var coldFetchAttempted = false; + await for (final rows in driftStream) { if (rows.isNotEmpty) { yield toResult(rows); + coldFetchAttempted = true; // Stale-while-revalidate: yield cache immediately, then kick off // a REST refresh in the background. Drift watch() picks up the // resulting writes and re-emits via this same stream loop. @@ -62,11 +75,25 @@ Stream cacheFirst({ } continue; } + // rows is empty. If we've already attempted a cold fetch and + // drift is still empty for this filter, yield empty so the UI + // shows the no-content state instead of spinning forever. + if (coldFetchAttempted) { + yield toResult(rows); + continue; + } + coldFetchAttempted = true; if (await isOnline()) { try { await fetchAndPopulate(); - // The drift watch() stream re-emits with the populated rows on - // the next loop iteration. Don't yield here. + // Yield the current (still-empty) rows so the UI moves past + // loading even if populate was a no-op for this filter + // (server returned nothing matching, or all rows were + // already in drift via sync). If populate DID write rows + // matching this filter, the drift watch's next emission + // yields them via the rows.isNotEmpty branch — UI briefly + // shows empty then populates, instead of spinning. + yield toResult(rows); } catch (e, st) { if (tag != null) { debugPrint('cacheFirst[$tag]: fetchAndPopulate failed: $e\n$st'); diff --git a/flutter_client/lib/library/library_screen.dart b/flutter_client/lib/library/library_screen.dart index 6b2c7693..b3e64948 100644 --- a/flutter_client/lib/library/library_screen.dart +++ b/flutter_client/lib/library/library_screen.dart @@ -242,7 +242,9 @@ final _likedTrackIdsProvider = StreamProvider>((ref) { driftStream: query, fetchAndPopulate: () => _populateLikeIds(ref), toResult: _idsForEntity, - isOnline: () async => (await ref.read(connectivityProvider.future)), + isOnline: () async => (await ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true)), alwaysRefresh: true, tag: 'likedTrackIds', ); @@ -258,7 +260,9 @@ final _likedAlbumIdsProvider = StreamProvider>((ref) { driftStream: query, fetchAndPopulate: () => _populateLikeIds(ref), toResult: _idsForEntity, - isOnline: () async => (await ref.read(connectivityProvider.future)), + isOnline: () async => (await ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true)), alwaysRefresh: true, tag: 'likedAlbumIds', ); @@ -274,7 +278,9 @@ final _likedArtistIdsProvider = StreamProvider>((ref) { driftStream: query, fetchAndPopulate: () => _populateLikeIds(ref), toResult: _idsForEntity, - isOnline: () async => (await ref.read(connectivityProvider.future)), + isOnline: () async => (await ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true)), alwaysRefresh: true, tag: 'likedArtistIds', );