Fix bugs found in full code audit

- Edit screens (note, task): cache _initFuture in initState instead of
  calling _loadExisting() from FutureBuilder on every rebuild. Prevents
  race condition where multiple concurrent loads could overwrite edits.
  Also removes unnecessary _loaded flag and simplifies the pattern.

- Note editor: remove onChanged: (_) => setState((){}) on the body
  TextField — triggered a full rebuild on every keystroke for no reason.

- SSE streaming: use utf8.decode(chunk, allowMalformed: true) so a
  malformed byte sequence from the server skips rather than throwing an
  unhandled FormatException that escaped all catch blocks.

- Chat provider: guard msgs.isEmpty before accessing msgs.last during
  SSE chunk processing to prevent a potential index error on empty state.

- Offline queue drain: check mounted before and after each async gap in
  _drainQueue so ref is never accessed on a disposed widget.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 11:56:26 -05:00
parent 647b36837e
commit eef101bef3
5 changed files with 30 additions and 18 deletions
+4 -1
View File
@@ -245,12 +245,15 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
}
Future<void> _drainQueue() async {
if (!mounted) return;
final queue = ref.read(captureQueueProvider);
if (queue.isEmpty) return;
final api = ref.read(quickCaptureApiProvider);
for (final text in List<String>.from(queue)) {
if (!mounted) break;
try {
final result = await api.capture(text);
if (!mounted) break;
await ref.read(captureQueueProvider.notifier).dequeue(text);
switch (result.type) {
case 'note':
@@ -263,7 +266,7 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
break; // Still offline — stop draining.
} catch (_) {
// Server/parse error — remove to avoid infinite retries.
await ref.read(captureQueueProvider.notifier).dequeue(text);
if (mounted) await ref.read(captureQueueProvider.notifier).dequeue(text);
}
}
}
+1 -1
View File
@@ -92,7 +92,7 @@ class ChatApi {
String currentEvent = '';
await for (final chunk in stream) {
buf.write(utf8.decode(chunk));
buf.write(utf8.decode(chunk, allowMalformed: true));
final raw = buf.toString();
final lines = raw.split('\n');
+1
View File
@@ -90,6 +90,7 @@ class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
await for (final chunk in repo.streamGeneration(convId)) {
streamedContent = true;
final msgs = state.requireValue;
if (msgs.isEmpty) continue;
final updated = msgs.last.copyWith(content: msgs.last.content + chunk);
state = AsyncData([...msgs.sublist(0, msgs.length - 1), updated]);
}
+11 -8
View File
@@ -20,7 +20,16 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
final _contentController = TextEditingController();
bool _preview = false;
bool _saving = false;
bool _loaded = false;
// Future is created once in initState so FutureBuilder never restarts it.
late final Future<void> _initFuture;
@override
void initState() {
super.initState();
_initFuture =
widget.noteId != null ? _loadExisting() : Future.value();
}
@override
void dispose() {
@@ -30,15 +39,10 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
}
Future<void> _loadExisting() async {
if (_loaded || widget.noteId == null) {
_loaded = true;
return;
}
final note =
await ref.read(notesRepositoryProvider).getOne(widget.noteId!);
_titleController.text = note.title;
_contentController.text = note.body;
_loaded = true;
}
Future<void> _delete() async {
@@ -97,7 +101,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _loadExisting(),
future: _initFuture,
builder: (context, snapshot) {
return Scaffold(
appBar: AppBar(
@@ -159,7 +163,6 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
expands: true,
keyboardType: TextInputType.multiline,
textAlignVertical: TextAlignVertical.top,
onChanged: (_) => setState(() {}),
),
),
),
+13 -8
View File
@@ -23,7 +23,16 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
TaskPriority _priority = TaskPriority.medium;
DateTime? _dueDate;
bool _saving = false;
bool _loaded = false;
// Future is created once in initState so FutureBuilder never restarts it.
late final Future<void> _initFuture;
@override
void initState() {
super.initState();
_initFuture =
widget.taskId != null ? _loadExisting() : Future.value();
}
@override
void dispose() {
@@ -33,17 +42,13 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
}
Future<void> _loadExisting() async {
if (_loaded || widget.taskId == null) {
_loaded = true;
return;
}
final task = await ref.read(tasksRepositoryProvider).getOne(widget.taskId!);
final task =
await ref.read(tasksRepositoryProvider).getOne(widget.taskId!);
_titleController.text = task.title;
_descController.text = task.description ?? '';
_status = task.status;
_priority = task.priority;
_dueDate = task.dueDate;
_loaded = true;
}
Future<void> _save() async {
@@ -117,7 +122,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _loadExisting(),
future: _initFuture,
builder: (context, snapshot) {
return Scaffold(
appBar: AppBar(