The AI bug catalog
Every snippet below was written to look exactly like the code an AI assistant actually produces — fluent, plausible, and often analyzing cleanly — then run for real against a throwaway flutter create scratch project on this Mac (Flutter 3.47.5, Dart 3.13.4), never the course’s own shared probe. The quoted output is copied verbatim from those runs, trimmed for length, not reconstructed from memory. Group the same way as How to read Flutter code you did not write and the Review Checklist that follows this lesson.
State & lifecycle
Section titled “State & lifecycle”1. Forgot ProviderScope
Section titled “1. Forgot ProviderScope”Riverpod’s ref.watch/ref.read need a ProviderContainer to read from. ProviderScope is the widget that creates one and makes it reachable. Skip it, and every ConsumerWidget throws the moment it tries to read a provider.
import 'package:flutter/material.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';
final nameProvider = Provider<String>((ref) => 'Milo');
class NameOnly extends ConsumerWidget { const NameOnly({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final name = ref.watch(nameProvider); return Text(name); }}
// AI-generated: forgot to wrap the app in ProviderScope.Widget buggyApp() => const MaterialApp(home: Scaffold(body: NameOnly()));Symptom — real flutter test failure, captured fresh for this module:
import 'package:flutter_test/flutter_test.dart';import 'package:my_app/ai-bug-catalog/01-provider-scope.dart';
void main() { testWidgets('no ProviderScope -> throws', (tester) async { await tester.pumpWidget(buggyApp()); final exception = tester.takeException(); expect(exception, isA<StateError>()); expect(exception.toString(), contains('No ProviderScope found')); });}✓ no ProviderScope -> throws00:00 +1: All tests passed!The real exception is a StateError with message “Bad state: No ProviderScope found”.
Fix:
Widget fixedApp() => const ProviderScope( child: MaterialApp(home: Scaffold(body: NameOnly())), );Caught by: a riverpod_lint missing_provider_scope finding (proven live in Verification Tools), or the crash on the very first screen that reads a provider.
2. setState after await with no mounted guard
Section titled “2. setState after await with no mounted guard”A State can be disposed (user navigates away, list item scrolled out of a lazily-built list) while a Future it kicked off is still pending. When it resolves and calls setState, there is no widget left to rebuild.
import 'package:flutter/material.dart';
class BuggyLoader extends StatefulWidget { const BuggyLoader({super.key, required this.future}); final Future<String> future; @override State<BuggyLoader> createState() => _BuggyLoaderState();}
class _BuggyLoaderState extends State<BuggyLoader> { String _text = 'loading'; @override void initState() { super.initState(); // AI-generated: no `mounted` guard after the await. widget.future.then((value) => setState(() => _text = value)); }
@override Widget build(BuildContext context) => Text(_text);}Symptom — real, unedited flutter test failure, captured fresh for this module:
setState() called after dispose(): _BuggyLoaderState#5aa37(lifecycle state: defunct, not mounted)This error happens if you call setState() on a State object for a widget that no longer appears inthe widget tree (e.g., whose parent widget no longer includes the widget in its build). This errorcan occur when code calls setState() from a timer, from an animation callback, or after anasynchronous operation (such as an awaited network request or other Future) completes after thewidget has been removed from the tree.The preferred solution is to cancel the timer or stop listening to the animation in the dispose()callback. Another solution is to check the "mounted" property of this object before callingsetState() to ensure the object is still in the tree.Fix, proven passing:
import 'package:flutter/material.dart';
class FixedLoader extends StatefulWidget { const FixedLoader({super.key, required this.future}); final Future<String> future; @override State<FixedLoader> createState() => _FixedLoaderState();}
class _FixedLoaderState extends State<FixedLoader> { String _text = 'loading'; @override void initState() { super.initState(); widget.future.then((value) { if (!mounted) return; setState(() => _text = value); }); }
@override Widget build(BuildContext context) => Text(_text);}import 'dart:async';import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/ai-bug-catalog/02-mounted-guard-fixed.dart';
void main() { testWidgets('mounted-guarded setState after dispose is a silent no-op', (tester) async { final completer = Completer<String>(); await tester.pumpWidget(MaterialApp(home: FixedLoader(future: completer.future))); await tester.pumpWidget(const MaterialApp(home: SizedBox())); completer.complete('Milo'); await tester.pump(); expect(tester.takeException(), isNull); });}✓ mounted-guarded setState after dispose is a silent no-op00:00 +1: All tests passed!Caught by: nothing automatic — flutter analyze sees a perfectly valid method call either way. Only a widget disposed mid-await surfaces it, in a Testing suite or in production.
3. setState called from a sibling’s build() (cross-sibling)
Section titled “3. setState called from a sibling’s build() (cross-sibling)”A widget that calls a callback synchronously from inside its own build(), and that callback calls setState on a different widget that is still mid-build, hits Flutter’s “don’t mutate what you’re building” guard.
import 'package:flutter/material.dart';
class Reporter extends StatelessWidget { const Reporter({super.key, required this.onBuild}); final VoidCallback onBuild; @override Widget build(BuildContext context) { // AI-generated: reports "I built" straight from build(), synchronously. onBuild(); return const SizedBox.shrink(); }}
class BuggyParent extends StatefulWidget { const BuggyParent({super.key}); @override State<BuggyParent> createState() => _BuggyParentState();}
class _BuggyParentState extends State<BuggyParent> { int _count = 0; @override Widget build(BuildContext context) { return Column( children: [ Text('$_count'), if (_count == 0) Reporter(onBuild: () => setState(() => _count++)), ], ); }}Symptom — real, unedited output, captured fresh for this module:
setState() or markNeedsBuild() called during build.This BuggyParent widget cannot be marked as needing to build because the framework is already in theprocess of building widgets. A widget can be marked as needing to be built during the build phaseonly if one of its ancestors is currently building. This exception is allowed because the frameworkbuilds parent widgets before children, which means a dirty descendant will always be built.The widget on which setState() or markNeedsBuild() was called was: BuggyParentThe widget which was currently being built when the offending call was made was: ReporterFix, proven passing: defer the setState past the current build phase with addPostFrameCallback.
import 'package:flutter/material.dart';
class FixedParent extends StatefulWidget { const FixedParent({super.key}); @override State<FixedParent> createState() => _FixedParentState();}
class _FixedParentState extends State<FixedParent> { int _count = 0; @override Widget build(BuildContext context) { return Column( children: [ Text('$_count'), if (_count == 0) _Reporter( onBuild: () => WidgetsBinding.instance .addPostFrameCallback((_) => setState(() => _count++)), ), ], ); }}
class _Reporter extends StatelessWidget { const _Reporter({required this.onBuild}); final VoidCallback onBuild; @override Widget build(BuildContext context) { onBuild(); return const SizedBox.shrink(); }}✓ deferring to addPostFrameCallback does not throw00:00 +1: All tests passed!Caught by: the framework, immediately and loudly — but only once this exact widget actually builds while its target is mid-build. A test that only pumps once and never triggers the reporting path misses it entirely.
4. late field read before it is assigned
Section titled “4. late field read before it is assigned”late defers a field’s initialization but does not change when callers expect to read it — an AI assistant that copies a late field without auditing every branch that might read it before the assigning method has run ships a real crash on first read, not first write.
class Session { // AI-generated: assumed `configure()` always runs before `token` is read, // but nothing enforces that ordering. late String token;
void configure(String value) => token = value;}PROOF:
import 'package:flutter_test/flutter_test.dart';import 'package:my_app/ai-bug-catalog/04-late-field.dart';
void main() { test('reading a late field before it is set throws LateInitializationError', () { final session = Session(); expect(() => session.token, throwsA(isA<Error>())); });
test('configuring first makes the read safe', () { final session = Session()..configure('abc123'); expect(session.token, 'abc123'); });}✓ reading a late field before it is set throws LateInitializationError✓ configuring first makes the read safe00:00 +2: All tests passed!Fix: either make the field nullable and check for null, give it a real default, or make the constructor require the value instead of a separate configure() step.
Caught by: nothing static — late is a promise to the compiler, not a guarantee the analyzer checks. Only a real read on an unconfigured instance throws, and it throws at the READ, not at the skipped assignment, which makes the stack trace point at an innocent-looking line far from the actual bug.
5. StateProvider/StateNotifierProvider imported as if they were still top-level Riverpod 3 API
Section titled “5. StateProvider/StateNotifierProvider imported as if they were still top-level Riverpod 3 API”AI training data spans Riverpod 2, where StateProvider and StateNotifierProvider were front-and-center in the main flutter_riverpod.dart export. Riverpod 3 moved both (plus ChangeNotifierProvider) behind an explicit legacy.dart import — they still exist, they just are not where an AI assistant trained on older docs expects.
// lib/ai-bug-catalog/05-state-provider.dart — @expect-error StateProvider moved behind package:flutter_riverpod/legacy.dart in Riverpod 3; the main export no longer has itimport 'package:flutter_riverpod/flutter_riverpod.dart';
// AI-generated (trained on Riverpod 2 docs).final counterProvider = StateProvider<int>((ref) => 0);Symptom — real flutter analyze diagnostic, captured fresh against flutter_riverpod 3.4.3:
error • The function 'StateProvider' isn't defined. Try importing the library that defines'StateProvider', correcting the name to the name of an existing function, or defining a functionnamed 'StateProvider' • lib/ai-bug-catalog/05-state-provider.dart:5:25 • undefined_functionFix, analyzes clean:
import 'package:flutter_riverpod/legacy.dart';
// Still works in Riverpod 3, but only via the explicit legacy import --// prefer NotifierProvider/@riverpod codegen for new code instead.final counterProvider = StateProvider<int>((ref) => 0);Caught by: flutter analyze — but only after the reader already knows to look, since the error message names a real function that genuinely does not exist under that import, giving no hint that it moved rather than vanished.
Widget tree & keys
Section titled “Widget tree & keys”6. No key on a reorderable list of stateful children
Section titled “6. No key on a reorderable list of stateful children”Without a key, Flutter matches a rebuilt widget to its State by type and position, not identity. Reorder the list, and each State object stays pinned to its screen position instead of following its data.
import 'package:flutter/material.dart';
class ColorBox extends StatefulWidget { const ColorBox({super.key, required this.label}); final String label; @override State<ColorBox> createState() => _ColorBoxState();}
class _ColorBoxState extends State<ColorBox> { bool _tapped = false; @override Widget build(BuildContext context) { return GestureDetector( onTap: () => setState(() => _tapped = true), child: Text(_tapped ? '${widget.label} (tapped)' : widget.label), ); }}
// AI-generated: no key, so Flutter matches by TYPE + POSITION, not identity.class BuggyList extends StatelessWidget { const BuggyList({super.key, required this.labels}); final List<String> labels; @override Widget build(BuildContext context) { return Column(children: [for (final l in labels) ColorBox(label: l)]); }}PROOF:
import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/ai-bug-catalog/06-missing-key.dart';
void main() { testWidgets('no key: tapped state sticks to POSITION, not the item', (tester) async { await tester.pumpWidget(const MaterialApp(home: BuggyList(labels: ['Milo', 'Rex']))); await tester.tap(find.text('Milo')); await tester.pump(); expect(find.text('Milo (tapped)'), findsOneWidget);
// Reorder: Rex now occupies position 0. No key -> Flutter reuses the // SAME State object for position 0 -- the "tapped" flag follows the // POSITION, not Milo. await tester.pumpWidget(const MaterialApp(home: BuggyList(labels: ['Rex', 'Milo']))); expect(find.text('Rex (tapped)'), findsOneWidget); expect(find.text('Milo (tapped)'), findsNothing); });}✓ no key: tapped state sticks to POSITION, not the item00:00 +1: All tests passed!Fix, proven passing: a ValueKey per item ties the State to the DATA, not the position.
import 'package:flutter/material.dart';
class ColorBox extends StatefulWidget { const ColorBox({super.key, required this.label}); final String label; @override State<ColorBox> createState() => _ColorBoxState();}
class _ColorBoxState extends State<ColorBox> { bool _tapped = false; @override Widget build(BuildContext context) { return GestureDetector( onTap: () => setState(() => _tapped = true), child: Text(_tapped ? '${widget.label} (tapped)' : widget.label), ); }}
class FixedList extends StatelessWidget { const FixedList({super.key, required this.labels}); final List<String> labels; @override Widget build(BuildContext context) { return Column(children: [for (final l in labels) ColorBox(key: ValueKey(l), label: l)]); }}✓ ValueKey: tapped state stays attached to the correct item00:00 +1: All tests passed!Caught by: nothing automatic — this renders correctly on the first pass every time; only a manual reorder-then-inspect during review (or a regression test seeded with a reorder step) catches it.
7. The same GlobalKey reused across sibling widgets
Section titled “7. The same GlobalKey reused across sibling widgets”An AI assistant that reaches for a single, module-level GlobalKey to mean “the currently featured item” — assuming only one widget will ever hold it at a time — breaks the moment two widgets carrying that key exist in the same frame.
import 'package:flutter/material.dart';
// AI-generated: ONE GlobalKey reused for "the featured item".final featuredKey = GlobalKey();
class BuggyTwoPanes extends StatelessWidget { const BuggyTwoPanes({super.key}); @override Widget build(BuildContext context) { return Row( children: [ Container(key: featuredKey, width: 100, height: 100, color: Colors.blue), Container(key: featuredKey, width: 100, height: 100, color: Colors.red), ], ); }}Symptom — real, unedited flutter test failure, captured fresh for this module:
Duplicate keys found.If multiple keyed widgets exist as children of another widget, they must have unique keys.Row(direction: horizontal, mainAxisAlignment: start, crossAxisAlignment: center) has multiplechildren with key [GlobalKey#52079].Fix, proven passing: a UniqueKey() (or no key at all, if identity across rebuilds doesn’t matter) per widget.
import 'package:flutter/material.dart';
class FixedTwoPanes extends StatelessWidget { const FixedTwoPanes({super.key}); @override Widget build(BuildContext context) { return Row( children: [ Container(key: UniqueKey(), width: 100, height: 100, color: Colors.blue), Container(key: UniqueKey(), width: 100, height: 100, color: Colors.red), ], ); }}✓ a distinct key per widget does not throw00:00 +1: All tests passed!Caught by: the framework, immediately and loudly, the instant two widgets carrying the same key exist in one build — but only that specific arrangement (siblings, or two branches built the same frame); a GlobalKey reused across builds that never overlap in time can hide for much longer.
8. Scaffold.of(context) called with the Scaffold’s own build context
Section titled “8. Scaffold.of(context) called with the Scaffold’s own build context”Scaffold.of(context) needs a context that is a descendant of the Scaffold — the context the Scaffold widget’s own build() method receives is an ancestor of it, not a descendant, so the lookup fails.
import 'package:flutter/material.dart';
// AI-generated: calls Scaffold.of(context) with the SAME context the// Scaffold itself was built with.class BuggyPage extends StatelessWidget { const BuggyPage({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Buggy')), body: Center( child: ElevatedButton( onPressed: () => Scaffold.of(context).showBottomSheet( (_) => const SizedBox(height: 100, child: Text('Sheet')), ), child: const Text('Open sheet'), ), ), ); }}Symptom — real, unedited flutter test failure, captured fresh for this module:
Scaffold.of() called with a context that does not contain a Scaffold.No Scaffold ancestor could be found starting from the context that was passed to Scaffold.of().This usually happens when the context provided is from the same StatefulWidget as that whose buildfunction actually creates the Scaffold widget being sought.Fix, proven passing: a Builder introduces a fresh context that IS a Scaffold descendant.
import 'package:flutter/material.dart';
class FixedPage extends StatelessWidget { const FixedPage({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Fixed')), body: Center( child: Builder( builder: (innerContext) => ElevatedButton( onPressed: () => Scaffold.of(innerContext).showBottomSheet( (_) => const SizedBox(height: 100, child: Text('Sheet')), ), child: const Text('Open sheet'), ), ), ), ); }}✓ Scaffold.of(context) from a Builder descendant works00:00 +1: All tests passed!Caught by: nothing until the button is actually tapped — this compiles and even builds fine; the lookup only runs, and fails, when the callback fires.
Layout
Section titled “Layout”9. ListView given unbounded height by a plain Column
Section titled “9. ListView given unbounded height by a plain Column”Column gives its children unbounded height along the main axis. ListView needs a bounded height to know how big its viewport is. Drop one straight into the other with nothing between them, and layout fails.
import 'package:flutter/material.dart';
// AI-generated: a ListView dropped straight into a Column.class BuggyPage extends StatelessWidget { const BuggyPage({super.key}); @override Widget build(BuildContext context) { return Column( children: [ const Text('Header'), ListView(children: const [Text('one'), Text('two'), Text('three')]), ], ); }}Symptom — real, unedited output, captured fresh for this module:
Vertical viewport was given unbounded height.Viewports expand in the scrolling direction to fill their container. In this case, a verticalviewport was given an unlimited amount of vertical space in which to expand. This situationtypically happens when a scrollable widget is nested inside another scrollable widget.Fix, proven passing: Expanded gives the ListView a bounded height — whatever space is left in the Column. The real rule, precisely: an unbounded-height ancestor (a Column’s main axis) needs a bounded box SOMEWHERE between it and a scrollable/flexible child. The reverse direction — a Column inside a ListView — needs nothing extra at all: a scrollable already gives its children a bounded cross-axis and unbounded main-axis, exactly what a naturally-sized Column wants.
import 'package:flutter/material.dart';
class FixedPage extends StatelessWidget { const FixedPage({super.key}); @override Widget build(BuildContext context) { return Column( children: [ const Text('Header'), Expanded( child: ListView(children: const [Text('one'), Text('two'), Text('three')]), ), ], ); }}
// The reverse needs NO extra widget:class ColumnInsideListView extends StatelessWidget { const ColumnInsideListView({super.key}); @override Widget build(BuildContext context) { return ListView(children: [Column(children: const [Text('a'), Text('b'), Text('c')])]); }}✓ Expanded around the ListView fixes it✓ a Column INSIDE a ListView needs no extra widget at all00:00 +2: All tests passed!Caught by: the framework, immediately and loudly. The nuance to catch in review is the DIRECTION — the same fix pattern is often over-applied to the reverse case, which never needed it.
10. Expanded used outside a Flex
Section titled “10. Expanded used outside a Flex”Expanded relies on ParentData that only a Row/Column/Flex sets up for its direct children. Placed inside a Center, a Container, or anything else, it fails.
import 'package:flutter/material.dart';
// AI-generated: Expanded directly inside a Center, which is not a Flex.class BuggyBox extends StatelessWidget { const BuggyBox({super.key}); @override Widget build(BuildContext context) { return Center(child: Expanded(child: Container(color: Colors.blue))); }}Symptom — real, unedited output, captured fresh for this module:
Incorrect use of ParentDataWidget.The ParentDataWidget Expanded(flex: 1) wants to apply ParentData of type FlexParentData to aRenderObject, which has been set up to accept ParentData of incompatible type BoxParentData.Usually, this means that the Expanded widget has the wrong ancestor RenderObjectWidget. Typically,Expanded widgets are placed directly inside Flex widgets.The offending Expanded is currently placed inside a Center widget.Fix, proven passing:
import 'package:flutter/material.dart';
class FixedBox extends StatelessWidget { const FixedBox({super.key}); @override Widget build(BuildContext context) { return Row(children: [Expanded(child: Container(color: Colors.blue))]); }}✓ Expanded is a child of a Row -- lays out fine00:00 +1: All tests passed!Caught by: the framework, immediately, and the error message even names the exact ancestor it found instead — the fastest diagnostic in this whole catalog to fix once seen, but the message alone doesn’t say “wrap in a Row/Column,” a reader still has to know that rule.
11. RenderFlex overflow from fixed-size children with no escape hatch
Section titled “11. RenderFlex overflow from fixed-size children with no escape hatch”A Row/Column of fixed-size children that together exceed the available space, with no Expanded/Flexible/scrolling to absorb the difference, overflows — visibly, in a debug build (the yellow-and-black stripes), and as a real assertion underneath.
import 'package:flutter/material.dart';
// AI-generated: 20 fixed-width boxes in a Row, wider than any phone screen.class BuggyRow extends StatelessWidget { const BuggyRow({super.key}); @override Widget build(BuildContext context) { return Row( children: List.generate( 20, (i) => Container(width: 100, height: 40, color: Colors.primaries[i % Colors.primaries.length]), ), ); }}Symptom — real, unedited output, captured fresh for this module:
A RenderFlex overflowed by 1200 pixels on the right.The overflowing RenderFlex has an orientation of Axis.horizontal.The edge of the RenderFlex that is overflowing has been marked in the rendering with a yellow andblack striped pattern. This is usually caused by the contents being too big for the RenderFlex.Consider applying a flex factor (e.g. using an Expanded widget) to force the children of theRenderFlex to fit within the available space instead of being sized to their natural size.Fix, proven passing: Expanded lets every child share the available width instead of demanding its natural width unconditionally.
import 'package:flutter/material.dart';
class FixedRow extends StatelessWidget { const FixedRow({super.key}); @override Widget build(BuildContext context) { return Row( children: List.generate( 20, (i) => Expanded( child: Container(height: 40, color: Colors.primaries[i % Colors.primaries.length]), ), ), ); }}✓ Expanded children share the available width -- no overflow00:00 +1: All tests passed!Caught by: the framework, visibly — the yellow-and-black overflow indicator is one of the few Flutter errors that shows up IN the rendered UI itself, not just the console, which is exactly why it is also one of the most-often “fixed” by the nearest Expanded, correctly or not.
Async & isolates
Section titled “Async & isolates”12. A Future created directly in build()
Section titled “12. A Future created directly in build()”Calling an async function inside build() creates a NEW Future — and a new network/database call — on every single rebuild, not once per logical “load.”
import 'package:flutter/material.dart';
int fetchCount = 0;Future<String> fetchName() async { fetchCount++; return 'Milo';}
// AI-generated: calls the async function directly inside build().class BuggyName extends StatelessWidget { const BuggyName({super.key, required this.tick}); final int tick; // forces a distinct widget per pump, to prove the rebuild count @override Widget build(BuildContext context) { return FutureBuilder<String>( future: fetchName(), // new Future every single build() builder: (context, snapshot) => Text(snapshot.data ?? 'loading'), ); }}PROOF:
import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/ai-bug-catalog/12-future-in-build.dart';
void main() { testWidgets('creating the Future in build() re-fetches on every rebuild', (tester) async { fetchCount = 0; await tester.pumpWidget(MaterialApp(home: BuggyName(tick: 0))); await tester.pumpWidget(MaterialApp(home: BuggyName(tick: 1))); await tester.pumpWidget(MaterialApp(home: BuggyName(tick: 2))); expect(fetchCount, 3); // three build()s -> three fetches });}✓ creating the Future in build() re-fetches on every rebuild00:00 +1: All tests passed!Fix, proven passing: create the Future ONCE, in State, not in build().
import 'package:flutter/material.dart';
int fetchCount = 0;Future<String> fetchName() async { fetchCount++; return 'Milo';}
class FixedName extends StatefulWidget { const FixedName({super.key, required this.tick}); final int tick; @override State<FixedName> createState() => _FixedNameState();}
class _FixedNameState extends State<FixedName> { late final Future<String> _future = fetchName(); @override Widget build(BuildContext context) { return FutureBuilder<String>( future: _future, builder: (context, snapshot) => Text(snapshot.data ?? 'loading'), ); }}✓ creating the Future once in initState fetches exactly once00:00 +1: All tests passed!Caught by: nothing automatic — every rebuild-refetch still resolves with the right data, so the demo works; only counting real backend calls (or a test like the one above) reveals the multiplication.
13. compute() closure capturing a BuildContext
Section titled “13. compute() closure capturing a BuildContext”Modern Dart’s compute() runs on Isolate.run, which genuinely can send well-behaved closures across the isolate boundary — a closure capturing a plain int, even a bound instance method, crosses fine on this course’s pinned Dart 3.13.4. What still cannot cross is anything reachable from the widget tree: a BuildContext transitively reaches a Zone, and a Zone is never sendable.
import 'package:flutter/material.dart';import 'package:flutter/foundation.dart';
// AI-generated: captures `context` to log something mid-computation.class BuggySummary extends StatelessWidget { const BuggySummary({super.key}); @override Widget build(BuildContext context) { int withContext(List<int> xs) { debugPrint('${context.widget}'); // captures `context` -> unsendable return xs.reduce((a, b) => a + b); } compute(withContext, const [1, 2, 3]).then((r) => debugPrint('result: $r')); return const SizedBox(); }}Symptom — real, unedited output, captured fresh for this module:
Invalid argument(s): Illegal argument in isolate message: object is unsendable -Library:'dart:async' Class: _CustomZone@5048458 (see restrictions listed at `SendPort.send()`documentation for more information) <- _parentZone in Instance of 'AutomatedTestWidgetsFlutterBinding' (from package:flutter_test/src/binding.dart) <- _binding in Instance of '_BindingPipelineManifold' (from package:flutter/src/rendering/binding.dart) <- ... (a long ownership chain from the captured BuildContext down to a real Zone)Fix, proven passing: a top-level function that takes only plain, explicit data — no BuildContext, no Zone, nothing tied to this isolate’s own binding.
import 'package:flutter/foundation.dart';
int _sum(List<int> xs) => xs.reduce((a, b) => a + b);
Future<int> fixedSummarize(List<int> values) => compute(_sum, values);✓ a top-level function with only plain data crosses the isolate boundary fine00:00 +1: All tests passed!Caught by: nothing until the compute() call actually runs — this compiles perfectly cleanly, and even a closure capturing a plain local variable would run fine, which is exactly what makes this easy to under- or over-correct: the rule isn’t “never capture anything,” it’s “never capture anything reachable from the Element tree or the binding.”
14. A single-subscription StreamController listened to twice
Section titled “14. A single-subscription StreamController listened to twice”StreamController() defaults to single-subscription. A second, independent .listen() call on the same stream — one piece of code for UI, another for analytics, say — throws; it does not silently create a second subscriber the way a broadcast stream would.
// lib/ai-bug-catalog/14-stream-double-listen.dart — @expect-error a single-subscription Stream throws on the second .listen() -- see the real "Bad state" belowimport 'dart:async';
// AI-generated: default StreamController, assumed multiple listeners are fine.StreamController<int> buggyController() => StreamController<int>();Symptom — real, unedited output, captured fresh for this module:
Bad state: Stream has already been listened to.Fix, proven passing: .broadcast() explicitly allows multiple independent listeners.
import 'dart:async';
StreamController<int> fixedController() => StreamController<int>.broadcast();import 'package:flutter_test/flutter_test.dart';import 'package:my_app/ai-bug-catalog/14-stream-double-listen-fixed.dart';
void main() { test('a broadcast StreamController allows two independent listeners', () async { final controller = fixedController(); final a = <int>[]; final b = <int>[]; controller.stream.listen(a.add); controller.stream.listen(b.add); // does NOT throw controller.add(1); await Future<void>.delayed(Duration.zero); expect(a, [1]); expect(b, [1]); await controller.close(); });}✓ a broadcast StreamController allows two independent listeners00:00 +1: All tests passed!Caught by: the framework, immediately and loudly, the moment the second .listen() runs — but only if that second listener is ever actually exercised; code paths that only add the second listener conditionally (an optional analytics hook, say) can ship untested.
Navigation
Section titled “Navigation”15. go_router: a parameterized route declared before a static one
Section titled “15. go_router: a parameterized route declared before a static one”go_router matches routes in declaration order, not by specificity. GoRoute(path: '/pets/:id') declared before GoRoute(path: '/pets/new') at the same segment level means /pets/new matches the FIRST route, with id literally equal to the string 'new'.
import 'package:flutter/material.dart';import 'package:go_router/go_router.dart';
// AI-generated: the parameterized route declared FIRST.GoRouter buggyRouter() => GoRouter( initialLocation: '/pets/1', routes: [ GoRoute(path: '/pets/:id', builder: (c, s) => Text('pet id=${s.pathParameters['id']}')), GoRoute(path: '/pets/new', builder: (c, s) => const Text('new pet form')), ], );PROOF:
import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/ai-bug-catalog/15-route-order.dart';
void main() { testWidgets('/pets/:id declared first swallows /pets/new as id="new"', (tester) async { final router = buggyRouter(); await tester.pumpWidget(MaterialApp.router(routerConfig: router)); router.go('/pets/new'); await tester.pumpAndSettle(); expect(find.text('pet id=new'), findsOneWidget); expect(find.text('new pet form'), findsNothing); });}✓ /pets/:id declared first swallows /pets/new as id="new"00:00 +1: All tests passed!Fix, proven passing: declare the specific, static route FIRST.
import 'package:flutter/material.dart';import 'package:go_router/go_router.dart';
GoRouter fixedRouter() => GoRouter( initialLocation: '/pets/1', routes: [ GoRoute(path: '/pets/new', builder: (c, s) => const Text('new pet form')), GoRoute(path: '/pets/:id', builder: (c, s) => Text('pet id=${s.pathParameters['id']}')), ], );✓ /pets/new declared first matches correctly00:00 +1: All tests passed!Caught by: nothing automatic — both orderings compile identically; only navigating to the specific route and checking what actually rendered reveals the misroute.
16. context.go() used where context.push() was needed
Section titled “16. context.go() used where context.push() was needed”go() replaces the current location in the route stack. push() adds to it. Using go() for what should be a drill-down navigation (a list to a detail screen, say) leaves the user with no “back” to press.
import 'package:flutter/material.dart';import 'package:go_router/go_router.dart';
GoRouter buildRouter() => GoRouter( initialLocation: '/a', routes: [ GoRoute(path: '/a', builder: (c, s) => const _Page('A')), GoRoute(path: '/b', builder: (c, s) => const _Page('B')), ], );
class _Page extends StatelessWidget { const _Page(this.label); final String label; @override Widget build(BuildContext context) => Scaffold(body: Text('Page $label'));}PROOF — stack depth, compared directly:
import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/ai-bug-catalog/16-go-vs-push.dart';
void main() { testWidgets('context.go REPLACES the stack', (tester) async { final router = buildRouter(); await tester.pumpWidget(MaterialApp.router(routerConfig: router)); router.go('/b'); // AI-generated: used go() for what should be a "push" await tester.pumpAndSettle(); expect(router.routerDelegate.currentConfiguration.matches.length, 1); // /a is GONE });
testWidgets('context.push KEEPS the previous page on the stack', (tester) async { final router = buildRouter(); await tester.pumpWidget(MaterialApp.router(routerConfig: router)); router.push('/b'); // fixed: push() when the user should be able to go "back" await tester.pumpAndSettle(); expect(router.routerDelegate.currentConfiguration.matches.length, 2); // /a is still there });}✓ context.go REPLACES the stack✓ context.push KEEPS the previous page on the stack00:00 +2: All tests passed!Fix: use context.push() (or router.push()) for drill-down navigation the user should be able to back out of; reserve go() for tab switches, redirects, and “replace this screen entirely” cases.
Caught by: nothing automatic — both calls navigate correctly to the destination; only checking Navigator.canPop()/pressing back afterward reveals the missing history entry.
17. redirect reads external state with no refreshListenable
Section titled “17. redirect reads external state with no refreshListenable”go_router’s redirect callback only re-runs on navigation events by default. If it reads some external state (an auth flag) that changes WITHOUT a navigation event happening at the same time, the router never notices.
import 'package:flutter/material.dart';import 'package:go_router/go_router.dart';
class AuthState extends ChangeNotifier { bool loggedIn = false; void logIn() { loggedIn = true; notifyListeners(); }}
// AI-generated: redirect reads auth.loggedIn, but nothing re-evaluates// redirects when that value changes outside of navigation.GoRouter buggyRouter(AuthState auth) => GoRouter( initialLocation: '/login', redirect: (context, state) { if (!auth.loggedIn && state.matchedLocation != '/login') return '/login'; if (auth.loggedIn && state.matchedLocation == '/login') return '/home'; return null; }, routes: [ GoRoute(path: '/login', builder: (c, s) => const Text('login')), GoRoute(path: '/home', builder: (c, s) => const Text('home')), ], );PROOF:
import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/ai-bug-catalog/17-redirect-refresh.dart';
void main() { testWidgets('without refreshListenable, logging in does NOT redirect away from /login', (tester) async { final auth = AuthState(); final router = buggyRouter(auth); await tester.pumpWidget(MaterialApp.router(routerConfig: router)); auth.logIn(); // state changed, but nothing tells the router to re-check await tester.pumpAndSettle(); expect(find.text('login'), findsOneWidget); // still stuck expect(find.text('home'), findsNothing); });}✓ without refreshListenable, logging in does NOT redirect away from /login00:00 +1: All tests passed!Fix, proven passing: refreshListenable: auth tells go_router to re-run redirect every time auth.notifyListeners() fires, not just on navigation.
import 'package:flutter/material.dart';import 'package:go_router/go_router.dart';
class AuthState extends ChangeNotifier { bool loggedIn = false; void logIn() { loggedIn = true; notifyListeners(); }}
GoRouter fixedRouter(AuthState auth) => GoRouter( initialLocation: '/login', refreshListenable: auth, redirect: (context, state) { if (!auth.loggedIn && state.matchedLocation != '/login') return '/login'; if (auth.loggedIn && state.matchedLocation == '/login') return '/home'; return null; }, routes: [ GoRoute(path: '/login', builder: (c, s) => const Text('login')), GoRoute(path: '/home', builder: (c, s) => const Text('home')), ], );✓ with refreshListenable, logging in redirects to /home immediately00:00 +1: All tests passed!Caught by: nothing automatic — the login screen’s OWN “log in” button usually navigates explicitly anyway, which triggers a redirect check and hides the bug; it only surfaces when auth state changes from somewhere that doesn’t also navigate (a background token refresh, a second tab, a push notification handler).
18. WillPopScope (deprecated), and the misspelled PredictiveBackPageTransitionBuilder
Section titled “18. WillPopScope (deprecated), and the misspelled PredictiveBackPageTransitionBuilder”AI training data spans a WillPopScope era; it was deprecated in favor of PopScope because WillPopScope does not support Android’s predictive-back gesture at all. Separately, the real Android predictive-back transitions class is PredictiveBackPageTransitionsBuilder — AI-generated code often drops the “s” in “Transitions.”
import 'package:flutter/material.dart';
// AI-generated (trained on older Flutter docs).Widget buggyGuard(Widget child) { return WillPopScope(onWillPop: () async => false, child: child);}Symptom — real flutter analyze diagnostic, captured fresh:
info • 'WillPopScope' is deprecated and shouldn't be used. Use PopScope instead. The Androidpredictive back feature will not work with WillPopScope. This feature was deprecated afterv3.12.0-1.0.pre. Try replacing the use of the deprecated member with the replacement •deprecated_member_useAnd the misspelled class name — real flutter analyze diagnostic, captured fresh:
error • The function 'PredictiveBackPageTransitionBuilder' isn't defined. Try importing the librarythat defines 'PredictiveBackPageTransitionBuilder', correcting the name to the name of an existingfunction, or defining a function named 'PredictiveBackPageTransitionBuilder' • undefined_functionFix, analyzes clean:
import 'package:flutter/material.dart';
Widget fixedGuard(Widget child) { return PopScope(canPop: false, child: child);}
// Note the "s": PredictiveBackPageTransitionsBuilder, not …TransitionBuilder.final predictiveBackBuilder = PredictiveBackPageTransitionsBuilder();Caught by: flutter analyze for both — the deprecation as an info, the typo as a real undefined_function error — but only if the reader runs analyze at all; an editor’s autocomplete often “fixes” the typo by suggesting the WRONG but similarly-named real class instead.
Platform
Section titled “Platform”19. dart:io/Platform.isIOS used unconditionally in code that also builds for web
Section titled “19. dart:io/Platform.isIOS used unconditionally in code that also builds for web”dart:io’s web implementation compiles cleanly — the web compiler ships a real, type-complete facade for it, confirmed by reading the SDK’s own io_patch.dart for the js_runtime target, where every member’s body is throw UnsupportedError(...). The build never fails; every member just throws the moment it actually runs.
import 'dart:io';import 'package:flutter/material.dart';
// AI-generated: reaches for dart:io directly to check the platform.class BuggyPlatformBadge extends StatelessWidget { const BuggyPlatformBadge({super.key}); @override Widget build(BuildContext context) { final label = Platform.isIOS ? 'iOS' : 'Other'; // throws on web, at runtime return Text(label); }}Symptom — real, freshly captured for this module:
$ flutter build web --releaseCompiling lib/main.dart for the Web...✓ Built build/webThe build succeeds with zero warning about dart:io. The failure is real but only reachable at runtime, in an actual browser, the moment Platform.isIOS executes — confirmed directly from the shipped SDK’s web-target patch, where _Platform._operatingSystem() (what Platform.isIOS ultimately reads) is implemented as throw UnsupportedError("Platform._operatingSystem").
Fix, proven to build clean: kIsWeb is a compile-time-safe constant from package:flutter/foundation.dart — check it FIRST, before anything that touches Platform.
import 'dart:io';import 'package:flutter/foundation.dart' show kIsWeb;
String platformLabel() { if (kIsWeb) return 'Web'; return Platform.isIOS ? 'iOS' : 'Other';}Caught by: nothing — not the analyzer, not the build, not even flutter build web --wasm (also proven to compile this exact snippet cleanly, fresh for this module). Only an actual run on an actual web target surfaces it.
20. MethodChannel.invokeMethod with no native handler registered
Section titled “20. MethodChannel.invokeMethod with no native handler registered”Invoking a platform channel method assumes a native implementation is registered on the running platform — true only if a real plugin (or a test’s own mock) actually wires it up.
import 'package:flutter/services.dart';
// AI-generated: assumes SOME native implementation exists.const _channel = MethodChannel('com.example/battery');
Future<int> buggyBatteryLevel() { return _channel.invokeMethod<int>('getBatteryLevel').then((v) => v ?? -1);}Symptom — real, unedited output, captured fresh for this module:
MissingPluginException(No implementation found for method getBatteryLevel on channel com.example/battery)Fix, proven passing: in tests, register a mock handler; in production, a real platform-specific implementation (a real plugin, or platform-specific code registered in the native project).
import 'package:flutter/services.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/ai-bug-catalog/20-missing-plugin.dart';
void main() { TestWidgetsFlutterBinding.ensureInitialized();
test('no native handler registered -> MissingPluginException', () async { await expectLater(buggyBatteryLevel(), throwsA(isA<MissingPluginException>())); });
test('registering a mock handler fixes it', () async { const channel = MethodChannel('com.example/battery'); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async => call.method == 'getBatteryLevel' ? 87 : null); final level = await buggyBatteryLevel(); expect(level, 87); });}✓ no native handler registered -> MissingPluginException✓ registering a mock handler fixes it00:00 +2: All tests passed!Caught by: nothing static — this compiles and analyzes perfectly; only actually invoking the channel (on a real device/emulator, or in a test with no mock registered) reveals a missing native side.
21. FlutterError.onError set with no runZonedGuarded (or vice versa)
Section titled “21. FlutterError.onError set with no runZonedGuarded (or vice versa)”FlutterError.onError only fires for errors the FRAMEWORK catches — during build, layout, and paint. An error thrown from a bare Future, a Timer, or any plain async code outside the framework’s own try/catch points never reaches it at all.
import 'dart:async';
// AI-generated: sets FlutterError.onError, assumes that's "global error// handling" done -- it is not, for anything outside the framework.Future<void> triggerAsyncErrorOutsideFramework() { return Future<void>(() => throw StateError('boom from a bare Future'));}Fix, proven passing: runZonedGuarded wraps the WHOLE zone, catching errors FlutterError.onError never sees.
import 'dart:async';
void runGuarded(void Function() body, void Function(Object, StackTrace) onError) { runZonedGuarded(body, onError);}import 'dart:async';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/ai-bug-catalog/21-zone-errors-fixed.dart';import 'package:my_app/ai-bug-catalog/21-zone-errors.dart';
void main() { test('runZonedGuarded catches an error a bare Future throws', () async { final caught = Completer<Object>(); runGuarded(() => triggerAsyncErrorOutsideFramework(), (error, stack) => caught.complete(error)); final error = await caught.future.timeout(const Duration(seconds: 2)); expect(error, isA<StateError>()); });}✓ runZonedGuarded catches an error a bare Future throws00:00 +1: All tests passed!Caught by: nothing static, and this is the sharpest trap in this group: FlutterError.onError alone LOOKS like complete error coverage (it catches real, visible framework crashes during development), which is exactly why the gap for bare async code goes unnoticed until a genuinely uncaught error reaches production with no report at all. PlatformDispatcher.onError (3.3+) is the modern alternative/complement worth knowing about too, catching errors at the platform-dispatcher level.
Performance
Section titled “Performance”22. AnimationController created but never disposed
Section titled “22. AnimationController created but never disposed”An AnimationController’s Ticker (created via SingleTickerProviderStateMixin) outlives the State object if nothing calls .dispose() on the controller — the framework itself catches this, loudly, in debug mode.
import 'package:flutter/material.dart';
// AI-generated: no dispose() override at all.class BuggySpinner extends StatefulWidget { const BuggySpinner({super.key}); @override State<BuggySpinner> createState() => _BuggySpinnerState();}
class _BuggySpinnerState extends State<BuggySpinner> with SingleTickerProviderStateMixin { late final AnimationController _controller = AnimationController(vsync: this, duration: const Duration(seconds: 1))..repeat();
@override Widget build(BuildContext context) => RotationTransition(turns: _controller, child: const Icon(Icons.sync));}Symptom — real, unedited output, captured fresh for this module:
_BuggySpinnerState#73be2(ticker active) was disposed with an active Ticker._BuggySpinnerState created a Ticker via its SingleTickerProviderStateMixin, but at the timedispose() was called on the mixin, that Ticker was still active. The Ticker must be disposed beforecalling super.dispose().Tickers used by AnimationControllers should be disposed by calling dispose() on theAnimationController itself. Otherwise, the ticker will leak.Fix, proven passing:
import 'package:flutter/material.dart';
class FixedSpinner extends StatefulWidget { const FixedSpinner({super.key}); @override State<FixedSpinner> createState() => _FixedSpinnerState();}
class _FixedSpinnerState extends State<FixedSpinner> with SingleTickerProviderStateMixin { late final AnimationController _controller = AnimationController(vsync: this, duration: const Duration(seconds: 1))..repeat();
@override void dispose() { _controller.dispose(); super.dispose(); }
@override Widget build(BuildContext context) => RotationTransition(turns: _controller, child: const Icon(Icons.sync));}✓ disposing the controller in dispose() leaves no leaked ticker00:00 +1: All tests passed!Caught by: flutter_test itself, unconditionally, if the widget is ever torn down in a test at all — but a widget that is simply never disposed during a short manual demo session never trips it, and the leak (a live Ticker firing frames forever) keeps costing CPU and battery in production regardless.
23. Opacity(opacity: 1.0) still allocates a real compositing layer
Section titled “23. Opacity(opacity: 1.0) still allocates a real compositing layer”The Opacity widget’s own doc comment says “Values 1.0 and 0.0 are painted with a fast path” — which is true, but only describes the PAINT cost. Reading RenderOpacity’s actual source: isRepaintBoundary => alwaysNeedsCompositing, and alwaysNeedsCompositing => child != null && _alpha > 0. That is TRUE for opacity: 1.0 (alpha 255) exactly as much as for opacity: 0.5 — only opacity: 0.0 (alpha 0) skips compositing entirely. Opacity(opacity: 1.0) is not free; it is a real repaint boundary with a real OpacityLayer.
PROOF — read directly off the live RenderObject, not assumed from the doc comment:
import 'package:flutter/material.dart';import 'package:flutter/rendering.dart';import 'package:flutter_test/flutter_test.dart';
void main() { testWidgets('Opacity(opacity: 1.0) is STILL a repaint boundary', (tester) async { await tester.pumpWidget( const MaterialApp(home: Opacity(opacity: 1.0, child: ColoredBox(color: Colors.blue))), ); final render = tester.renderObject<RenderOpacity>(find.byType(Opacity)); expect(render.isRepaintBoundary, isTrue); expect(render.alwaysNeedsCompositing, isTrue); });
testWidgets('Opacity(opacity: 0.0) is the ONE value that skips compositing entirely', (tester) async { await tester.pumpWidget( const MaterialApp(home: Opacity(opacity: 0.0, child: ColoredBox(color: Colors.blue))), ); final render = tester.renderObject<RenderOpacity>(find.byType(Opacity)); expect(render.isRepaintBoundary, isFalse); expect(render.alwaysNeedsCompositing, isFalse); });}✓ Opacity(opacity: 1.0) is STILL a repaint boundary✓ Opacity(opacity: 0.0) is the ONE value that skips compositing entirely00:00 +2: All tests passed!Fix: if the opacity is a compile-time constant 1.0 (a widget conditionally shown/hidden by swapping it out entirely, say), skip Opacity altogether — return the child directly, or use Visibility/a ternary that removes the widget instead of dimming it. Reserve Opacity/AnimatedOpacity/FadeTransition for opacity that genuinely varies.
Caught by: nothing automatic, and this is a genuinely nuanced correction: Opacity(opacity: 1.0) does not crash and does not even mis-render — it just always pays for a layer it usually does not need to, and the framework’s own doc comment can be read (incorrectly) as saying otherwise.
24. cacheExtent deprecated → scrollCacheExtent (a new sealed-class type, not just a rename)
Section titled “24. cacheExtent deprecated → scrollCacheExtent (a new sealed-class type, not just a rename)”cacheExtent (a plain double) was deprecated in Flutter 3.41 in favor of scrollCacheExtent — but the replacement is not a drop-in rename: scrollCacheExtent takes a NEW sealed class, ScrollCacheExtent, with factory constructors .pixels(double) and .viewport(double), and that type is not even re-exported by package:flutter/material.dart — it needs an explicit package:flutter/rendering.dart import.
import 'package:flutter/material.dart';
// AI-generated (trained on older Flutter docs).Widget buggyList() => ListView.builder( cacheExtent: 500, itemCount: 100, itemBuilder: (context, i) => Text('$i'), );Symptom — real flutter analyze diagnostic, captured fresh:
info • 'cacheExtent' is deprecated and shouldn't be used. Use scrollCacheExtent instead. Thisfeature was deprecated after v3.41.0-0.0.pre. Try replacing the use of the deprecated member withthe replacement • deprecated_member_useA naive rename to scrollCacheExtent: 500 fails differently:
error • The argument type 'int' can't be assigned to the parameter type 'ScrollCacheExtent?' •argument_type_not_assignableFix, analyzes clean:
import 'package:flutter/material.dart';import 'package:flutter/rendering.dart' show ScrollCacheExtent;
Widget fixedList() => ListView.builder( scrollCacheExtent: ScrollCacheExtent.pixels(500), itemCount: 100, itemBuilder: (context, i) => Text('$i'), );Caught by: flutter analyze for the deprecation, and dart fix --dry-run correctly identifies it as fixable (proven in Verification Tools) — but a reflexive find-and-replace rename, without reading the new type, trades one diagnostic for a different one.
Tooling & release
Section titled “Tooling & release”25. prefer_const_constructors is NOT in flutter_lints 6
Section titled “25. prefer_const_constructors is NOT in flutter_lints 6”flutter_lints 6.0.0 ships prefer_const_constructors_in_immutables (const inside an @immutable class’s own build()) but not the general prefer_const_constructors rule that flags ANY missed const opportunity — confirmed by reading the package’s own flutter.yaml rule list directly.
import 'package:flutter/material.dart';
// AI-generated: missing const -- flutter_lints 6 says nothing about it.Widget buildGreeting() { return Text('hello');}Symptom — real flutter analyze, under this course’s pinned flutter_lints: ^6.0.0:
Analyzing 25-prefer-const.dart...No issues found!Contrast — the SAME file, with prefer_const_constructors: true added explicitly to analysis_options.yaml:
info • Use 'const' with the constructor to improve performance. Try adding the 'const' keyword tothe constructor invocation • prefer_const_constructorsFix: either add const, or explicitly enable prefer_const_constructors (or adopt very_good_analysis, which does include it) if the team wants the general rule enforced.
Caught by: nothing, under this course’s exact pinned lint set — a reviewer relying on “the linter would have caught it” for const specifically is relying on a rule that is not actually enabled.
26. print() is not stripped from a release build
Section titled “26. print() is not stripped from a release build”Nothing in Flutter’s default release pipeline strips print() calls. The literal string survives verbatim into the compiled output.
void main() { print('READING_FLUTTER_PRINT_MARKER_7f3a9c'); // AI-generated: "it's just a debug print"}Symptom — real flutter build web --release, then grepping the actual output bundle, captured fresh for this module:
$ flutter build web --release✓ Built build/web$ grep -c READING_FLUTTER_PRINT_MARKER_7f3a9c build/web/main.dart.js1The marker string is present, verbatim, once, inside the minified release JS bundle.
Fix: use dart:developer’s log() (which respects log levels and can be stripped/filtered by tooling) instead of print() for anything that should not ship in release, or gate debug-only prints behind kDebugMode from package:flutter/foundation.dart.
Caught by: nothing automatic — no lint in this course’s pinned flutter_lints 6 flags a bare print() by default (avoid_print IS enabled, worth noting as the one exception: it fires as a lint, but nothing enforces fixing lints before a release build ships).
27. Non-exhaustive switch on a sealed class
Section titled “27. Non-exhaustive switch on a sealed class”An AI assistant trained on the old “abstract class + is-check” style for state unions sometimes still forgets a case even when using a modern sealed class + switch expression — but here, the analyzer can actually PROVE the switch is incomplete, at compile time, because the class is sealed.
// lib/ai-bug-catalog/27-non-exhaustive-switch.dart — @expect-error non_exhaustive_switch_expression -- see the real diagnostic belowsealed class LoadState {}class Loading extends LoadState {}class Loaded extends LoadState { Loaded(this.data); final String data;}class LoadError extends LoadState { LoadError(this.message); final String message;}
// AI-generated: forgot the LoadError case entirely.String buggyDescribe(LoadState state) { return switch (state) { Loading() => 'loading', Loaded(:final data) => 'loaded: $data', };}Symptom — real flutter analyze diagnostic, captured fresh:
error • The type 'LoadState' isn't exhaustively matched by the switch cases since it doesn't matchthe pattern 'LoadError()'. Try adding a wildcard pattern or cases that match 'LoadError()' •non_exhaustive_switch_expressionFix, analyzes clean:
sealed class LoadState {}class Loading extends LoadState {}class Loaded extends LoadState { Loaded(this.data); final String data;}class LoadError extends LoadState { LoadError(this.message); final String message;}
String fixedDescribe(LoadState state) { return switch (state) { Loading() => 'loading', Loaded(:final data) => 'loaded: $data', LoadError(:final message) => 'error: $message', };}Caught by: flutter analyze, as a real, hard ERROR (not a lint an AI’s confidence can wave away) — this is Dart 3’s own exhaustiveness checking, and it is the single strongest argument in this whole catalog for adopting sealed class state unions over the old abstract class style: a fourth subtype added later makes every existing switch fail to compile again until it is handled.
28. Hallucinated API: a named parameter and a method that do not exist
Section titled “28. Hallucinated API: a named parameter and a method that do not exist”An AI assistant blending several widgets/APIs it has seen invents a plausible-sounding named parameter or method name that does not actually exist on the class in question.
// lib/ai-bug-catalog/28-hallucinated-api.dart — @expect-error undefined_named_parameter + undefined_method -- see the real diagnostics belowimport 'package:flutter/material.dart';
Widget buggyCard() { return Card( elevation: 4, cornerRadius: 12, // hallucinated -- Card has no such parameter child: const Text('hi'), );}
void buggyScroll(ScrollController controller) { controller.animateToEnd(); // hallucinated -- no such method on ScrollController}Symptom — real flutter analyze diagnostics, captured fresh:
error • The named parameter 'cornerRadius' isn't defined. Try correcting the name to an existingnamed parameter's name, or defining a named parameter with the name 'cornerRadius' •undefined_named_parametererror • The method 'animateToEnd' isn't defined for the type 'ScrollController'. Try correcting thename to the name of an existing method, or defining a method named 'animateToEnd' • undefined_methodFix, analyzes clean:
import 'package:flutter/material.dart';
Widget fixedCard() { return Card( elevation: 4, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: const Text('hi'), );}
void fixedScroll(ScrollController controller) { controller.animateTo( controller.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, );}Caught by: flutter analyze every time — the cheapest, most mechanical entry in this whole catalog to catch, and exactly why “did you even run flutter analyze” is the first line of the Review Checklist that follows this lesson.
Mental Model
Section titled “Mental Model”flowchart LR A["State & lifecycle"] --> R["Needs a real dispose/rebuild sequence<br/>to surface at all"] W["Widget tree & keys"] --> T["Mostly human review<br/>(renders correctly either way)"] L["Layout"] --> T2["Framework catches it loudly --<br/>but only the exact arrangement that overflows"] As["Async & isolates"] --> R N["Navigation"] --> T P["Platform"] --> D["Needs the actual platform/runtime --<br/>compiles and analyzes clean otherwise"] Pf["Performance"] --> M["Silent cost, not a crash --<br/>needs reading RenderObject properties directly"] Tl["Tooling & release"] --> M2["Analyzer/lint catches SOME --<br/>only if the rule is actually enabled"]