Skip to content

Core Leak Detection & Prevention

FASQ includes built-in leak detection tools to help identify and prevent memory leaks in your Flutter applications. These features are only available in debug mode (kDebugMode) to avoid performance overhead in production builds.

Memory leaks can occur when Query objects are not properly disposed after use. This can happen when:

  • Widgets don’t properly clean up queries on disposal
  • Listeners are added but never removed
  • Queries are kept alive by circular references

FASQ’s leak detection system provides:

  • Debug Instrumentation: Automatic tracking of query creation and reference holders
  • Leak Detection Tools: Utilities to identify leaked queries in tests
  • Detailed Error Messages: Stack traces showing where leaks originate

Every Query instance in debug mode automatically captures:

  • Creation Stack Trace: Where the query was created
  • Reference Holders: What objects are keeping the query alive (listeners)

This information is exposed through the debugInfo getter:

final query = client.getQuery<String>('user', () async => fetchUser());
final debugInfo = query.debugInfo;
if (debugInfo != null) {
print('Created at: ${debugInfo.creationStack}');
print('Held by: ${debugInfo.referenceHolders.keys}');
}

The QueryClient provides access to debug information for all active queries:

final client = QueryClient();
final debugInfos = client.activeQueryDebugInfo;
for (final info in debugInfos) {
print('Query created at: ${info.creationStack}');
print('Held by: ${info.referenceHolders.keys}');
}

Or get a map of query keys to debug info:

final debugInfoMap = client.activeQueryDebugInfoMap;
for (final entry in debugInfoMap.entries) {
print('Query ${entry.key}:');
print(' Created at: ${entry.value.creationStack}');
print(' Held by: ${entry.value.referenceHolders.keys}');
}

The LeakDetector class provides utilities for detecting memory leaks in tests.

import 'package:fasq/fasq.dart';
import 'package:fasq/src/testing/leak_detector.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('My Tests', () {
late QueryClient client;
late LeakDetector detector;
setUp(() {
client = QueryClient();
detector = LeakDetector();
});
tearDown(() async {
// Check for leaks after each test
detector.expectNoLeakedQueries(client);
await QueryClient.resetForTesting();
});
test('my test', () {
final query = client.getQuery<String>('test', () async => 'data');
query.addListener();
// ... test code ...
query.removeListener(); // Important: clean up!
});
});
}

The primary method for leak detection. It throws a TestFailure if any queries are still active:

final detector = LeakDetector();
final client = QueryClient();
// Create and use a query
final query = client.getQuery<String>('user', () async => fetchUser());
query.addListener();
// ... use the query ...
// Clean up
query.removeListener();
// Verify no leaks
detector.expectNoLeakedQueries(client); // ✅ Passes

If a leak is detected, you’ll get a detailed error message:

Found 1 leaked query(ies):
Query: user
──────────────────────────────────────────────────
Created at:
#0 Query.new (package:fasq/src/core/query.dart:123:45)
#1 QueryClient.getQuery (package:fasq/src/core/query_client.dart:282:15)
#2 main.<anonymous closure> (test/my_test.dart:15:20)
Held by 1 reference holder(s):
- test-widget
Stack trace:
#0 Query.addListener (package:fasq/src/core/query.dart:340:12)
#1 main.<anonymous closure> (test/my_test.dart:16:20)
... (5 more lines)

Sometimes you intentionally want to keep queries alive across tests. Use allowedLeakKeys:

final persistentQuery = client.getQuery<String>(
'persistent-data',
() async => fetchPersistentData(),
);
detector.expectNoLeakedQueries(
client,
allowedLeakKeys: {'persistent-data'}, // This query is allowed
);

For advanced use cases, you can track arbitrary objects to verify they’re garbage collected:

final detector = LeakDetector();
final query = client.getQuery<String>('test', () async => 'data');
// Track the query for GC
detector.trackForGc(query, debugLabel: 'test-query');
// Use and dispose the query
query.addListener();
query.removeListener();
query.dispose();
// Make query unreachable
query = null;
// Wait for GC and verify
final allGc = await detector.verifyAllTrackedObjectsGc();
expect(allGc, isTrue);

For widget tests, ensure queries are properly disposed after widget teardown:

testWidgets('widget test with leak detection', (tester) async {
final detector = LeakDetector();
final client = QueryClient();
await tester.pumpWidget(
MaterialApp(
home: QueryBuilder<String>(
queryKey: 'test'.toQueryKey(),
queryFn: () async => 'data',
builder: (context, state) => Text(state.data ?? 'loading'),
),
),
);
await tester.pumpAndSettle();
// Remove the widget
await tester.pumpWidget(Container());
// Verify no leaks
detector.expectNoLeakedQueries(client);
});
test('my test', () {
final query = client.getQuery<String>('test', () async => 'data');
query.addListener();
// ... test code ...
// Always clean up!
query.removeListener();
query.dispose(); // Or let QueryClient handle it
});
tearDown(() {
detector.expectNoLeakedQueries(client);
});
testWidgets('widget test', (tester) async {
// ... test code ...
await tester.pumpWidget(Container()); // Remove widget
detector.expectNoLeakedQueries(client); // Check after teardown
});

Only use allowedLeakKeys when you have a legitimate reason to keep queries alive:

// Good: Persistent cache that should survive tests
detector.expectNoLeakedQueries(
client,
allowedLeakKeys: {'persistent-cache'},
);
// Bad: Using allowedLeakKeys to hide actual leaks
detector.expectNoLeakedQueries(
client,
allowedLeakKeys: {'leaked-query'}, // Fix the leak instead!
);

All leak detection features are only available in debug mode. In release builds:

  • Query.debugInfo returns null
  • QueryClient.activeQueryDebugInfo returns an empty iterable
  • LeakDetector methods still work but won’t have debug information

This ensures zero performance overhead in production.

If you see this error:

  1. Check the creation stack trace: This shows where the query was created
  2. Check the reference holders: This shows what’s keeping the query alive
  3. Ensure proper cleanup: Make sure you’re calling removeListener() and/or dispose()

If queries aren’t being disposed even after removing listeners:

  1. Check disposal delay: Queries are disposed after a delay (default: 5 seconds) when reference count reaches zero
  2. Use query.dispose(): For immediate disposal in tests
  3. Use client.removeQuery(): To force removal from the registry

If you’re getting false positives:

  1. Wait for disposal delay: Queries aren’t disposed immediately
  2. Check for active listeners: Make sure all listeners are removed
  3. Use allowedLeakKeys: For queries that should remain active
class QueryDebugInfo {
final StackTrace? creationStack;
final Map<Object, StackTrace> referenceHolders;
}
class LeakDetector {
// Track an object for garbage collection
void trackForGc(Object object, {String? debugLabel});
// Verify all tracked objects are GC'd
Future<bool> verifyAllTrackedObjectsGc({Duration timeout});
// Get list of leaked objects
List<String> getLeakedObjects();
// Clear tracking information
void clearTracking();
// Assert no queries are leaked
void expectNoLeakedQueries(
QueryClient client, {
Set<String>? allowedLeakKeys,
});
}
// Get debug info for all active queries
Iterable<QueryDebugInfo> get activeQueryDebugInfo;
// Get map of query keys to debug info
Map<String, QueryDebugInfo> get activeQueryDebugInfoMap;