Bloc Lifecycle Management - Design Document
This document outlines the replacement of the current BlocScope LRU-based caching with a lifecycle-aware resolution system.
Problem Statement
The current BlocScope uses LRU caching which is the wrong abstraction for bloc management:
- LRU is memory-pressure heuristics - it evicts based on access patterns
- Lifecycle is semantic correctness - blocs should live/die based on their purpose
Juice blocs are “building blocks” that hold streams, in-flight use cases, websockets, caches, etc. They need explicit lifecycle management, not cache eviction.
Normative Rules (MUST/SHOULD)
These are the core invariants that prevent misuse:
Resolution Rules
| Lifecycle | get() | lease() |
|---|---|---|
permanent | ALLOWED | ALLOWED (but unnecessary) |
feature | ALLOWED | ALLOWED |
leased | MUST throw in debug, warn in release | REQUIRED |
Rule: Any code that holds a bloc reference beyond the current synchronous call stack MUST hold a lease.
Registration Rules
- Re-registration MUST match - Registering the same
BlocIdwith different factory or lifecycle MUST throw. - Implicit registration - When a widget registers a bloc on-demand, it SHOULD log in debug mode.
Closing Rules
- No instance while closing - If
closingFuture != null,get()andlease()MUST await completion before creating new instance. - close() is idempotent - Multiple calls to
close()MUST be safe and return the same Future. - Lease release during close - Releasing a lease while bloc is closing MUST be a no-op (already closing).
Lease Rules
- Increment on mount - Lease count increases in
initState()or equivalent. - Decrement on dispose - Lease count decreases in
dispose()or equivalent. - Never in build() - Lease operations MUST NOT occur in
build()methods. - Cross-bloc leases - EventSubscription, StateRelay, StatusRelay, and any cross-bloc dependency MUST hold leases.
Core Concepts
1. Lifecycle Enum
enum BlocLifecycle {
/// Lives for entire app lifetime. Never auto-disposed.
/// Use for: AuthBloc, SettingsBloc, AppBloc, ThemeBloc
permanent,
/// Lives for a feature/flow. Disposed when FeatureScope ends.
/// Use for: CheckoutBloc, OnboardingBloc, WizardBloc
feature,
/// Lives while leases exist. Auto-disposed when last lease releases.
/// Use for: FormBloc, SearchBloc, ItemDetailBloc
leased,
}
2. Scope Keys
Multiple instances of the same bloc type must coexist safely:
- Two chat threads open → two
ChatBlocinstances - Nested forms → two
FormBlocinstances - List items with blocs → N
ItemBlocinstances
Solution: Key blocs by (Type, scopeKey) tuple.
// Same type, different instances
final chat1 = BlocScope.get<ChatBloc>(scope: 'thread-123');
final chat2 = BlocScope.get<ChatBloc>(scope: 'thread-456');
Scope Key Identity vs Equality
Scope keys use Dart’s standard == operator for comparison. This means:
- Strings/ints: Equal by value (
'thread-123' == 'thread-123') - FeatureScope objects: Equal by identity (two
FeatureScope('checkout')are different)
Guidelines:
| Use Case | Recommended Key Type |
|---|---|
| Feature flows | FeatureScope object (identity) |
| Item-scoped (list items) | String/int ID |
| Route-scoped | Route name or path string |
Warning: Custom objects with overridden == may cause unexpected aliasing. Prefer primitives or FeatureScope for clarity.
3. Leases (Reference Counting)
A lease represents active usage of a bloc. Anything that depends on a bloc instance must:
- Acquire a lease on mount/init
- Release the lease on dispose/cleanup
This includes:
- UI widgets
- Cross-bloc subscriptions (EventSubscription, StateRelay, StatusRelay)
- Any other dependent code
Critical rule: Increment on mount, decrement on dispose — never in build().
Data Model
BlocId
class BlocId {
final Type type;
final Object scopeKey;
const BlocId(this.type, [this.scopeKey = const _GlobalScope()]);
@override
bool operator ==(Object other) =>
other is BlocId && other.type == type && other.scopeKey == scopeKey;
@override
int get hashCode => Object.hash(type, scopeKey);
}
class _GlobalScope {
const _GlobalScope();
}
BlocEntry
class _BlocEntry<T extends JuiceBloc> {
final T Function() factory;
final BlocLifecycle lifecycle;
T? instance;
int leaseCount = 0;
Future<void>? closingFuture; // Prevents double-close race
DateTime? createdAt;
bool get isActive => instance != null && closingFuture == null;
bool get isClosing => closingFuture != null;
}
API Design
BlocScope (Revised)
class BlocScope {
static final Map<BlocId, _BlocEntry> _entries = {};
/// Register a bloc factory with lifecycle behavior.
static void register<T extends JuiceBloc>(
T Function() factory, {
BlocLifecycle lifecycle = BlocLifecycle.permanent,
Object? scope,
});
/// Check if a bloc type is registered.
static bool isRegistered<T extends JuiceBloc>({Object? scope});
/// Get a bloc instance (creates lazily if needed).
/// For leased blocs, prefer using lease() instead.
static T get<T extends JuiceBloc>({Object? scope});
/// Acquire a lease on a bloc. Returns bloc and release function.
/// The bloc will not be disposed while any lease is held.
static BlocLease<T> lease<T extends JuiceBloc>({Object? scope});
/// End a feature-scoped bloc explicitly.
/// Only valid for BlocLifecycle.feature blocs.
static Future<void> end<T extends JuiceBloc>({Object? scope});
/// End all blocs associated with a FeatureScope.
static Future<void> endFeature(FeatureScope scope);
/// Dispose all blocs (app shutdown).
static Future<void> endAll();
}
BlocLease
class BlocLease<T extends JuiceBloc> {
final T bloc;
final void Function() release;
bool _released = false;
BlocLease._(this.bloc, this.release);
/// Release the lease. Safe to call multiple times.
void dispose() {
if (!_released) {
_released = true;
release();
}
}
}
FeatureScope
For features with multiple blocs, use FeatureScope to manage them together:
class FeatureScope {
final String name;
final String _id = _generateId(); // Unique ID for identity
final Set<BlocId> _managedBlocs = {};
bool _ended = false;
FeatureScope([this.name = 'unnamed']);
static String _generateId() => DateTime.now().microsecondsSinceEpoch.toString();
/// Track a bloc type as managed by this scope.
/// Called automatically when registering with this scope.
void _track(Type type) {
if (_ended) throw StateError('FeatureScope "$name" already ended');
_managedBlocs.add(BlocId(type, this));
}
/// End all blocs managed by this scope.
Future<void> end() async {
if (_ended) return;
_ended = true;
await BlocScope.endFeature(this);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is FeatureScope && other._id == _id);
@override
int get hashCode => _id.hashCode;
@override
String toString() => 'FeatureScope($name, $_id)';
}
Usage:
class CheckoutFlow {
final scope = FeatureScope('checkout');
void start() {
// All these blocs share this scope's lifecycle
BlocScope.register<CartBloc>(() => CartBloc(),
lifecycle: BlocLifecycle.feature, scope: scope);
BlocScope.register<PaymentBloc>(() => PaymentBloc(),
lifecycle: BlocLifecycle.feature, scope: scope);
BlocScope.register<ShippingBloc>(() => ShippingBloc(),
lifecycle: BlocLifecycle.feature, scope: scope);
}
Future<void> complete() async {
// Disposes CartBloc, PaymentBloc, ShippingBloc
await scope.end();
}
}
Widget Integration
StatelessJuiceWidget Changes
Widgets acquire leases internally using a stateful wrapper:
abstract class StatelessJuiceWidget<TBloc extends JuiceBloc>
extends StatelessWidget {
StatelessJuiceWidget({
super.key, // Let Flutter handle keys normally
this.groups = const {"*"},
this.scope, // Optional scope key
this.create, // Optional factory for unregistered blocs
this.lifecycle, // Lifecycle if creating
});
final Set<String> groups;
final Object? scope;
final TBloc Function()? create;
final BlocLifecycle? lifecycle;
@override
Widget build(BuildContext context) {
return _BlocLeaseHolder<TBloc>(
scope: scope,
create: create,
lifecycle: lifecycle ?? BlocLifecycle.leased,
builder: (bloc) => _buildWithBloc(context, bloc),
);
}
Widget _buildWithBloc(BuildContext context, TBloc bloc) {
return JuiceAsyncBuilder<StreamStatus>(
stream: bloc.stream.where((status) => _shouldRebuild(status)),
initial: bloc.currentStatus,
builder: (context, status) => onBuild(context, status),
// ...
);
}
}
_BlocLeaseHolder (Internal StatefulWidget)
class _BlocLeaseHolder<TBloc extends JuiceBloc> extends StatefulWidget {
final Object? scope;
final TBloc Function()? create;
final BlocLifecycle lifecycle;
final Widget Function(TBloc bloc) builder;
const _BlocLeaseHolder({
required this.builder,
required this.lifecycle,
this.scope,
this.create,
super.key,
});
@override
State<_BlocLeaseHolder<TBloc>> createState() => _BlocLeaseHolderState<TBloc>();
}
class _BlocLeaseHolderState<TBloc extends JuiceBloc>
extends State<_BlocLeaseHolder<TBloc>> {
BlocLease<TBloc>? _lease;
@override
void initState() {
super.initState();
_acquireLease();
}
void _acquireLease() {
final scope = widget.scope;
// Register if needed (for widget-declared blocs)
if (!BlocScope.isRegistered<TBloc>(scope: scope)) {
if (widget.create != null) {
// Log implicit registration in debug mode
assert(() {
debugPrint(
'BlocScope: Widget implicitly registering $TBloc '
'(scope: $scope, lifecycle: ${widget.lifecycle})'
);
return true;
}());
BlocScope.register<TBloc>(
widget.create!,
lifecycle: widget.lifecycle,
scope: scope,
);
} else {
throw StateError(
'Bloc $TBloc not registered (scope: $scope) '
'and no create factory provided'
);
}
} else {
// Validate that existing registration matches expected lifecycle
// This prevents subtle bugs where different widgets expect different lifecycles
BlocScope.validateRegistration<TBloc>(
scope: scope,
expectedLifecycle: widget.lifecycle,
);
}
_lease = BlocScope.lease<TBloc>(scope: scope);
}
@override
void dispose() {
_lease?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.builder(_lease!.bloc);
}
}
Cross-Bloc Dependencies
EventSubscription, StateRelay, and StatusRelay must also hold leases:
class EventSubscription<TSourceBloc, TSourceEvent, TLocalEvent>
implements UseCaseBuilderBase {
BlocLease<TSourceBloc>? _sourceLease;
void _initialize() {
// Acquire lease instead of just resolving
_sourceLease = BlocScope.lease<TSourceBloc>();
_sourceBloc = _sourceLease!.bloc;
_setupSubscription();
}
@override
Future<void> close() async {
await _subscription?.cancel();
_sourceLease?.dispose(); // Release the lease
// ...
}
}
Invariants
These must always hold:
- permanent blocs ignore lease counts and never auto-dispose
- leased blocs dispose only when
leaseCountreaches 0 - feature blocs require explicit
end()orFeatureScope.end() - close() is idempotent and awaitable
- No new instance created while previous is closing (
closingFutureguard) - Lease increment on mount, decrement on dispose — never in build()
Async Close Race Prevention
Closing Logic
static Future<void> _closeEntry(BlocId id) async {
final entry = _entries[id];
if (entry == null || entry.instance == null) return;
// Already closing? Wait for it.
if (entry.closingFuture != null) {
await entry.closingFuture;
return;
}
// Start close
final bloc = entry.instance!;
entry.closingFuture = bloc.close();
await entry.closingFuture;
// Only clear after close completes
entry.instance = null;
entry.closingFuture = null;
entry.leaseCount = 0;
}
Lease During Close
When lease() is called while a bloc is closing:
static Future<BlocLease<T>> lease<T extends JuiceBloc>({Object? scope}) async {
final id = BlocId(T, scope ?? const _GlobalScope());
final entry = _entries[id];
if (entry == null) {
throw StateError('Bloc $T not registered');
}
// If closing, wait for close to complete
if (entry.closingFuture != null) {
await entry.closingFuture;
// After close, instance is null - create fresh
}
// Create instance if needed
if (entry.instance == null) {
entry.instance = entry.factory() as T;
entry.createdAt = DateTime.now();
}
entry.leaseCount++;
return BlocLease._(
entry.instance as T,
() => _releaseLease(id),
);
}
State Diagram
┌─────────────────┐
│ NOT_CREATED │
└────────┬────────┘
│ lease() or get()
▼
┌─────────────────┐
┌────────►│ ACTIVE │◄────────┐
│ └────────┬────────┘ │
│ │ last lease │ lease()
│ │ released │ (after close)
│ ▼ │
│ ┌─────────────────┐ │
│ │ CLOSING │─────────┘
│ └────────┬────────┘
│ │ close() completes
│ ▼
│ ┌─────────────────┐
└─────────│ NOT_CREATED │
└─────────────────┘
Migration Path
Phase 1: Add New API
- Add
BlocLifecycleenum - Add
BlocLeaseclass - Add
FeatureScopeclass - Add
lease()method to BlocScope - Keep existing
get()working
Phase 2: Update Widgets
- Add
_BlocLeaseHolderinternal widget - Update
StatelessJuiceWidgetto use leases - Update
StatelessJuiceWidget2,StatelessJuiceWidget3
Phase 3: Update Cross-Bloc
- Update
EventSubscriptionto use leases - Update
StateRelayandStatusRelayto use leases
Phase 4: Remove Legacy
- Remove LRU cache code
- Remove
singletonparameter fromget() - Update documentation
Example Usage
App-Level Blocs (permanent)
void main() {
// Register permanent blocs at startup
BlocScope.register<AuthBloc>(() => AuthBloc(),
lifecycle: BlocLifecycle.permanent);
BlocScope.register<SettingsBloc>(() => SettingsBloc(),
lifecycle: BlocLifecycle.permanent);
runApp(MyApp());
}
// Widget just declares dependency - no create needed
class ProfilePage extends StatelessJuiceWidget<AuthBloc> {
ProfilePage() : super(groups: {'profile'});
@override
Widget onBuild(BuildContext context, StreamStatus status) {
return Text('Welcome ${bloc.state.userName}');
}
}
Feature-Level Blocs (feature)
class CheckoutFeature {
final scope = FeatureScope('checkout');
void enter() {
BlocScope.register<CartBloc>(() => CartBloc(),
lifecycle: BlocLifecycle.feature, scope: scope);
}
Future<void> exit() => scope.end();
}
// In navigation
void onCheckoutPressed() {
checkoutFeature.enter();
navigator.push(CheckoutPage());
}
void onCheckoutComplete() {
checkoutFeature.exit();
navigator.pop();
}
Widget-Level Blocs (leased)
// Widget declares bloc with factory - created on demand,
// disposed when last widget using it unmounts
class SearchPage extends StatelessJuiceWidget<SearchBloc> {
SearchPage() : super(
groups: {'results'},
create: () => SearchBloc(),
lifecycle: BlocLifecycle.leased,
);
@override
Widget onBuild(BuildContext context, StreamStatus status) {
return SearchResults(results: bloc.state.results);
}
}
Scoped Instances (same type, different instances)
// Chat list - each item has its own ChatBloc instance
class ChatListItem extends StatelessJuiceWidget<ChatBloc> {
ChatListItem({required this.threadId}) : super(
scope: threadId, // Scope key
create: () => ChatBloc(),
lifecycle: BlocLifecycle.leased,
);
final String threadId;
@override
Widget onBuild(BuildContext context, StreamStatus status) {
return ListTile(
title: Text(bloc.state.lastMessage),
);
}
}
Diagnostics API
For debugging and monitoring bloc lifecycle in development:
class BlocScope {
/// Dump all registered blocs and their state (debug only)
static void debugDump() {
assert(() {
debugPrint('=== BlocScope Debug Dump ===');
for (final entry in _entries.entries) {
final id = entry.key;
final data = entry.value;
debugPrint('''
${id.type} (scope: ${id.scopeKey})
lifecycle: ${data.lifecycle}
instance: ${data.instance != null ? 'active' : 'null'}
leaseCount: ${data.leaseCount}
isClosing: ${data.closingFuture != null}
createdAt: ${data.createdAt}
''');
}
debugPrint('=== End Dump ===');
return true;
}());
}
/// Get diagnostic info for a specific bloc
static BlocDiagnostics? diagnostics<T extends JuiceBloc>({Object? scope}) {
final id = BlocId(T, scope ?? const _GlobalScope());
final entry = _entries[id];
if (entry == null) return null;
return BlocDiagnostics(
type: T,
scope: scope,
lifecycle: entry.lifecycle,
isActive: entry.instance != null,
leaseCount: entry.leaseCount,
isClosing: entry.closingFuture != null,
createdAt: entry.createdAt,
);
}
}
class BlocDiagnostics {
final Type type;
final Object? scope;
final BlocLifecycle lifecycle;
final bool isActive;
final int leaseCount;
final bool isClosing;
final DateTime? createdAt;
const BlocDiagnostics({
required this.type,
required this.scope,
required this.lifecycle,
required this.isActive,
required this.leaseCount,
required this.isClosing,
required this.createdAt,
});
}
Leak Detection (Debug Mode)
On app shutdown, assert that no blocs are leaked:
class BlocScope {
/// Dispose all blocs and check for leaks (app shutdown)
static Future<void> endAll() async {
// Close all blocs
final futures = <Future<void>>[];
for (final entry in _entries.entries) {
if (entry.value.instance != null) {
futures.add(_closeEntry(entry.key));
}
}
await Future.wait(futures);
// Leak detection in debug mode
assert(() {
final leaks = <String>[];
for (final entry in _entries.entries) {
final data = entry.value;
// Leased blocs should have 0 leases at shutdown
if (data.lifecycle == BlocLifecycle.leased && data.leaseCount > 0) {
leaks.add(
'LEAK: ${entry.key.type} has ${data.leaseCount} unreleased leases'
);
}
// Feature blocs should be ended before shutdown
if (data.lifecycle == BlocLifecycle.feature && data.instance != null) {
leaks.add(
'LEAK: Feature bloc ${entry.key.type} was not ended before shutdown'
);
}
}
if (leaks.isNotEmpty) {
debugPrint('=== BlocScope Leak Detection ===');
for (final leak in leaks) {
debugPrint(leak);
}
debugPrint('================================');
// Optionally throw in debug to catch leaks early
// throw StateError('Bloc leaks detected');
}
return true;
}());
_entries.clear();
}
}
FeatureScope Leak Detection
class FeatureScope {
// Track all active feature scopes for leak detection
static final Set<FeatureScope> _activeScopes = {};
FeatureScope([this.name = 'unnamed']) {
assert(() {
_activeScopes.add(this);
return true;
}());
}
Future<void> end() async {
if (_ended) return;
_ended = true;
assert(() {
_activeScopes.remove(this);
return true;
}());
await BlocScope.endFeature(this);
}
/// Check for un-ended feature scopes (call before app shutdown)
static void debugCheckLeaks() {
assert(() {
if (_activeScopes.isNotEmpty) {
debugPrint('=== FeatureScope Leak Detection ===');
for (final scope in _activeScopes) {
debugPrint('LEAK: FeatureScope "${scope.name}" was never ended');
}
debugPrint('====================================');
}
return true;
}());
}
}
Summary
| Aspect | LRU (Old) | Lifecycle (New) |
|---|---|---|
| Eviction trigger | Access pattern | Semantic lifecycle |
| Multiple instances | Awkward key param | First-class scope keys |
| Widget integration | Implicit | Explicit leases |
| Cross-bloc deps | Not tracked | Lease-based |
| Feature flows | Manual | FeatureScope |
| Close races | Possible | Prevented |
| Leak detection | None | Built-in (debug) |
| Diagnostics | None | debugDump(), diagnostics() |
| Mental model | “Cache” | “Ownership” |