Files
buzz/mobile/test/features/channels/thread_reply_refresh_cases.dart
Tom Brow ef2aa1ae38 fix(mobile): preserve thread replies through refresh failures (#7757)
## Summary

A failed mobile thread refresh could briefly remove replies and show “0
replies” even though Riverpod still retained the previous result. Read
the retained reply value directly and merge it with pending local
replies through errors and retries. When a reopened thread has no
retained result, show loading or failure instead of a false zero count.
Error status remains visible alongside retained replies.

No additional cache or retention is introduced. Current main
intentionally disposes thread queries on close; reopening still fetches
fresh data. The recorded symptom is reproduced by the failed-refresh
test, but the recording alone does not establish its network trigger.

### Related issue

Related to #4831 (thread refresh and disposal behavior), incorporated
into main through #6691. No duplicate for this rendering defect found.

### Testing

- Reproduced disappearing cached replies in widget tests before the fix;
the new retry/recovery, terminal-error, reopen/loading, initial-error,
and empty-result tests now pass.
- Provider coverage verifies cached and pending local replies survive a
failed refresh and confirmation still clears the local overlay.
- All five new widget regressions, the existing
hydration/scroll-position regression, all nine thread-provider tests,
Flutter analysis, and the final-head full mobile pre-push gate passed
(2,261 tests passed; four skipped).
- Phone-width and large-text rendering checked with synthetic messages.
[Before/after
screenshots](https://github.com/block/buzz/pull/7757#issuecomment-5743469556).
- All final-head GitHub checks passed, including mobile tests, Android
debug APK, iOS simulator app, and unsigned iOS release builds.
- Codex code review and security review are clean for `4e5f606ae`; the
earlier accessibility finding is fixed and resolved. Builderbot review
was requested but has not returned a result.
- Broad local `just ci` could not finish: unchanged ACP timing tests
failed, including `keepalive_resets_idle_past_deadline` on the
lower-concurrency retry. The separate ACP run passed all 941 tests.
Initial parallel mobile runs also hit timing flakes; the final-head full
mobile gate passed. No unrelated native code was changed.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-19 11:00:47 -07:00

196 lines
6.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

part of 'channel_detail_page_test.dart';
void threadReplyRefreshTests() {
const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'refresh-root');
final root = _textMsg(
id: args.rootId,
pubkey: 'alice',
content: 'Refresh root',
);
final reply = _textMsg(
id: 'cached-reply',
pubkey: 'bob',
content: 'Cached reply',
createdAt: 1001,
extraTags: [
['e', args.rootId, '', 'reply'],
],
);
Future<(NavigatorState, ProviderContainer)> mount(
WidgetTester tester,
Future<List<NostrEvent>> Function() load, {
bool retry = false,
TextScaler textScaler = TextScaler.noScaling,
}) async {
await tester.pumpWidget(
_buildTestable(
messages: [root],
textScaler: textScaler,
threadReplyLoaders: {args.rootId: load},
providerRetry: (_, _) =>
retry ? const Duration(milliseconds: 200) : null,
),
);
await tester.pumpAndSettle();
final context = tester.element(find.byType(ChannelDetailPage));
return (Navigator.of(context), ProviderScope.containerOf(context));
}
void open(NavigatorState navigator) {
final head = formatTimeline([root]).single;
navigator.push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: head,
allMessages: [head],
channelId: _channelId,
currentPubkey: 'self',
isMember: true,
isArchived: false,
),
),
);
}
testWidgets(
'thread refresh keeps cached replies through retry and recovery',
(tester) async {
var calls = 0;
final refresh = Completer<List<NostrEvent>>();
final retry = Completer<List<NostrEvent>>();
final (navigator, container) = await mount(tester, () {
calls++;
return switch (calls) {
1 => Future.value([reply]),
2 => refresh.future,
_ => retry.future,
};
}, retry: true);
open(navigator);
await tester.pumpAndSettle();
expect(find.text('1 reply'), findsOneWidget);
container.invalidate(threadRepliesProvider(args));
await tester.pump();
expect(find.text('1 reply'), findsOneWidget);
refresh.completeError(Exception('Transient refresh failure'));
await tester.pump();
for (var frame = 0; frame < 20; frame++) {
await tester.pump(const Duration(milliseconds: 16));
expect(find.text('0 replies'), findsNothing, reason: 'frame $frame');
expect(
find.byKey(const ValueKey('thread-message-group-cached-reply')),
findsOneWidget,
);
expect(container.read(threadRepliesProvider(args)).value, [reply]);
}
expect(calls, 3);
retry.complete([
reply,
_textMsg(
id: 'fresh-reply',
pubkey: 'bob',
content: 'Fresh reply',
createdAt: 1002,
extraTags: [
['e', args.rootId, '', 'reply'],
],
),
]);
await tester.pumpAndSettle();
expect(find.text('2 replies'), findsOneWidget);
expect(
find.byKey(const ValueKey('thread-message-group-fresh-reply')),
findsOneWidget,
);
},
);
testWidgets('thread refresh error keeps cached replies visible', (
tester,
) async {
final semantics = tester.ensureSemantics();
var calls = 0;
final refresh = Completer<List<NostrEvent>>();
tester.view.physicalSize = const Size(320, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final (navigator, container) = await mount(
tester,
() => ++calls == 1 ? Future.value([reply]) : refresh.future,
textScaler: const TextScaler.linear(2),
);
open(navigator);
await tester.pumpAndSettle();
container.invalidate(threadRepliesProvider(args));
await tester.pump();
refresh.completeError(Exception('Refresh failed'));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('thread-message-group-cached-reply')),
findsOneWidget,
);
expect(find.text('0 replies'), findsNothing);
expect(find.text('1 reply · Couldn’t refresh'), findsOneWidget);
expect(
tester
.getSemantics(find.text('1 reply · Couldn’t refresh'))
.getSemanticsData()
.flagsCollection
.isLiveRegion,
isTrue,
);
expect(tester.takeException(), isNull);
expect(container.read(threadRepliesProvider(args)).hasError, isTrue);
semantics.dispose();
});
testWidgets('thread reopen shows loading until the fresh query completes', (
tester,
) async {
var calls = 0;
final reopened = Completer<List<NostrEvent>>();
final (navigator, _) = await mount(
tester,
() => ++calls == 1 ? Future.value([reply]) : reopened.future,
);
open(navigator);
await tester.pumpAndSettle();
navigator.pop();
await tester.pumpAndSettle();
open(navigator);
await tester.pumpAndSettle();
expect(calls, 2);
expect(find.text('0 replies'), findsNothing);
expect(find.text('Loading replies…'), findsOneWidget);
reopened.complete([reply]);
await tester.pumpAndSettle();
expect(find.text('1 reply'), findsOneWidget);
expect(find.text('Loading replies…'), findsNothing);
});
testWidgets('thread initial error is distinct from an empty thread', (
tester,
) async {
final query = Completer<List<NostrEvent>>();
final (navigator, _) = await mount(tester, () => query.future);
open(navigator);
await tester.pumpAndSettle();
query.completeError(Exception('Initial load failed'));
await tester.pumpAndSettle();
expect(find.text('0 replies'), findsNothing);
expect(find.text('Couldn’t load replies'), findsOneWidget);
});
testWidgets('thread successful empty query displays zero replies', (
tester,
) async {
final (navigator, _) = await mount(tester, () async => []);
open(navigator);
await tester.pumpAndSettle();
expect(find.text('0 replies'), findsOneWidget);
expect(find.text('Loading replies…'), findsNothing);
expect(find.text('Couldn’t load replies'), findsNothing);
});
}