SWR strategy implementation guide for fast UI responsiveness when fetching data in projects.
Overview#
The SWR strategy handles two scenarios:
| Scenario | Behavior |
|---|---|
| No cache | Show loading state -> Server request -> Display data |
| Cache exists | Display cached data immediately -> Background server request -> Refresh data |
Role by Architecture Layer#
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Page (Widget) โ
โ - BlocBuilder๋ก ์ํ ๋ ๋๋ง โ
โ - ๋ก๋ฉ/์๋ฌ/๋ฐ์ดํฐ ์ํ ์ฒ๋ฆฌ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ add(Event)
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ BLoC โ
โ - Stream ๊ตฌ๋
(emit.forEach) โ
โ - ์บ์ โ emit โ ์๋ฒ โ emit (์ง์ฐ ์๋ต ์ฒ๋ฆฌ) โ
โ - isClosed ์ฒดํฌ ํ์ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ call(params)
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ UseCase (Stream ๊ธฐ๋ฐ) โ
โ - Stream < Either < Failure, T > > ๋ฐํ โ
โ - async* + yield๋ก ์์ฐจ ๋ฐ์ดํฐ ๋ฐฉ์ถ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ getWithCache(params)
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Repository โ
โ - ์บ์ ์กฐํ โ yield (์บ์ ๋ฐ์ดํฐ) โ
โ - ์๋ฒ ์์ฒญ โ yield (์๋ฒ ๋ฐ์ดํฐ) โ
โ - ์บ์ ๊ฐฑ์ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
1. Repository Pattern (Cache + Server Sequential Return)#
Stream-based Repository Method#
// repository_interface.dart
abstract class IBookRepository {
/// SWR pattern: cache -> server sequential return
Stream<Either<Failure, List<Book>>> getBooksWithCache({
required int categoryId,
int? limit,
});
/// Before Future ํจํด (๋จ์ ์๋ฒ ํธ์ถ)
Future<Either<Failure, List<Book>>> getBooks({
required int categoryId,
int? limit,
});
}
Repository Implementation#
// book_repository.dart
class BookRepository implements IBookRepository {
const BookRepository(this._client, this._cacheService);
final ApiClient _client;
final BookCacheService _cacheService;
@override
Stream<Either<Failure, List<Book>>> getBooksWithCache({
required int categoryId,
int? limit,
}) async* {
final cacheKey = 'books_$categoryId';
// 1. Check and emit cached data (if available)
final cachedBooks = _cacheService.get(cacheKey);
if (cachedBooks != null && cachedBooks.isNotEmpty) {
yield right(cachedBooks);
}
// 2. Server request
try {
final serverBooks = await _client.book.getBooks(
categoryId: categoryId,
limit: limit,
);
// 3. Cache refresh
_cacheService.set(cacheKey, serverBooks);
// 4. Emit server data
yield right(serverBooks);
} on Exception catch (error, stackTrace) {
Log.e('๋์ ๋ชฉ๋ก ์กฐํ ์คํจ', error: error, stackTrace: stackTrace);
// Ignore error if cache existed (cached data already displayed)
if (cachedBooks == null || cachedBooks.isEmpty) {
yield left(BookLoadFailure(message: error.toString()));
}
}
}
}
2. UseCase Pattern (Stream-based)#
StreamUseCase Abstract Class#
// stream_usecase.dart
/// Stream ๊ธฐ๋ฐ UseCase ์ธํฐํ์ด์ค (SWR ํจํด์ฉ)
///
/// ์บ์ ๋ฐ์ดํฐ์ ์๋ฒ ๋ฐ์ดํฐ๋ฅผ ์์ฐจ์ ์ผ๋ก ๋ฐฉ์ถํฉ๋๋ค.
abstract class StreamUseCase<T, Params, Repo> {
/// Repository instance
Repo get repo;
/// Execute UseCase (returns Stream)
///
/// On success, sequentially emits data of type [T],
/// on failure, emits error of type [Failure].
Stream<Either<Failure, T>> call(Params param);
}
/// Stream UseCase requiring authentication
abstract class AuthRequiredStreamUseCase<T, Params, Repo>
extends StreamUseCase<T, Params, Repo>
with GlobalAuthMixin {
String get featureName;
Stream<Either<Failure, T>> executeBusinessLogic(Params param);
@override
Stream<Either<Failure, T>> call(Params param) async* {
// Auth check
final isAuthenticated = await checkAuthentication();
if (!isAuthenticated) {
yield left(AuthenticationFailure(message: '$featureName: ์ธ์ฆ์ด ํ์ํฉ๋๋ค.'));
return;
}
// Execute business logic (Stream)
await for (final result in executeBusinessLogic(param)) {
yield result;
}
}
}
UseCase Implementation Example#
// get_books_with_cache_usecase.dart
class GetBooksWithCacheUseCase
implements StreamUseCase<List<Book>, GetBooksParams, IBookRepository> {
const GetBooksWithCacheUseCase();
@override
IBookRepository get repo => getIt<IBookRepository>();
@override
Stream<Either<Failure, List<Book>>> call(GetBooksParams params) {
return repo.getBooksWithCache(
categoryId: params.categoryId,
limit: params.limit,
);
}
}
3. BLoC Pattern (Stream Subscription)#
Using emit.forEach (Recommended)#
// book_bloc.dart
class BookBloc extends Bloc<BookEvent, BookState> {
BookBloc(this._getBooksWithCacheUseCase) : super(const BookState()) {
on<_LoadBooks>(_onLoadBooks);
}
final GetBooksWithCacheUseCase _getBooksWithCacheUseCase;
Future<void> _onLoadBooks(
_LoadBooks event,
Emitter<BookState> emit,
) async {
// Show loading state only when no cache
if (state.books.isEmpty) {
emit(state.copyWith(status: const BookStatusLoading()));
}
// โ
Subscribe to Stream via emit.forEach
await emit.forEach<Either<Failure, List<Book>>>(
_getBooksWithCacheUseCase(
GetBooksParams(categoryId: event.categoryId),
),
onData: (result) {
return result.fold(
(failure) => state.copyWith(
status: BookStatusError(failure.toString()),
),
(books) => state.copyWith(
status: const BookStatusLoaded(),
books: books,
lastUpdated: DateTime.now(),
),
);
},
onError: (error, stackTrace) {
Log.e('๋์ ๋ชฉ๋ก ๋ก๋ ์คํจ', error: error, stackTrace: stackTrace);
return state.copyWith(
status: BookStatusError(error.toString()),
);
},
);
}
}
Manual Stream Subscription (Alternative)#
Future<void> _onLoadBooks(
_LoadBooks event,
Emitter<BookState> emit,
) async {
if (state.books.isEmpty) {
emit(state.copyWith(status: const BookStatusLoading()));
}
// โ
Traverse Stream with await for
await for (final result in _getBooksWithCacheUseCase(
GetBooksParams(categoryId: event.categoryId),
)) {
// โ ๏ธ Required: isClosed check
if (isClosed) return;
emit(result.fold(
(failure) => state.copyWith(
status: BookStatusError(failure.toString()),
),
(books) => state.copyWith(
status: const BookStatusLoaded(),
books: books,
lastUpdated: DateTime.now(),
),
));
}
}
4. State Design (SWR Compatible)#
State Class#
// book_state.dart
@freezed
class BookState with _$BookState {
const factory BookState({
@Default(BookStatusInitial()) BookStatus status,
@Default([]) List<Book> books,
DateTime? lastUpdated, // ์บ์/์๋ฒ ๊ตฌ๋ถ์ฉ
}) = _BookState;
}
@freezed
sealed class BookStatus with _$BookStatus {
const factory BookStatus.initial() = BookStatusInitial;
const factory BookStatus.loading() = BookStatusLoading;
const factory BookStatus.loaded() = BookStatusLoaded;
const factory BookStatus.error(String message) = BookStatusError;
const factory BookStatus.refreshing() = BookStatusRefreshing; // โ
๋ฐฑ๊ทธ๋ผ์ด๋ ๊ฐฑ์ in progress
}
Cache/Server Data Distinction State#
// ๋ ์ ๊ตํ ์ํ ๊ด๋ฆฌ
@freezed
sealed class BookStatus with _$BookStatus {
const factory BookStatus.initial() = BookStatusInitial;
const factory BookStatus.loading() = BookStatusLoading;
const factory BookStatus.cachedData() = BookStatusCachedData; // โ
์บ์ ๋ฐ์ดํฐ ํ์ in progress
const factory BookStatus.refreshing() = BookStatusRefreshing; // โ
๋ฐฑ๊ทธ๋ผ์ด๋ ๊ฐฑ์ in progress
const factory BookStatus.freshData() = BookStatusFreshData; // โ
์๋ฒ ๋ฐ์ดํฐ๋ก ๊ฐฑ์ ๋จ
const factory BookStatus.error(String message) = BookStatusError;
}
5. UI Pattern (Page/Widget)#
Using BlocBuilder#
// book_list_page.dart
class BookListPage extends StatelessWidget {
const BookListPage({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<BookBloc, BookState>(
buildWhen: (prev, curr) =>
prev.status != curr.status ||
prev.books.length != curr.books.length || // โ ๏ธ ๋ฆฌ์คํธ๋ length๋ก ๋น๊ต
prev.lastUpdated != curr.lastUpdated,
builder: (context, state) {
return switch (state.status) {
BookStatusInitial() ||
BookStatusLoading() when state.books.isEmpty =>
const BookListSkeleton(), // ๋ก๋ฉ ์ค์ผ๋ ํค
BookStatusCachedData() ||
BookStatusRefreshing() =>
BookListView(
books: state.books,
isRefreshing: state.status is BookStatusRefreshing,
),
BookStatusLoaded() ||
BookStatusFreshData() =>
BookListView(books: state.books),
BookStatusError(:final message) when state.books.isEmpty =>
ErrorView(message: message),
BookStatusError() =>
BookListView(
books: state.books,
errorMessage: (state.status as BookStatusError).message,
),
_ => BookListView(books: state.books),
};
},
);
}
}
Background Refresh Indicator#
class BookListView extends StatelessWidget {
const BookListView({
required this.books,
this.isRefreshing = false,
this.errorMessage,
super.key,
});
final List<Book> books;
final bool isRefreshing;
final String? errorMessage;
@override
Widget build(BuildContext context) {
return Column(
children: [
// ๋ฐฑ๊ทธ๋ผ์ด๋ ๊ฐฑ์ in progress ํ์
if (isRefreshing)
const LinearProgressIndicator(),
// ์๋ฌ ๋ฉ์์ง (๋ฐ์ดํฐ๋ ์์ง๋ง ๊ฐฑ์ ์คํจ)
if (errorMessage != null)
ErrorBanner(message: errorMessage!),
// ๋ฐ์ดํฐ ๋ชฉ๋ก
Expanded(
child: ListView.builder(
itemCount: books.length,
itemBuilder: (context, index) => BookTile(book: books[index]),
),
),
],
);
}
}
6. Cache Service Pattern#
Memory Cache Service#
// book_cache_service.dart
@singleton
class BookCacheService {
final _cache = <String, List<Book>>{};
final _timestamps = <String, DateTime>{};
/// Cache validity duration (5 minutes)
static const _cacheDuration = Duration(minutes: 5);
List<Book>? get(String key) {
final timestamp = _timestamps[key];
if (timestamp == null) return null;
// Cache expiration check
if (DateTime.now().difference(timestamp) > _cacheDuration) {
_cache.remove(key);
_timestamps.remove(key);
return null;
}
return _cache[key];
}
void set(String key, List<Book> books) {
_cache[key] = books;
_timestamps[key] = DateTime.now();
}
void invalidate(String key) {
_cache.remove(key);
_timestamps.remove(key);
}
void invalidateAll() {
_cache.clear();
_timestamps.clear();
}
}
7. Migration Guide#
Converting from Future-based to Stream-based#
// Before: Future ๊ธฐ๋ฐ
class GetBooksUseCase implements UseCase<List<Book>, GetBooksParams, IBookRepository> {
@override
Future<Either<Failure, List<Book>>> call(GetBooksParams params) async {
return repo.getBooks(categoryId: params.categoryId);
}
}
// After: Stream ๊ธฐ๋ฐ (SWR)
class GetBooksWithCacheUseCase
implements StreamUseCase<List<Book>, GetBooksParams, IBookRepository> {
@override
Stream<Either<Failure, List<Book>>> call(GetBooksParams params) {
return repo.getBooksWithCache(categoryId: params.categoryId);
}
}
BLoC Migration#
// Before: Future ๊ธฐ๋ฐ
Future<void> _onLoadBooks(_LoadBooks event, Emitter<BookState> emit) async {
emit(state.copyWith(status: const BookStatusLoading()));
final result = await _getBooksUseCase(params);
if (isClosed) return;
emit(result.fold(
(failure) => state.copyWith(status: BookStatusError(failure.toString())),
(books) => state.copyWith(status: const BookStatusLoaded(), books: books),
));
}
// After: Stream ๊ธฐ๋ฐ (SWR)
Future<void> _onLoadBooks(_LoadBooks event, Emitter<BookState> emit) async {
if (state.books.isEmpty) {
emit(state.copyWith(status: const BookStatusLoading()));
}
await emit.forEach<Either<Failure, List<Book>>>(
_getBooksWithCacheUseCase(params),
onData: (result) => result.fold(
(failure) => state.copyWith(status: BookStatusError(failure.toString())),
(books) => state.copyWith(
status: const BookStatusLoaded(),
books: books,
lastUpdated: DateTime.now(),
),
),
);
}
8. Anti-patterns (When Consuming SWR Stream in BLoC)#
โ .last on SWR Stream โ Infinite wait (Critical)#
// โ WRONG: SWR Stream is created by SwrStrategyImpl.watchStream() using
// StreamController which is NOT closed until subscription cancel.
// .last awaits Stream close โ handler waits forever โ state never emits.
final result = await _useCases.getBooks(params).last;
Critical bug: SwrStrategyImpl.watchStream() returns a StreamController.stream
that is closed only via onCancel. Stream.last requires close to return a value, so the handler hangs indefinitely and the page stays in loading state forever. Tests may pass because
Stream.value(...) (single-value) auto-closes โ masking the bug.
Real cases:
-
PR #5465 / #5466 โ
_onLoadLikedBooks,_onLoadMoreLikedBooks,_onLoadMoreMyBooks๋์ ๋ชฉ๋ก ๋ฏธํ์ -
PR #5467 โ
console_book_list_blocawait forํจํด,console_book_registration_bloc4๊ณณawait for(handler ๋ฏธ์ข ๋ฃ)
Recovery test pattern (close๋์ง ์๋ stream์์๋ ๋์ ๊ฒ์ฆ):
blocTest<MyBloc, MyState>(
'close๋์ง ์๋ stream์์๋ ์ฒซ emission์ผ๋ก ํ์๋์ด์ผ ํ๋ค',
setUp: () {
final controller = StreamController<Either<Failure, T>>();
addTearDown(controller.close);
controller.add(Right(testData));
when(() => mockUseCase(any())).thenAnswer((_) => controller.stream);
},
// ...
);
โ ๏ธ .first on SWR Stream โ Acceptable when cache invalidation is guaranteed#
// โ ๏ธ Conditional: .first only collects first emission (cache or server)
final result = await _useCases.getUserInfo(const NoParams()).first;
Trade-off: Loses the "background refresh" part of SWR โ only the first emission is consumed. The second emission (network result after cache hit) is missed because the stream is no longer awaited.
When .first is acceptable:
-
Cache invalidation is guaranteed by upstream events (e.g.,
DomainEventBus) โ first emission becomes a fresh network result on cache miss -
Async transform between emissions is required (e.g.,
BookLike โ Bookper item) โemit.forEachcannot await insideonData - Pagination Load More โ see Exception below
When .first is wrong:
- Cache TTL is long and no invalidation channel exists โ user sees stale data until TTL expires
- A simple list fetch where
emit.forEachworks fine
Real cases:
- PR #4585 โ 10 calls in
my_page_blocusing.firstlost background refresh -
PR #5465โ5467 โ
.firstis the correct fix when SWR stream doesn't close (only choice that doesn't hang) -
PR #5471 โ Combined with
DomainEventBuscache invalidation,.firstworks correctly because invalidated cache forces fresh fetch on next emission
โ Direct .listen usage - StreamSubscription management burden#
// โ WRONG: Direct listen inside BLoC -> requires subscription management
final subscription = _useCases.getBooks(params).listen(
(result) => add(BooksUpdated(result)),
);
Problem: Missing subscription cancellation on close() causes memory leaks.
emit.forEach automatically manages subscriptions.
Exception: .listen is appropriate for non-SWR WebSocket/SSE stream subscriptions like real-time chat.
โ await for on SWR Stream โ Handler hangs#
// โ WRONG: SWR Stream doesn't close โ await for loop never exits
await for (final result in _useCases.getBooks(params)) {
if (isClosed) return;
result.fold(...);
}
Problem: Same root cause as .last โ SwrStrategyImpl.watchStream()
doesn't close. await for loop awaits next emission forever, so the BLoC handler never returns. This causes:
- Memory leaks (subscription never cancelled)
- Next event processing delay (depending on transformer, sequential transformer queues new events)
- Repeated work on every emission (e.g., publisher/category lookup runs twice)
Recovery options:
.firstif cache invalidation is guaranteed (preferred, simplest)-
emit.forEachwithrestartable()transformer โ emitterdoneautomatically cancels subscription
Real case: PR #5467 โ console_book_list_bloc._loadBooksFromStream, console_book_registration_bloc
4 handlers
โ
Correct pattern - emit.forEach + restartable()#
// โ
CORRECT: Subscribe to SWR Stream via emit.forEach
on<_LoadBooks>(_onLoadBooks, transformer: restartable());
Future<void> _onLoadBooks(_LoadBooks event, Emitter<State> emit) async {
if (state.books.isEmpty) {
emit(state.copyWith(status: const Loading()));
}
await emit.forEach<Either<Failure, List<Book>>>(
_useCases.getBooks(params),
onData: (result) => result.fold(
(failure) => state.copyWith(status: Error(failure)),
(books) => state.copyWith(status: const Loaded(), books: books),
),
onError: (_, _) => state.copyWith(status: const Error('๋ก๋ ์คํจ')),
);
}
Exception for Pagination "Load More"#
.last is appropriate for Load More (append):
- Since SWR cache key doesn't include offset, cached data is previous page data
- Subscribing with emit.forEach would duplicate-append cache (previous page) + server (new page) data
// โ
Keep .last for Load More
final result = await _useCases.getBooks(
PaginationParams(limit: 20, offset: currentOffset),
).last;
// Append after deduplication
final uniqueNew = newBooks.where((b) => !existingIds.contains(b.id)).toList();
emit(state.copyWith(books: [...state.books, ...uniqueNew]));
9. Cross-Module Mutation Cache Invalidation#
The Problem#
SWR caches are scoped per module. A mutation in one module (e.g., store::PurchaseBookUsecase) often invalidates caches in
other modules (e.g., my_library::purchasedBooks, my_page::userWalletInfo). Without explicit invalidation, the consumer module sees stale data until cache TTL expires.
Example failure scenario:
- User purchases book in
store(10-minCachePolicies.standardTTL) - Within 10 min, user enters
my_library - SWR strategy: cache hit โ no network fetch โ stale data, purchased book not visible
Fix path: invalidate the consumer module's cache when the producer mutation succeeds.
โ Anti-pattern: bootstrap callback registration (accumulates)#
// app/kobic/lib/bootstrap.dart - new mutation pollutes bootstrap
// (ServiceLocator: package/core/lib/src/app/service_locator.dart)
ServiceLocator.instance.registerSingleton<BookLikeChangedCallback>(...);
ServiceLocator.instance.registerSingleton<BookPurchasedCallback>(...);
ServiceLocator.instance.registerSingleton<BookReviewedCallback>(...);
// keeps growing as new mutations are added
| Issue | Why |
|---|---|
| SRP violation | Bootstrap takes responsibility for all domain cache invalidation |
| Low scalability | Every new mutation requires bootstrap edit |
| Low visibility | Reading my_library alone doesn't reveal which mutations it reacts to |
| Tight coupling |
Bootstrap must import all related modules (
my_library
,
my_page
,
store
, โฆ)
|
โ
Recommended: DomainEventBus pattern#
Define domain events in package:core, register a single DomainEventBus in bootstrap, and let each module subscribe to events that affect its own caches.
// package:core - domain events + bus
abstract class DomainEvent {
const DomainEvent();
}
@immutable
final class BookPurchasedEvent extends DomainEvent {
const BookPurchasedEvent({required this.bookId});
final int bookId;
}
abstract class DomainEventBus {
void publish<T extends DomainEvent>(T event);
StreamSubscription<T> subscribe<T extends DomainEvent>(
void Function(T event) handler,
);
Future<void> dispose();
}
class InMemoryDomainEventBus implements DomainEventBus {
// broadcast StreamController + per-handler try/catch isolation
}
// app/kobic/lib/bootstrap.dart - register ONCE, no growth
// (console: app/kobic_console/lib/bootstrap.dart;
// ServiceLocator: package/core/lib/src/app/service_locator.dart)
ServiceLocator.instance.registerSingleton<DomainEventBus>(
InMemoryDomainEventBus(),
);
registerMyLibraryModule();
registerMyPageModule();
// feature/store - publisher
class PurchaseBookUsecase {
Future<void> call(...) async {
if (result.isRight()) {
try {
if (locator.isRegistered<DomainEventBus>()) {
locator.get<DomainEventBus>()
.publish(BookPurchasedEvent(bookId: bookId));
}
} on Object catch (error, stackTrace) {
Log.w('โ ๏ธ event publish failed (isolated)', error: error);
}
}
}
}
// feature/my_library - subscriber owns its cache
void registerMyLibraryModule() {
final eventBus = ServiceLocator.instance.get<DomainEventBus>();
// ignore: avoid-unassigned-stream-subscriptions
eventBus.subscribe<BookPurchasedEvent>((event) {
unawaited(MyLibraryCacheInvalidator.fromDefault().invalidateAfterPurchase());
});
// ignore: avoid-unassigned-stream-subscriptions
eventBus.subscribe<BookLikeChangedEvent>((event) {
unawaited(MyLibraryCacheInvalidator.fromDefault().invalidateAfterToggleLike());
});
}
Why this works#
| Property | Callback pattern | EventBus pattern |
|---|---|---|
| Bootstrap responsibility | All mutation โ module mappings | Register bus once + call module init |
| SRP | โ bootstrap owns all domains | โ each module owns its cache |
| Adding a mutation | Edit bootstrap + module | Module-only change |
| Producer/consumer coupling | Loose via typedef | Loose via event type |
| Visibility | Bootstrap reveals mappings | Module reveals its own subscriptions |
| Failure isolation | Caller's responsibility | Built-in per-handler try/catch |
Implementation tips#
@immutableon event classes โ events should be value objects- Per-handler try/catch in the bus implementation โ one subscriber's exception must not break others or the publisher
-
isRegistered<T>()check in publishers โ register-failure should not cascade into mutation failure unawaited()inside subscribers โ invalidation is fire-and-forget-
avoid-unassigned-stream-subscriptionslint suppression with comment โ global subscriptions persist for app lifetime by design -
abstract, notsealedforDomainEventโ allows feature modules to define their own events without library lock-in -
Test pattern: subscribe โ publish โ
await Future<void>.delayed(Duration.zero)โ assert handler called
Real cases#
-
PR #5471 โ Migrated
BookLikeChangedCallbackandBookPurchasedCallbacktoDomainEventBus -
PR #5469 (superseded by #5471) โ Added
BookPurchasedCallbackfor cross-module invalidation - PR #5027 โ Original
BookLikeChangedCallbackpattern that revealed the SRP issue
10. Checklist#
SWR Implementation Checklist#
- Use
async*+yieldpattern in Repository - UseCase returns
Stream<Either<Failure, T>> - Use
emit.forEachin BLoC for simple list fetch (preferred) -
Apply
restartable()transformer to events usingemit.forEach - Skip loading state when cache data exists
- Specify
onErrorcallback - Add
lastUpdatedor data source distinction field to State
Anti-pattern Avoidance Checklist#
-
NEVER use
.laston SWR Stream (infinite wait โ stream doesn't close) -
NEVER use
await foron SWR Stream (handler hangs โ same root cause) -
Use
.firstonly when (a) cache invalidation is guaranteed, OR (b) async transform is needed inside, OR (c) Pagination Load More -
Test with non-auto-closing
StreamController.broadcast()to verify no infinite wait โStream.value(...)masks the bug
Cross-Module Mutation Checklist#
- Identify which consumer modules' caches are affected by the mutation
- Use
DomainEventBus(NOT bootstrap callback registration) - Define event in
package:corewith@immutable -
Publisher:
eventBus.publish(...)insidetry/catchwithisRegistered<DomainEventBus>()check -
Subscriber: register subscription inside the module's
registerXxxModule() -
Add cache invalidator method (e.g.,
invalidateAfterXxx()) to consumer module - Both L1 (memory) and L2 (disk) invalidation
- Test cache invalidation triggers and produces fresh data on next fetch
11. unawaited() + Error Silent-Failure Pitfall (Critical)#
Background: #7746 โ an "infinite loading" bug found in
SwrStrategyImpl._revalidate(background revalidation). The root cause is not SWR-specific โ it's a Dart language trait combined withunawaited()fire-and-forget โ so it reproduces identically in non-SWR code. See [cc-dev-cycle:discovery-audit] for the codebase-wide audit โ confirm โ parallel-fix workflow used after discovery.
11-1. Why โ Dart's Error does not extend Exception#
TypeError, StateError, NoSuchMethodError, FlutterError,
JsonUnsupportedObjectError, etc. are all Error, not Exception. In practice, almost every catch block conventionally uses
on Exception catch only.
This alone isn't a problem for a normal await-chained call โ an uncaught Error
propagates up and eventually surfaces as a crash or a log. The problem is when this catch chain sits behind
unawaited(someFunction()) (fire-and-forget). There, an Error missed by on Exception
becomes an exception no code path is set up to observe. Whether it also surfaces as a crash/log elsewhere depends on the execution zone (a plain Dart isolate prints "Unhandled exception" and aborts; a Flutter app's guarded zone may route it to
PlatformDispatcher.onError instead, or swallow it depending on what that handler does) โ but regardless of that,
the specific consumer awaiting this result never gets a terminal emission: BLoC/Cubit state, a
ValueNotifier/useState-gated button or spinner, a StreamController
all hang forever waiting for a response that never arrives. Infinite loading spinners, permanently-disabled buttons, and dialogs that never close are the typical symptoms.
11-2. Reconstructed example (SwrStrategyImpl._revalidate pattern)#
// package/cache/lib/src/data/swr_strategy_impl.dart (reconstructed โ illustrates the pattern, not the literal diff)
void onListen() {
// 1. emit cached data immediately
// 2. background revalidate is fire-and-forget โ this is the trap
unawaited(_revalidate(query, controller)); // โ any Error thrown inside disappears completely
}
Future<void> _revalidate(
CacheQuery query,
StreamController<Either<Failure, T>> controller,
) async {
try {
final fresh = await _fetcher(query);
await saveToCache(query.cacheKey, fresh); // may throw an Error internally (e.g. jsonEncode)
if (!controller.isClosed) controller.add(right(fresh));
} on Exception catch (error, stackTrace) { // โ Error (TypeError/JsonUnsupportedObjectError/...) is NOT caught here
Log.e('background revalidate failed', error: error, stackTrace: stackTrace);
if (!controller.isClosed) controller.add(left(NetworkFailure(error.toString())));
}
}
If something inside saveToCache or the response mapping throws an Error (e.g.
JsonUnsupportedObjectError from a non-serializable object, or TypeError from an unsafe
as T cast), it passes straight through on Exception catch and exits _revalidate. Neither
controller.add branch runs, and since the call is unawaited(), nobody awaits the Future to notice the failure. Result: any BLoC subscribed to that
cacheKey never receives a terminal emission and the screen stays stuck in loading state.
11-3. Detection โ grep-able once known#
# candidates โ every production file with a fire-and-forget unawaited( call
grep -rl " unawaited( " --include= " *.dart " feature/ package/ app/ | grep -v _test.dart
# then manually trace the full call chain of each unawaited(fn()) to check
# whether EVERY catch clause in that chain is `on Exception` only (misses Error)
Full automation is hard because the offending catch can live in a function several calls below
the one passed to unawaited() โ the full call chain must be traced. This is why a separate triage (cast a wide net) โ verify (trace the chain, confirm) split matters; see [cc-dev-cycle:discovery-audit].
11-4. Real case#
An audit of all 274 production files containing unawaited( in kobic confirmed 11 independent reproducible instances (including a high-severity full EPUB-reader hang). Two "high" severity candidates were rejected as dead/@Deprecated
code, and one was rejected because its consumer was a BehaviorSubject.seeded() โ it doesn't hang, it just serves stale data until the next update.
11-5. Fix#
// โ
on Object catch covers both Exception and Error
} on Object catch (error, stackTrace) {
Log.e('background revalidate failed', error: error, stackTrace: stackTrace);
if (!controller.isClosed) controller.add(left(NetworkFailure(error.toString())));
}
Any function reached via unawaited() should widen its catch to on Object catch
(or on Exception catch + a parallel on Error catch) โ this is the one place where the usual
on Exception-only convention is not enough.
11-6. Checklist#
-
Before adding a new
unawaited(fn())call, verify the full call chain offn()also catchesError -
The top-level catch of any function reached via
unawaited()ison Object(oron Exception+on Error) - The consumer (BLoC/Cubit/ValueNotifier/StreamController) is guaranteed a terminal emission (success or failure) even on this path
- Same item is mirrored in the code review checklist: [cc-code-quality:code-review-checklist] ยง2 State Management
Related Documents#
- dcm-bloc.md - BLoC pattern rules
- CLAUDE.md - Complete project guide
- auth_required_usecase.dart - Existing UseCase Pattern
-
#7746 -
SwrStrategyImpl._revalidateinfinite-loading fix caused byunawaited()+ missedError - [cc-dev-cycle:discovery-audit] - Workflow used to audit/confirm/parallel-fix this bug pattern across the codebase