Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions lib/screens/components/controllers/note_edit_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
Expand Down Expand Up @@ -196,6 +207,9 @@ class NoteEditController {
}

void dispose() {
_disposed = true;
_noteModel?.removeListener(_syncControllerFromModel);
_noteModel = null;
textController.dispose();
}
}
49 changes: 49 additions & 0 deletions test/controllers/note_edit_controller_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
}
Loading