@pilllesss/yorn 1.0.182 → 1.0.183
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of @pilllesss/yorn might be problematic. Click here for more details.
- package/README.md +1 -1
- package/dist/providers/data/.manifest.json +1 -1
- package/dist/skills/code-review/LICENSE +21 -0
- package/dist/skills/code-review/SKILL.md +233 -0
- package/dist/skills/code-review/assets/pr-review-template.md +137 -0
- package/dist/skills/code-review/assets/review-checklist.md +123 -0
- package/dist/skills/code-review/reference/angular.md +768 -0
- package/dist/skills/code-review/reference/architecture-review-guide.md +472 -0
- package/dist/skills/code-review/reference/c.md +890 -0
- package/dist/skills/code-review/reference/code-quality-universal.md +488 -0
- package/dist/skills/code-review/reference/code-review-best-practices.md +136 -0
- package/dist/skills/code-review/reference/common-bugs-checklist.md +302 -0
- package/dist/skills/code-review/reference/cpp.md +893 -0
- package/dist/skills/code-review/reference/cross-cutting/async-concurrency-patterns.md +515 -0
- package/dist/skills/code-review/reference/cross-cutting/error-handling-principles.md +492 -0
- package/dist/skills/code-review/reference/cross-cutting/n-plus-one-queries.md +309 -0
- package/dist/skills/code-review/reference/cross-cutting/sql-injection-prevention.md +308 -0
- package/dist/skills/code-review/reference/cross-cutting/xss-prevention.md +264 -0
- package/dist/skills/code-review/reference/csharp.md +525 -0
- package/dist/skills/code-review/reference/css-less-sass.md +661 -0
- package/dist/skills/code-review/reference/dart.md +670 -0
- package/dist/skills/code-review/reference/django.md +985 -0
- package/dist/skills/code-review/reference/fastapi.md +580 -0
- package/dist/skills/code-review/reference/go.md +993 -0
- package/dist/skills/code-review/reference/java.md +409 -0
- package/dist/skills/code-review/reference/java8.md +586 -0
- package/dist/skills/code-review/reference/kotlin.md +1018 -0
- package/dist/skills/code-review/reference/nestjs.md +593 -0
- package/dist/skills/code-review/reference/performance-review-guide.md +816 -0
- package/dist/skills/code-review/reference/php.md +684 -0
- package/dist/skills/code-review/reference/python.md +1073 -0
- package/dist/skills/code-review/reference/qt.md +757 -0
- package/dist/skills/code-review/reference/react.md +871 -0
- package/dist/skills/code-review/reference/ruby.md +964 -0
- package/dist/skills/code-review/reference/rust.md +846 -0
- package/dist/skills/code-review/reference/security-review-guide.md +494 -0
- package/dist/skills/code-review/reference/svelte.md +1064 -0
- package/dist/skills/code-review/reference/swift.md +936 -0
- package/dist/skills/code-review/reference/typescript.md +1016 -0
- package/dist/skills/code-review/reference/vue.md +924 -0
- package/dist/skills/code-review/reference/zig.md +440 -0
- package/dist/skills/code-review/scripts/pr-analyzer.py +435 -0
- package/dist/skills/code-review/scripts/test_pr_analyzer.py +380 -0
- package/dist/yorn.cjs +628 -628
- package/package.json +2 -2
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
# Dart / Flutter Code Review Guide
|
|
2
|
+
|
|
3
|
+
> Code review guidelines for Dart 3 and Flutter focusing on widget rebuilds, const constructors, null safety, isolates, async in `build`, Riverpod/Bloc state pitfalls, platform channels, keys, disposal, and testability. Not a language tutorial.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Widget Rebuilds & Const Constructors](#widget-rebuilds--const-constructors)
|
|
8
|
+
- [Null Safety & `late`](#null-safety--late)
|
|
9
|
+
- [Isolates](#isolates)
|
|
10
|
+
- [Async in `build`](#async-in-build)
|
|
11
|
+
- [State Management: Riverpod & Bloc](#state-management-riverpod--bloc)
|
|
12
|
+
- [Platform Channels](#platform-channels)
|
|
13
|
+
- [Keys](#keys)
|
|
14
|
+
- [Disposal & Lifecycle](#disposal--lifecycle)
|
|
15
|
+
- [Testability](#testability)
|
|
16
|
+
- [Review Checklist](#review-checklist)
|
|
17
|
+
- [References](#references)
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Widget Rebuilds & Const Constructors
|
|
22
|
+
|
|
23
|
+
Flutter rebuilds are cheap only when the Element tree can skip work. A review should flag widgets that allocate new subtrees, closures, or Theme lookups on every frame when a `const` constructor or an extracted widget would keep the child Element.
|
|
24
|
+
|
|
25
|
+
### Prefer `const` constructors where the subtree is static
|
|
26
|
+
|
|
27
|
+
```dart
|
|
28
|
+
// ❌ Bad: every parent rebuild allocates a new Text and Icon.
|
|
29
|
+
Widget build(BuildContext context) {
|
|
30
|
+
return Row(
|
|
31
|
+
children: [
|
|
32
|
+
Icon(Icons.star),
|
|
33
|
+
Text('Favorites'),
|
|
34
|
+
],
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ✅ Good: const widgets can be canonicalized and skipped on rebuild.
|
|
39
|
+
Widget build(BuildContext context) {
|
|
40
|
+
return const Row(
|
|
41
|
+
children: [
|
|
42
|
+
Icon(Icons.star),
|
|
43
|
+
Text('Favorites'),
|
|
44
|
+
],
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Review questions:
|
|
50
|
+
- Are leaf widgets (`Text`, `Icon`, `SizedBox`, `Padding` with constant insets) `const`?
|
|
51
|
+
- Does a missing `const` on a parent block `const` on an entire subtree?
|
|
52
|
+
- Is `const` omitted because a single non-const argument (color from `Theme.of`, a closure) poisons the constructor?
|
|
53
|
+
|
|
54
|
+
### Extract widgets, not helper methods, when state should be isolated
|
|
55
|
+
|
|
56
|
+
A private method that returns a `Widget` is inlined into the caller's `build`. The returned widgets have no Element identity of their own, so they rebuild whenever the caller rebuilds.
|
|
57
|
+
|
|
58
|
+
```dart
|
|
59
|
+
class ProfilePage extends StatelessWidget {
|
|
60
|
+
const ProfilePage({super.key, required this.user});
|
|
61
|
+
final User user;
|
|
62
|
+
|
|
63
|
+
// ❌ Bad: `_header()` rebuilds with ProfilePage even when `user` is unchanged.
|
|
64
|
+
Widget _header() => Header(title: user.name);
|
|
65
|
+
|
|
66
|
+
@override
|
|
67
|
+
Widget build(BuildContext context) {
|
|
68
|
+
return Column(children: [_header(), const Feed()]);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ✅ Good: a separate widget gets its own Element; Feed stays const.
|
|
73
|
+
class ProfilePage extends StatelessWidget {
|
|
74
|
+
const ProfilePage({super.key, required this.user});
|
|
75
|
+
final User user;
|
|
76
|
+
|
|
77
|
+
@override
|
|
78
|
+
Widget build(BuildContext context) {
|
|
79
|
+
return Column(
|
|
80
|
+
children: [
|
|
81
|
+
Header(title: user.name),
|
|
82
|
+
const Feed(),
|
|
83
|
+
],
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Do not call `setState` for derived values
|
|
90
|
+
|
|
91
|
+
```dart
|
|
92
|
+
// ❌ Bad: derived data stored in State, forcing a rebuild to recompute a getter.
|
|
93
|
+
class CartBadge extends StatefulWidget { /* ... */ }
|
|
94
|
+
|
|
95
|
+
class _CartBadgeState extends State<CartBadge> {
|
|
96
|
+
int _count = 0;
|
|
97
|
+
|
|
98
|
+
void didUpdateWidget(CartBadge old) {
|
|
99
|
+
super.didUpdateWidget(old);
|
|
100
|
+
setState(() => _count = widget.items.length);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ✅ Good: derive in build; setState only when the source data changes.
|
|
105
|
+
class CartBadge extends StatelessWidget {
|
|
106
|
+
const CartBadge({super.key, required this.items});
|
|
107
|
+
final List<Item> items;
|
|
108
|
+
|
|
109
|
+
@override
|
|
110
|
+
Widget build(BuildContext context) {
|
|
111
|
+
return Text('${items.length}');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Rebuild scope
|
|
117
|
+
|
|
118
|
+
- `setState` on a high `State` object rebuilds that subtree. Prefer lifting only the state that must be shared, and wrapping expensive children in `RepaintBoundary` or extracting them.
|
|
119
|
+
- `ListView(children: [...])` builds every child. Prefer `ListView.builder` / `SliverList` for long lists.
|
|
120
|
+
- Passing a newly allocated `List`/`Map` or a new callback instance into a child that is otherwise `const`-eligible defeats child skip. Capture stable callbacks (`void Function()` stored on State) or use `Widget.canUpdate` identity via `const` / extracted widgets.
|
|
121
|
+
|
|
122
|
+
For general UI performance patterns see [Performance Review Guide](performance-review-guide.md).
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Null Safety & `late`
|
|
127
|
+
|
|
128
|
+
Dart's null safety is only as strong as the holes the code leaves (`!`, `late`, `as`). Treat those as review signals, not as style.
|
|
129
|
+
|
|
130
|
+
### Avoid `!` on values the compiler cannot prove
|
|
131
|
+
|
|
132
|
+
```dart
|
|
133
|
+
// ❌ Bad: bang operator hides a possible null and crashes at runtime.
|
|
134
|
+
void openProfile(User? user) {
|
|
135
|
+
Navigator.pushNamed(context, '/user/${user!.id}');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ✅ Good: promote with a local or return early.
|
|
139
|
+
void openProfile(User? user) {
|
|
140
|
+
final id = user?.id;
|
|
141
|
+
if (id == null) return;
|
|
142
|
+
Navigator.pushNamed(context, '/user/$id');
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### `late` is a delayed crash, not a type
|
|
147
|
+
|
|
148
|
+
`late` without an initializer throws `LateInitializationError` on first read. `late final` with an initializer is lazy and is the usual legitimate case (expensive, once).
|
|
149
|
+
|
|
150
|
+
```dart
|
|
151
|
+
class Session {
|
|
152
|
+
// ❌ Bad: `late` field is read from another method with no constructor guarantee.
|
|
153
|
+
late String token;
|
|
154
|
+
|
|
155
|
+
bool get isReady => token.isNotEmpty;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
class Session {
|
|
159
|
+
// ✅ Good: nullable until assigned; API makes absence visible.
|
|
160
|
+
String? token;
|
|
161
|
+
|
|
162
|
+
bool get isReady => token != null && token!.isNotEmpty;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
class ThemeCache {
|
|
166
|
+
// ✅ Good: lazy `late final` with initializer; first read computes once.
|
|
167
|
+
late final Map<String, Color> _colors = _loadColors();
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Review questions:
|
|
172
|
+
- Is `late` used because the author did not want `?` / a constructor argument?
|
|
173
|
+
- Is a `late` field assigned in `initState` but read from a listener that can fire earlier (animation, platform callback)?
|
|
174
|
+
- Are `as Foo` casts used where a pattern (`if (x case final Foo foo)`) or `is` promotion would fail loudly and locally?
|
|
175
|
+
|
|
176
|
+
### Fields do not promote
|
|
177
|
+
|
|
178
|
+
```dart
|
|
179
|
+
class ProfileTile extends StatelessWidget {
|
|
180
|
+
const ProfileTile({super.key, required this.user});
|
|
181
|
+
final User? user;
|
|
182
|
+
|
|
183
|
+
@override
|
|
184
|
+
Widget build(BuildContext context) {
|
|
185
|
+
// ❌ Bad: field promotion does not apply; this is a lint (`unchecked_use_of_nullable_value`).
|
|
186
|
+
// return Text(user.name);
|
|
187
|
+
|
|
188
|
+
// ✅ Good: promote a local.
|
|
189
|
+
final user = this.user;
|
|
190
|
+
if (user == null) return const SizedBox.shrink();
|
|
191
|
+
return Text(user.name);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Null-related crashes are still crashes. See [Error Handling Guide](cross-cutting/error-handling-principles.md) for fail-fast vs. swallowing.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Isolates
|
|
201
|
+
|
|
202
|
+
> 📖 Cross-language concurrency patterns: [Async & Concurrency Guide](cross-cutting/async-concurrency-patterns.md)
|
|
203
|
+
|
|
204
|
+
Flutter's UI isolate must stay free of heavy JSON, image, and crypto work. Dart isolates do not share memory; messages must be sendable.
|
|
205
|
+
|
|
206
|
+
### Do not block the UI isolate
|
|
207
|
+
|
|
208
|
+
```dart
|
|
209
|
+
// ❌ Bad: large JSON decode on the UI isolate janks frames.
|
|
210
|
+
final users = (jsonDecode(raw) as List)
|
|
211
|
+
.map((e) => User.fromJson(e as Map<String, dynamic>))
|
|
212
|
+
.toList();
|
|
213
|
+
|
|
214
|
+
// ✅ Good: `Isolate.run` (Dart 2.19+) / `compute` for one-shot work.
|
|
215
|
+
final users = await Isolate.run(() {
|
|
216
|
+
return (jsonDecode(raw) as List)
|
|
217
|
+
.map((e) => User.fromJson(e as Map<String, dynamic>))
|
|
218
|
+
.toList();
|
|
219
|
+
});
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Message restrictions
|
|
223
|
+
|
|
224
|
+
```dart
|
|
225
|
+
// ❌ Bad: closures, ReceivePorts, and many plugin types are not sendable.
|
|
226
|
+
await Isolate.run(() => widget.onParsed());
|
|
227
|
+
|
|
228
|
+
// ✅ Good: send plain data; map back to UI types on the main isolate.
|
|
229
|
+
final dto = await Isolate.run(() => parseReport(bytes));
|
|
230
|
+
setState(() => report = Report.fromDto(dto));
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Review questions:
|
|
234
|
+
- Is `jsonDecode` / image decode / encryption of non-trivial payloads on the UI isolate?
|
|
235
|
+
- Does the isolate callback capture `BuildContext`, `State`, or plugin controllers?
|
|
236
|
+
- If a long-lived isolate is spawned, is `kill` / `pause` paired with widget disposal?
|
|
237
|
+
- Are isolate failures surfaced, or does `await Isolate.run` lack error handling?
|
|
238
|
+
|
|
239
|
+
`compute` from `flutter/foundation.dart` is a thin wrapper; prefer `Isolate.run` in Dart-only code. Neither is a thread pool — spawning per tiny call is more expensive than doing the work inline.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Async in `build`
|
|
244
|
+
|
|
245
|
+
`build` must stay synchronous and side-effect free. Futures created in `build` restart on every rebuild.
|
|
246
|
+
|
|
247
|
+
### Do not start I/O or create a new `Future` in `build`
|
|
248
|
+
|
|
249
|
+
```dart
|
|
250
|
+
// ❌ Bad: new Future every rebuild; the previous request is abandoned.
|
|
251
|
+
Widget build(BuildContext context) {
|
|
252
|
+
return FutureBuilder<Profile>(
|
|
253
|
+
future: api.fetchProfile(id),
|
|
254
|
+
builder: (context, snap) { /* ... */ },
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ✅ Good: cache the Future in State; refetch when `id` changes.
|
|
259
|
+
class ProfileView extends StatefulWidget {
|
|
260
|
+
const ProfileView({super.key, required this.id});
|
|
261
|
+
final String id;
|
|
262
|
+
@override
|
|
263
|
+
State<ProfileView> createState() => _ProfileViewState();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
class _ProfileViewState extends State<ProfileView> {
|
|
267
|
+
late Future<Profile> _future = api.fetchProfile(widget.id);
|
|
268
|
+
|
|
269
|
+
@override
|
|
270
|
+
void didUpdateWidget(ProfileView oldWidget) {
|
|
271
|
+
super.didUpdateWidget(oldWidget);
|
|
272
|
+
if (oldWidget.id != widget.id) {
|
|
273
|
+
_future = api.fetchProfile(widget.id);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
@override
|
|
278
|
+
Widget build(BuildContext context) {
|
|
279
|
+
return FutureBuilder<Profile>(
|
|
280
|
+
future: _future,
|
|
281
|
+
builder: (context, snap) { /* ... */ },
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Keying `ProfileView` with `ValueKey(id)` also remounts State and starts a new fetch. Either approach is fine; a cached `late final` future with no `didUpdateWidget` goes stale when `id` changes.
|
|
288
|
+
|
|
289
|
+
The same rule applies to `StreamBuilder`: do not open a new stream in `build`. Create it once (`initState`, a provider, a Bloc) and cancel on dispose.
|
|
290
|
+
|
|
291
|
+
### `setState` after `await` must check `mounted`
|
|
292
|
+
|
|
293
|
+
```dart
|
|
294
|
+
Future<void> _load() async {
|
|
295
|
+
final data = await api.fetch();
|
|
296
|
+
// ❌ Bad: widget may have been disposed during the await.
|
|
297
|
+
setState(() => _data = data);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
Future<void> _load() async {
|
|
301
|
+
final data = await api.fetch();
|
|
302
|
+
if (!mounted) return;
|
|
303
|
+
setState(() => _data = data);
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Use `context.mounted` (Flutter 3.7+) before using `BuildContext` after an async gap (`Navigator`, `ScaffoldMessenger`, `showDialog`). The `use_build_context_synchronously` lint exists because this is a crash class, not a style issue.
|
|
308
|
+
|
|
309
|
+
### Do not mark `build` `async`
|
|
310
|
+
|
|
311
|
+
```dart
|
|
312
|
+
// ❌ Bad: build cannot be async; this does not compile, or a helper is abused.
|
|
313
|
+
Widget build(BuildContext context) async {
|
|
314
|
+
final user = await repo.user();
|
|
315
|
+
return Text(user.name);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ✅ Good: hold async state in State / a notifier; build only reads it.
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Fire-and-forget `unawaited(load())` inside `build` is the same bug with extra steps.
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## State Management: Riverpod & Bloc
|
|
326
|
+
|
|
327
|
+
These examples use Riverpod and Bloc because they dominate Flutter reviews. The same pitfalls apply to Provider, GetX, Signals, and raw `InheritedWidget`: rebuild scope, side effects in build, and lifetime.
|
|
328
|
+
|
|
329
|
+
### Riverpod: `watch` vs `read`, and rebuild scope
|
|
330
|
+
|
|
331
|
+
```dart
|
|
332
|
+
class CartButton extends ConsumerWidget {
|
|
333
|
+
const CartButton({super.key});
|
|
334
|
+
|
|
335
|
+
@override
|
|
336
|
+
Widget build(BuildContext context, WidgetRef ref) {
|
|
337
|
+
// ❌ Bad: watches the whole cart; button rebuilds on every item mutation.
|
|
338
|
+
final cart = ref.watch(cartProvider);
|
|
339
|
+
|
|
340
|
+
// ✅ Good: watch only the field this widget needs.
|
|
341
|
+
final count = ref.watch(cartProvider.select((c) => c.items.length));
|
|
342
|
+
return Badge(label: Text('$count'));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
```dart
|
|
348
|
+
void onPressed() {
|
|
349
|
+
// ❌ Bad: `watch` in a callback / listener; it is not a rebuild subscription.
|
|
350
|
+
// ref.watch(cartProvider).add(item);
|
|
351
|
+
|
|
352
|
+
// ✅ Good: `read` for one-shot, `watch`/`select` only in `build` / `build` of a listen widget.
|
|
353
|
+
ref.read(cartProvider.notifier).add(item);
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Review questions:
|
|
358
|
+
- Is `ref.watch` used in `initState`, a gesture handler, or a `Provider`'s constructor?
|
|
359
|
+
- Does a widget `watch` a large object when `select` / a derived provider would do?
|
|
360
|
+
- Are providers that capture `ref` after `dispose` (`ref.onDispose` missing for controllers, streams)?
|
|
361
|
+
- Is `autoDispose` omitted on screen-scoped providers so they leak after pop?
|
|
362
|
+
|
|
363
|
+
### Bloc: `create` vs `value`, `buildWhen`, closed cubits
|
|
364
|
+
|
|
365
|
+
`BlocProvider(create: ...)` inside `build` is the documented pattern: `create` runs once per Element, not on every rebuild. `updateShouldNotify` does not control `create`. The bug is constructing a new bloc instance in `build` and handing it to `BlocProvider.value` (no ownership, no dispose, new object every rebuild). Because `create` captures `id` once, a parent that later passes a new `id` will not reload unless the Element remounts or you dispatch an update.
|
|
366
|
+
|
|
367
|
+
```dart
|
|
368
|
+
// ❌ Bad: new ProfileBloc() every rebuild; `value` does not dispose it.
|
|
369
|
+
Widget build(BuildContext context) {
|
|
370
|
+
return BlocProvider.value(
|
|
371
|
+
value: ProfileBloc()..add(ProfileStarted(id)),
|
|
372
|
+
child: const ProfileBody(),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ❌ Bad: UniqueKey remounts every rebuild — `create` runs again each time.
|
|
377
|
+
Widget build(BuildContext context) {
|
|
378
|
+
return BlocProvider(
|
|
379
|
+
key: UniqueKey(),
|
|
380
|
+
create: (_) => ProfileBloc()..add(ProfileStarted(id)),
|
|
381
|
+
child: const ProfileBody(),
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ✅ Good: `create` owns one instance; ValueKey(id) remounts only when id changes.
|
|
386
|
+
class ProfilePage extends StatelessWidget {
|
|
387
|
+
const ProfilePage({super.key, required this.id});
|
|
388
|
+
final String id;
|
|
389
|
+
|
|
390
|
+
@override
|
|
391
|
+
Widget build(BuildContext context) {
|
|
392
|
+
return BlocProvider(
|
|
393
|
+
key: ValueKey(id),
|
|
394
|
+
create: (_) => ProfileBloc()..add(ProfileStarted(id)),
|
|
395
|
+
child: const ProfileBody(),
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Alternatively, keep the same bloc and in `didUpdateWidget` dispatch `ProfileRequested(id)` when `id` changes — do not construct a replacement `ProfileBloc()` there.
|
|
402
|
+
|
|
403
|
+
```dart
|
|
404
|
+
// ❌ Bad: listener work inside builder; also rebuilds on every state.
|
|
405
|
+
BlocBuilder<ProfileBloc, ProfileState>(
|
|
406
|
+
builder: (context, state) {
|
|
407
|
+
if (state is ProfileFailure) {
|
|
408
|
+
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
409
|
+
ScaffoldMessenger.of(context).showSnackBar(/* ... */);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
return ProfileBody(state: state);
|
|
413
|
+
},
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
// ✅ Good: side effects in BlocListener; rebuilds filtered with buildWhen.
|
|
417
|
+
BlocConsumer<ProfileBloc, ProfileState>(
|
|
418
|
+
listenWhen: (p, c) => c is ProfileFailure && p is! ProfileFailure,
|
|
419
|
+
listener: (context, state) {
|
|
420
|
+
final message = (state as ProfileFailure).message;
|
|
421
|
+
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
|
422
|
+
},
|
|
423
|
+
buildWhen: (p, c) => p.user != c.user || p.loading != c.loading,
|
|
424
|
+
builder: (context, state) => ProfileBody(state: state),
|
|
425
|
+
)
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
Review questions:
|
|
429
|
+
- Is a `Cubit`/`Bloc` closed (`BlocProvider` does this if `lazy`/`create` owns it; a manually constructed bloc must `close`)?
|
|
430
|
+
- Are events dispatched from `build`?
|
|
431
|
+
- Is `Equatable` / `==` missing on state so every `emit` rebuilds even when fields did not change?
|
|
432
|
+
- Is business logic in the widget (`context.read<FooBloc>().add` mixed with parsing, I/O) instead of the bloc?
|
|
433
|
+
|
|
434
|
+
Do not treat GetX `Obx` / `GetBuilder` as exempt: the same "who owns the controller, who rebuilds, who disposes" questions apply.
|
|
435
|
+
|
|
436
|
+
---
|
|
437
|
+
|
|
438
|
+
## Platform Channels
|
|
439
|
+
|
|
440
|
+
Method channels, event channels, and Pigeon are a trust and lifetime boundary. Failures look like "missing plugin" at runtime, not at compile time.
|
|
441
|
+
|
|
442
|
+
```dart
|
|
443
|
+
// ❌ Bad: untyped maps, ignored errors, UI isolate blocked on a heavy native call.
|
|
444
|
+
final result = await MethodChannel('app.wallet').invokeMethod('sign', {'tx': raw});
|
|
445
|
+
final signature = result as String;
|
|
446
|
+
|
|
447
|
+
// ✅ Good: typed API (Pigeon or a wrapper), errors handled, no UI-thread assumption.
|
|
448
|
+
try {
|
|
449
|
+
final signature = await walletHost.sign(SignRequest(tx: raw));
|
|
450
|
+
if (!mounted) return;
|
|
451
|
+
onSigned(signature);
|
|
452
|
+
} on PlatformException catch (e) {
|
|
453
|
+
onSignFailed(e.code, e.message);
|
|
454
|
+
}
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
Review questions:
|
|
458
|
+
- Is the channel name a collision-prone generic string (`app`, `native`) without a domain prefix?
|
|
459
|
+
- Are Android/iOS implementations of the same method covering all argument types and null?
|
|
460
|
+
- Does the Dart side assume the plugin is registered (tests, background isolates, and add-to-app engines often are not)?
|
|
461
|
+
- Is binary data passed as `List<int>` copies instead of `Uint8List` / Pigeon bytes?
|
|
462
|
+
- Are EventChannel subscriptions cancelled in `dispose`?
|
|
463
|
+
- Does native code do disk/network work on the platform main thread?
|
|
464
|
+
|
|
465
|
+
Platform data is untrusted input: validate it the same way you would a network payload. See [Security Review Guide](security-review-guide.md).
|
|
466
|
+
|
|
467
|
+
---
|
|
468
|
+
|
|
469
|
+
## Keys
|
|
470
|
+
|
|
471
|
+
Keys preserve `State` / `Element` identity across rebuilds. Wrong keys cause state to attach to the wrong row; missing keys cause state to reset when the list reorders.
|
|
472
|
+
|
|
473
|
+
```dart
|
|
474
|
+
// ❌ Bad: no keys; after delete/reorder, TextField state sticks to the index.
|
|
475
|
+
ListView(
|
|
476
|
+
children: items.map((item) => TodoRow(item: item)).toList(),
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
// ❌ Bad: UniqueKey() in build: every rebuild is a new identity; state is always reset.
|
|
480
|
+
TodoRow(key: UniqueKey(), item: item)
|
|
481
|
+
|
|
482
|
+
// ✅ Good: a stable id from the model.
|
|
483
|
+
ListView(
|
|
484
|
+
children: [
|
|
485
|
+
for (final item in items)
|
|
486
|
+
TodoRow(key: ValueKey(item.id), item: item),
|
|
487
|
+
],
|
|
488
|
+
)
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
```dart
|
|
492
|
+
// ❌ Bad: GlobalKey created in `build` — a new key every rebuild, plus GlobalKey cost.
|
|
493
|
+
Widget build(BuildContext context) {
|
|
494
|
+
final key = GlobalKey<FormState>();
|
|
495
|
+
return Form(key: key, child: /* ... */);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ✅ Good: GlobalKey is rare; hold it on State when FormState is actually needed.
|
|
499
|
+
class _EditorState extends State<Editor> {
|
|
500
|
+
final _formKey = GlobalKey<FormState>();
|
|
501
|
+
@override
|
|
502
|
+
Widget build(BuildContext context) => Form(key: _formKey, child: /* ... */);
|
|
503
|
+
}
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
Review questions:
|
|
507
|
+
- Do reorderable / dismissible / animated lists use `ValueKey` / `ObjectKey` from a stable model id?
|
|
508
|
+
- Is `GlobalKey` used to reach into a child's `State` when a callback / `ValueNotifier` would do?
|
|
509
|
+
- Are keys on widgets whose `runtimeType` already uniquely identifies them (usually unnecessary)?
|
|
510
|
+
|
|
511
|
+
---
|
|
512
|
+
|
|
513
|
+
## Disposal & Lifecycle
|
|
514
|
+
|
|
515
|
+
Anything with `addListener`, a subscription, a ticker, or a native peer needs a matching `dispose`. Flutter will not save you.
|
|
516
|
+
|
|
517
|
+
```dart
|
|
518
|
+
class SearchField extends StatefulWidget {
|
|
519
|
+
const SearchField({super.key});
|
|
520
|
+
@override
|
|
521
|
+
State<SearchField> createState() => _SearchFieldState();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
class _SearchFieldState extends State<SearchField> with SingleTickerProviderStateMixin {
|
|
525
|
+
late final TextEditingController _controller;
|
|
526
|
+
late final AnimationController _anim;
|
|
527
|
+
StreamSubscription<Query>? _sub;
|
|
528
|
+
|
|
529
|
+
@override
|
|
530
|
+
void initState() {
|
|
531
|
+
super.initState();
|
|
532
|
+
_controller = TextEditingController();
|
|
533
|
+
_anim = AnimationController(vsync: this, duration: const Duration(milliseconds: 200));
|
|
534
|
+
_sub = queryStream.listen((q) {
|
|
535
|
+
if (mounted) _controller.text = q.text;
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
@override
|
|
540
|
+
void dispose() {
|
|
541
|
+
_sub?.cancel();
|
|
542
|
+
_anim.dispose();
|
|
543
|
+
_controller.dispose();
|
|
544
|
+
super.dispose();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
@override
|
|
548
|
+
Widget build(BuildContext context) => TextField(controller: _controller);
|
|
549
|
+
}
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
Review questions:
|
|
553
|
+
- `TextEditingController`, `ScrollController`, `FocusNode`, `AnimationController`, `TabController`, `PageController` — created? disposed?
|
|
554
|
+
- `StreamSubscription`, `Timer`, `ChangeNotifier` listeners, `WidgetsBindingObserver` — removed?
|
|
555
|
+
- `AnimationController` uses a `TickerProvider` that dies with the `State` (`SingleTickerProviderStateMixin`), not a leaked vsync?
|
|
556
|
+
- Route-level controllers created in `BlocProvider`/`Provider` — `dispose` callback set?
|
|
557
|
+
- `Image.network` / video / camera controllers stopped when the route is covered, not only when popped?
|
|
558
|
+
|
|
559
|
+
`dispose` must not use `BuildContext` (the element is unmounted). Do not call `setState` there.
|
|
560
|
+
|
|
561
|
+
---
|
|
562
|
+
|
|
563
|
+
## Testability
|
|
564
|
+
|
|
565
|
+
Review the tests the same way as the product code. Flutter tests that pump the whole app to assert a string are a smell that logic is trapped in widgets.
|
|
566
|
+
|
|
567
|
+
```dart
|
|
568
|
+
// ❌ Bad: parsing and I/O live in the widget; tests must pump and mock everything.
|
|
569
|
+
class PriceLabel extends StatelessWidget {
|
|
570
|
+
const PriceLabel({super.key, required this.raw});
|
|
571
|
+
final String raw;
|
|
572
|
+
|
|
573
|
+
@override
|
|
574
|
+
Widget build(BuildContext context) {
|
|
575
|
+
final n = NumberFormat.simpleCurrency().parse(raw);
|
|
576
|
+
return Text(n.toString());
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ✅ Good: pure function / mapper is unit-tested; widget only renders.
|
|
581
|
+
String formatPrice(String raw, NumberFormat format) => format.format(format.parse(raw));
|
|
582
|
+
|
|
583
|
+
class PriceLabel extends StatelessWidget {
|
|
584
|
+
const PriceLabel({super.key, required this.text});
|
|
585
|
+
final String text;
|
|
586
|
+
|
|
587
|
+
@override
|
|
588
|
+
Widget build(BuildContext context) => Text(text);
|
|
589
|
+
}
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
```dart
|
|
593
|
+
// ❌ Bad: test depends on real time and network.
|
|
594
|
+
testWidgets('loads profile', (tester) async {
|
|
595
|
+
await tester.pumpWidget(const ProfilePage(id: '1'));
|
|
596
|
+
await tester.pumpAndSettle();
|
|
597
|
+
expect(find.text('Ada'), findsOneWidget);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// ✅ Good: inject a fake; pump until the Future you control completes.
|
|
601
|
+
testWidgets('loads profile', (tester) async {
|
|
602
|
+
final api = FakeProfileApi()..completeWith(Profile(name: 'Ada'));
|
|
603
|
+
await tester.pumpWidget(ProfilePage(id: '1', api: api));
|
|
604
|
+
await tester.pump();
|
|
605
|
+
expect(find.text('Ada'), findsOneWidget);
|
|
606
|
+
});
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
Review questions:
|
|
610
|
+
- Can the new logic be tested with `flutter test` without `IntegrationTestWidgetsFlutterBinding`?
|
|
611
|
+
- Are `Key`s present on interactive widgets the test (and the reviewer) needs to find, without painting `UniqueKey` in `build`?
|
|
612
|
+
- Are platform channels mocked (`TestDefaultBinaryMessengerBinding`)?
|
|
613
|
+
- Does `pumpAndSettle` hang because an infinite animation / polling stream never idles?
|
|
614
|
+
- Is `late` in tests hiding missing setup?
|
|
615
|
+
|
|
616
|
+
---
|
|
617
|
+
|
|
618
|
+
## Review Checklist
|
|
619
|
+
|
|
620
|
+
### Widgets & rebuilds
|
|
621
|
+
- [ ] Static subtrees use `const` constructors
|
|
622
|
+
- [ ] Expensive children are extracted widgets, not `_buildFoo()` methods
|
|
623
|
+
- [ ] Long lists use lazy builders, not a fully-materialized `children:` list
|
|
624
|
+
- [ ] `setState` is not used to store values that can be derived in `build`
|
|
625
|
+
|
|
626
|
+
### Null safety & `late`
|
|
627
|
+
- [ ] `!` and `as` are not used to silence the type system
|
|
628
|
+
- [ ] `late` is lazy-init (`late final x = ...`) or truly guaranteed before first read
|
|
629
|
+
- [ ] Nullable fields are promoted via locals, not assumed non-null
|
|
630
|
+
|
|
631
|
+
### Isolates & async
|
|
632
|
+
- [ ] Heavy JSON / image / crypto work is off the UI isolate
|
|
633
|
+
- [ ] Isolate messages are sendable plain data
|
|
634
|
+
- [ ] `build` does not create Futures/Streams or start I/O
|
|
635
|
+
- [ ] `FutureBuilder`/`StreamBuilder` reuse a cached future/stream; refetch when the id/input changes
|
|
636
|
+
- [ ] `setState` / `BuildContext` after `await` check `mounted` / `context.mounted`
|
|
637
|
+
|
|
638
|
+
### State management
|
|
639
|
+
- [ ] `ref.watch` / `context.watch` only in `build`; `read` in callbacks
|
|
640
|
+
- [ ] Rebuilds are narrowed (`select`, `buildWhen`)
|
|
641
|
+
- [ ] Do not construct a `Bloc`/`Cubit`/`ChangeNotifier` in `build` and pass it to `*.value` — use `create`; key with `ValueKey(id)` (not `UniqueKey()`) or `didUpdateWidget` so a new id reloads
|
|
642
|
+
- [ ] Providers/blocs that own controllers are disposed (`autoDispose`, `close`)
|
|
643
|
+
|
|
644
|
+
### Platform, keys, disposal
|
|
645
|
+
- [ ] Platform channel calls handle `PlatformException` and missing plugins
|
|
646
|
+
- [ ] EventChannel / native subscriptions are cancelled
|
|
647
|
+
- [ ] List children that hold `State` have stable `ValueKey`s (not `UniqueKey()` in `build`)
|
|
648
|
+
- [ ] Controllers, tickers, and subscriptions are disposed; `dispose` does not use `context`
|
|
649
|
+
|
|
650
|
+
### Tests
|
|
651
|
+
- [ ] Domain logic is unit-tested without pumping widgets
|
|
652
|
+
- [ ] Widget tests inject fakes; they do not hit network or real platform channels
|
|
653
|
+
- [ ] `pumpAndSettle` is not used on never-idle animations
|
|
654
|
+
|
|
655
|
+
---
|
|
656
|
+
|
|
657
|
+
## References
|
|
658
|
+
|
|
659
|
+
- [Dart language tour (null safety)](https://dart.dev/null-safety)
|
|
660
|
+
- [Effective Dart](https://dart.dev/effective-dart)
|
|
661
|
+
- [Flutter performance: best practices](https://docs.flutter.dev/perf/best-practices)
|
|
662
|
+
- [FutureBuilder class](https://api.flutter.dev/flutter/widgets/FutureBuilder-class.html)
|
|
663
|
+
- [Isolate.run](https://api.dart.dev/stable/dart-isolate/Isolate/run.html)
|
|
664
|
+
- [Riverpod: Refs (`watch` vs `read`)](https://riverpod.dev/docs/concepts2/refs)
|
|
665
|
+
- [Bloc: BlocListener vs BlocBuilder](https://bloclibrary.dev/bloc-concepts/)
|
|
666
|
+
- [Key class](https://api.flutter.dev/flutter/foundation/Key-class.html)
|
|
667
|
+
- [Error Handling Guide](cross-cutting/error-handling-principles.md)
|
|
668
|
+
- [Async & Concurrency Guide](cross-cutting/async-concurrency-patterns.md)
|
|
669
|
+
- [Performance Review Guide](performance-review-guide.md)
|
|
670
|
+
- [Security Review Guide](security-review-guide.md)
|