fix(chat): guard _saveMessage against disposed Ref before accessing state
`_saveMessage` evaluated `_isInMemoryChat` (which reads `state`) before checking `ref.mounted`. On a disposed notifier, the `state` getter throws "Cannot use the Ref of NotifierProvider ... after it has been disposed", so the mounted check never ran and the app crashed. Reorder the guard so `ref.mounted` is checked first, then short-circuit on `_isInMemoryChat`. Also add a `ref.mounted` check between the two `_saveMessage` awaits and the subsequent `_isInMemoryChat` access in `sendMessage` (line 870 in the stack trace). Add a regression test that disposes the container and then invokes `_saveMessage` to confirm it no longer throws. Fixes #73 Co-Authored-By: Opus 4.8 <noreply@puku.sh>
abdulmominsakib committed
Aug 31, 2026 at 12:40 UTC
1b3cbb6de4038bff61d4c8fc7c967dad4b650ee6
2 files changed
+89
-1
lib/features/chat/providers/chat_notifier.dart
+13
-1
@@ -2,6 +2,7 @@ import 'dart:async';
2
import 'dart:io';
3
import 'dart:math';
4
5
+import 'package:flutter/foundation.dart';
6
import 'package:flutter_riverpod/flutter_riverpod.dart';
7
import 'package:localmind/core/logger/app_logger.dart';
8
import 'package:localmind/core/models/enums.dart';
@@ -869,6 +870,8 @@ class ChatNotifier extends Notifier<ChatState> {
870
await _saveMessage(userMessage);
871
await _saveMessage(assistantMessage);
872
873
+ if (!ref.mounted) return;
874
+
875
if (!_isInMemoryChat && _currentConversationId != null) {
876
final preview = trimmedContent.isNotEmpty
877
? (trimmedContent.length > 100
@@ -2567,7 +2570,10 @@ class ChatNotifier extends Notifier<ChatState> {
2570
}
2571
2572
Future<void> _saveMessage(Message message) async {
2570
- if (_isInMemoryChat || !ref.mounted) return;
2573
+ // Check `ref.mounted` before touching `state` (via `_isInMemoryChat`).
2574
+ // On a disposed notifier, accessing `state` throws "Cannot use the Ref of
2575
+ // NotifierProvider ... after it has been disposed" — see issue #73.
2576
+ if (!ref.mounted || _isInMemoryChat) return;
2577
final db = ref.read(databaseProvider);
2578
await db.store.runInTransactionAsync(
2579
TxMode.write,
@@ -2576,6 +2582,12 @@ class ChatNotifier extends Notifier<ChatState> {
2582
);
2583
}
2584
2585
+ /// Test-only hook that exposes [_saveMessage] so we can verify it is safe
2586
+ /// to call on a disposed notifier (issue #73).
2587
+ @visibleForTesting
2588
+ Future<void> debugSaveMessageForTest(Message message) =>
2589
+ _saveMessage(message);
2590
+
2591
static void _saveMessageInBackground(Store store, Message message) {
2592
final box = store.box<MessageEntity>();
2593
final convBox = store.box<ConversationEntity>();
test/features/chat/providers/chat_notifier_disposed_ref_test.dart
new
+76
@@ -0,0 +1,76 @@
1
+import 'package:flutter_riverpod/flutter_riverpod.dart';
2
+import 'package:flutter_test/flutter_test.dart';
3
+import 'package:localmind/core/models/enums.dart';
4
+import 'package:localmind/features/chat/data/models/message.dart';
5
+import 'package:localmind/features/chat/providers/chat_notifier.dart';
6
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
7
+import 'package:localmind/features/servers/data/models/server.dart';
8
+import 'package:localmind/features/servers/providers/server_providers.dart';
9
+
10
+// Regression test for issue #73:
11
+// "Crash: Cannot use the Ref of NotifierProvider<ChatNotifier, ChatState>...
12
+// after it has been disposed."
13
+//
14
+// The bug: `_saveMessage` evaluated `_isInMemoryChat` (which reads `state`)
15
+// BEFORE checking `ref.mounted`. On a disposed notifier, reading `state`
16
+// throws. The fix is to check `ref.mounted` first, then fall back to the
17
+// `_isInMemoryChat` short-circuit.
18
+void main() {
19
+ test(
20
+ '_saveMessage does not throw when the provider is disposed before call',
21
+ () async {
22
+ final container = ProviderContainer(
23
+ overrides: [
24
+ activeServerProvider.overrideWith(_ConnectedServerNotifier.new),
25
+ onDeviceEngineProvider.overrideWith(_EmptyEngineNotifier.new),
26
+ chatProvider.overrideWith(_DisposedChatNotifier.new),
27
+ ],
28
+ );
29
+
30
+ // Read the notifier once to instantiate it, then dispose the container
31
+ // so any subsequent call to `state` would normally throw
32
+ // "Cannot use the Ref ... after it has been disposed".
33
+ final notifier = container.read(chatProvider.notifier);
34
+ container.dispose();
35
+
36
+ // This used to throw before the fix because `_saveMessage` touched
37
+ // `_isInMemoryChat` (=> `state`) before `ref.mounted`.
38
+ expect(
39
+ () => notifier.debugSaveMessageForTest(_dummyMessage()),
40
+ returnsNormally,
41
+ );
42
+ },
43
+ );
44
+}
45
+
46
+Message _dummyMessage() => Message(
47
+ id: 'msg-1',
48
+ conversationId: 'conv-1',
49
+ role: MessageRole.user,
50
+ content: 'hello',
51
+ createdAt: DateTime.utc(2026, 8, 29),
52
+);
53
+
54
+class _EmptyEngineNotifier extends OnDeviceEngineNotifier {
55
+ @override
56
+ OnDeviceEngineState build() => const OnDeviceEngineState();
57
+}
58
+
59
+class _ConnectedServerNotifier extends ActiveServerNotifier {
60
+ @override
61
+ Server? build() => Server(
62
+ id: 'on-device',
63
+ name: 'On-Device',
64
+ type: ServerType.onDevice,
65
+ host: '',
66
+ port: 0,
67
+ createdAt: DateTime.utc(2026, 8, 13),
68
+ lastConnectedAt: DateTime.utc(2026, 8, 13),
69
+ status: ConnectionStatus.connected,
70
+ );
71
+}
72
+
73
+class _DisposedChatNotifier extends ChatNotifier {
74
+ @override
75
+ ChatState build() => const ChatState();
76
+}
\ No newline at end of file