diff --git a/lib/screens/components/controllers/note_edit_controller.dart b/lib/screens/components/controllers/note_edit_controller.dart index 2cb439f..64cc751 100644 --- a/lib/screens/components/controllers/note_edit_controller.dart +++ b/lib/screens/components/controllers/note_edit_controller.dart @@ -15,15 +15,30 @@ class NoteEditController { final HtmlToMarkdownConverter htmlToMarkdownConverter; TextEditingController textController = TextEditingController(); + NoteModel? _noteModel; + bool _disposed = false; + NoteEditController({ required this.imageService, required this.clipboardService, required this.htmlToMarkdownConverter, }); + void _syncControllerFromModel() { + if (_disposed) return; + final noteModel = _noteModel; + if (noteModel == null) return; + if (noteModel.content != textController.text) { + textController.text = noteModel.content; + } + } + void initialize(NoteModel noteModel, Note? note, BuildContext context) { + assert(_noteModel == null, 'NoteEditController.initialize() must not be called more than once'); // Delay the update to avoid triggering a rebuild during the build phase WidgetsBinding.instance.addPostFrameCallback((_) { + if (_disposed) return; + if (noteModel.initialContent.isNotEmpty && note == null) { noteModel.content = noteModel.initialContent; } else if (note != null) { @@ -33,12 +48,8 @@ class NoteEditController { // Set the initial text in the controller textController.text = noteModel.content; - // Add listener to update controller.text when noteModel.content changes - noteModel.addListener(() { - if (noteModel.content != textController.text) { - textController.text = noteModel.content; - } - }); + _noteModel = noteModel; + noteModel.addListener(_syncControllerFromModel); // Request focus noteModel.requestFocus(); @@ -196,6 +207,9 @@ class NoteEditController { } void dispose() { + _disposed = true; + _noteModel?.removeListener(_syncControllerFromModel); + _noteModel = null; textController.dispose(); } } diff --git a/test/controllers/note_edit_controller_test.dart b/test/controllers/note_edit_controller_test.dart index f18bcf2..47d0bfa 100644 --- a/test/controllers/note_edit_controller_test.dart +++ b/test/controllers/note_edit_controller_test.dart @@ -314,4 +314,53 @@ void main() { expect(noteModel.isPasting, isFalse); }); }); + + group('NoteEditController listener lifecycle', () { + late NoteEditController controller; + + setUp(() { + controller = NoteEditController( + imageService: FakeImageService(), + clipboardService: FakeClipboardService(), + htmlToMarkdownConverter: HtmlToMarkdownConverter(), + ); + }); + + testWidgets('post-dispose notifyListeners does not throw', (tester) async { + final noteModel = NoteModel(); + await tester.pumpWidget(MaterialApp( + home: Builder(builder: (ctx) { + controller.initialize(noteModel, null, ctx); + return const SizedBox(); + }), + )); + await tester.pump(); // allow addPostFrameCallback to fire + + controller.dispose(); + + // Simulates in-flight upload/paste completing after navigation away + expect(() => noteModel.setUploading(true), returnsNormally); + expect(() => noteModel.setUploading(false), returnsNormally); + expect(() => noteModel.setPasting(true), returnsNormally); + expect(() => noteModel.setPasting(false), returnsNormally); + }); + + testWidgets('dispose before frame callback does not throw', (tester) async { + final noteModel = NoteModel(); + await tester.pumpWidget(MaterialApp( + home: Builder(builder: (ctx) { + controller.initialize(noteModel, null, ctx); + // dispose immediately, before the frame callback fires + controller.dispose(); + return const SizedBox(); + }), + )); + await tester.pump(); // callback fires but should be a no-op + + // setUploading(true) changes state and fires notifyListeners — the + // listener must not touch the disposed textController. + expect(() => noteModel.setUploading(true), returnsNormally); + expect(() => noteModel.setUploading(false), returnsNormally); + }); + }); }