Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 4eca1b5ac8 fix(undo): await init before mutating history, add persistence tests
Race condition: pushAction/clear/undo could run before init() loaded DB
history, causing persisted state to overwrite newer actions on load.
Now all mutating methods await the _ready future set by init(). Three new
tests cover restart persistence and the concurrent init+push race.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:30:58 +02:00
7 changed files with 157 additions and 53 deletions
+13 -4
View File
@@ -10,12 +10,19 @@ class UndoService extends StateNotifier<List<UndoAction>> {
final Ref _ref;
static const int _maxHistory = 10;
// Resolves once init() has loaded persisted history. Default to an already-
// resolved future so operations are safe even if init() is never called.
Future<void> _ready = Future.value();
Future<void> init() async {
final repo = _ref.read(undoRepositoryProvider);
state = await repo.getHistory();
_ready = _ref.read(undoRepositoryProvider).getHistory().then((history) {
if (mounted) state = history;
});
await _ready;
}
void pushAction(UndoAction action) {
Future<void> pushAction(UndoAction action) async {
await _ready;
final newList = [...state, action];
if (newList.length > _maxHistory) {
final removed = newList.removeAt(0);
@@ -25,12 +32,14 @@ class UndoService extends StateNotifier<List<UndoAction>> {
unawaited(_ref.read(undoRepositoryProvider).saveAction(action));
}
void clear() {
Future<void> clear() async {
await _ready;
state = [];
unawaited(_ref.read(undoRepositoryProvider).clearHistory());
}
Future<void> undo({String? actionId}) async {
await _ready;
if (state.isEmpty) return;
final UndoAction action;
+5 -1
View File
@@ -123,6 +123,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
final destPath = await repo.deleteEmail(widget.emailId);
if (header != null) {
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
@@ -133,6 +134,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
destinationMailboxPath: destPath,
originalEmails: [header],
),
),
);
}
@@ -354,6 +356,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
@@ -363,6 +366,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: chosen,
),
),
);
if (context.mounted) context.pop();
@@ -384,7 +388,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
sourceMailboxPath: header.mailboxPath,
originalEmails: [header],
);
ref.read(undoServiceProvider.notifier).pushAction(action);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
await repo.snoozeEmail(widget.emailId, until);
if (context.mounted) {
+10 -6
View File
@@ -331,7 +331,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
destinationMailboxPath: mailbox.path,
originalEmails: originalEmails,
);
ref.read(undoServiceProvider.notifier).pushAction(action);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
Future<void> _batchArchive() =>
@@ -364,7 +364,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
ref.read(undoServiceProvider.notifier).pushAction(action);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
Future<void> _batchMarkSpam() =>
@@ -426,7 +426,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
destinationMailboxPath: chosen,
originalEmails: originalEmails,
);
ref.read(undoServiceProvider.notifier).pushAction(action);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
Future<void> _batchSnooze() async {
@@ -458,7 +458,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
sourceMailboxPath: widget.mailboxPath,
originalEmails: originalEmails,
);
ref.read(undoServiceProvider.notifier).pushAction(action);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
if (!mounted) return;
@@ -609,7 +609,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
destinationMailboxPath: archive.path,
originalEmails: originalEmails,
);
ref.read(undoServiceProvider.notifier).pushAction(action);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(action),
);
} else {
String? lastDestPath;
for (final id in t.emailIds) {
@@ -625,7 +627,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
ref.read(undoServiceProvider.notifier).pushAction(action);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(action),
);
}
},
child: tile,
+2
View File
@@ -256,6 +256,7 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
final destPath = await repo.deleteEmail(widget.email.id);
if (original != null) {
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
@@ -266,6 +267,7 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
destinationMailboxPath: destPath,
originalEmails: [original],
),
),
);
}
}
+4 -1
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
@@ -22,7 +24,8 @@ class UndoLogScreen extends ConsumerWidget {
tooltip: 'Clear history',
onPressed: history.isEmpty
? null
: () => ref.read(undoServiceProvider.notifier).clear(),
: () =>
unawaited(ref.read(undoServiceProvider.notifier).clear()),
),
],
),
+4 -4
View File
@@ -109,7 +109,7 @@ void main() {
sourceMailboxPath: 'INBOX',
originalEmails: [original!],
);
container.read(undoServiceProvider.notifier).pushAction(action);
await container.read(undoServiceProvider.notifier).pushAction(action);
await container.read(undoServiceProvider.notifier).undo();
// 3. Verify it is back in Inbox
@@ -190,7 +190,7 @@ void main() {
emailIds: [emailId],
sourceMailboxPath: 'INBOX',
);
container.read(undoServiceProvider.notifier).pushAction(action);
await container.read(undoServiceProvider.notifier).pushAction(action);
await container.read(undoServiceProvider.notifier).undo();
// 3. Verify it is back in Inbox
@@ -230,7 +230,7 @@ void main() {
destinationMailboxPath: destPath,
originalEmails: [original!],
);
container.read(undoServiceProvider.notifier).pushAction(action);
await container.read(undoServiceProvider.notifier).pushAction(action);
await container.read(undoServiceProvider.notifier).undo();
// 4. Verify local state
@@ -273,7 +273,7 @@ void main() {
sourceMailboxPath: 'INBOX',
originalEmails: [original!],
);
container.read(undoServiceProvider.notifier).pushAction(action);
await container.read(undoServiceProvider.notifier).pushAction(action);
await container.read(undoServiceProvider.notifier).undo();
// 3. Verify it is back in Inbox and metadata is cleared
+90 -8
View File
@@ -61,10 +61,10 @@ void main() {
final notifier = container.read(undoServiceProvider.notifier);
await notifier.init(); // Wait for persistent load
notifier.pushAction(action1);
await notifier.pushAction(action1);
expect(container.read(undoServiceProvider), [action1]);
notifier.pushAction(action2);
await notifier.pushAction(action2);
expect(container.read(undoServiceProvider), [action1, action2]);
});
@@ -91,8 +91,8 @@ void main() {
final notifier = container.read(undoServiceProvider.notifier);
await notifier.init();
notifier.pushAction(action1);
notifier.pushAction(action2);
await notifier.pushAction(action1);
await notifier.pushAction(action2);
await notifier.undo();
expect(container.read(undoServiceProvider), [action1]);
@@ -126,8 +126,8 @@ void main() {
final notifier = container.read(undoServiceProvider.notifier);
await notifier.init();
notifier.pushAction(action1);
notifier.pushAction(action2);
await notifier.pushAction(action1);
await notifier.pushAction(action2);
await notifier.undo(actionId: '1');
expect(container.read(undoServiceProvider), [action2]);
@@ -154,7 +154,7 @@ void main() {
final notifier = container.read(undoServiceProvider.notifier);
await notifier.init();
notifier.pushAction(action);
await notifier.pushAction(action);
await notifier.undo();
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
@@ -193,11 +193,93 @@ void main() {
final notifier = container.read(undoServiceProvider.notifier);
await notifier.init();
notifier.pushAction(action);
await notifier.pushAction(action);
await notifier.undo();
verify(mockEmailRepo.restoreEmails(any)).called(1);
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
});
test('init loads persisted history from repository', () async {
final persisted = UndoAction(
id: '99',
accountId: 'acc1',
type: UndoType.move,
emailIds: ['e99'],
sourceMailboxPath: 'INBOX',
);
when(
mockUndoRepo.getHistory(limit: anyNamed('limit')),
).thenAnswer((_) async => [persisted]);
final notifier = container.read(undoServiceProvider.notifier);
await notifier.init();
expect(container.read(undoServiceProvider), [persisted]);
});
test('pushAction after restart appends to persisted history', () async {
final persisted = UndoAction(
id: '1',
accountId: 'acc1',
type: UndoType.move,
emailIds: ['e1'],
sourceMailboxPath: 'INBOX',
);
final newAction = UndoAction(
id: '2',
accountId: 'acc1',
type: UndoType.delete,
emailIds: ['e2'],
sourceMailboxPath: 'INBOX',
);
when(
mockUndoRepo.getHistory(limit: anyNamed('limit')),
).thenAnswer((_) async => [persisted]);
final notifier = container.read(undoServiceProvider.notifier);
await notifier.init();
await notifier.pushAction(newAction);
expect(container.read(undoServiceProvider), [persisted, newAction]);
});
test('pushAction concurrent with init waits for init to complete', () async {
final persisted = UndoAction(
id: '1',
accountId: 'acc1',
type: UndoType.move,
emailIds: ['e1'],
sourceMailboxPath: 'INBOX',
);
final raced = UndoAction(
id: '2',
accountId: 'acc1',
type: UndoType.delete,
emailIds: ['e2'],
sourceMailboxPath: 'INBOX',
);
// Simulate slow DB load
when(
mockUndoRepo.getHistory(limit: anyNamed('limit')),
).thenAnswer(
(_) => Future.delayed(
const Duration(milliseconds: 10),
() => [persisted],
),
);
final notifier = container.read(undoServiceProvider.notifier);
final initFuture = notifier.init();
// pushAction issued before init completes — it must still see persisted history
final pushFuture = notifier.pushAction(raced);
await Future.wait([initFuture, pushFuture]);
expect(container.read(undoServiceProvider), [persisted, raced]);
});
}