mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
## Summary - refetch mounted mobile thread replies after relay reconnect, preserving the previous reply list during recovery - auto-dispose route-scoped relay reply caches so reopening a thread queries current relay state - invalidate live replies through both the channel-window and legacy websocket-history paths - preserve optimistic-reply confirmation when the route closes before its deferred cleanup - stabilize rapid same-second messages using desktop's existing split contract: channel timelines render `(created_at ASC, id DESC)` while threads render `(created_at ASC, id ASC)` - retain late live rows after a channel window is exhausted instead of dropping same-second tail messages Closes #4404. Closes #4830. Closes #6204. ## Context The broad all-channel/all-DM stale-session defect reported in #4402 is already addressed on current `main` by #4372 and #3053. Two distinct mobile gaps remained: 1. `threadRepliesProvider` was a process-lifetime one-shot query, so replies missed while the socket was stale remained absent after reconnect or after closing and reopening the thread. 2. Mobile had inconsistent timestamp-only and event-id ordering across channel producers. Rapid messages routinely share Nostr's one-second timestamp, so later hydration/live reconciliation could reshuffle them. Desktop deliberately has two render contracts: channel windows reverse the relay's composite order to `(created_at ASC, id DESC)`, while thread replies use `(created_at ASC, id ASC)`. This consolidates the current-main portions of #4831 and #3243 rather than reviving stale overlapping branches. ## Validation Exact pushed head: `be92d9542c6cd1342733bdc5e8359664b511ce02` - focused channel-provider/window/thread suites: 48/48 passed - incident regression: a mounted thread misses a reply while disconnected, reconnects, and renders the recovered reply - route regression: closing and reopening a thread performs a fresh authoritative query - websocket fallback regression: live reply invalidates the mounted thread even without the channel-window path - disposal regression: optimistic confirmation survives provider disposal between rebuild and deferred cleanup - ordering regressions: channel window/live, websocket fallback, optimistic sends, deep links, both pagination paths, and thread merges preserve their desktop-compatible same-second order - boundary regression: exhausted windows admit late same-second live rows without weakening open-page cursor boundaries - independent adversarial review: no production blocker; source contract verified across all producers and relay cursor semantics unchanged - pre-push Mobile lane passed at exact head, including analysis, file-size/branch checks, and full Flutter suite: 1,675/1,675 passed - `git diff --check` --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
239 lines
8.0 KiB
Dart
239 lines
8.0 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:buzz/features/channels/pending_local_messages_provider.dart';
|
|
import 'package:buzz/features/channels/thread_replies_provider.dart';
|
|
import 'package:buzz/shared/relay/relay.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
class _FakeRelaySession extends RelaySessionNotifier {
|
|
int queryCount = 0;
|
|
List<NostrEvent> replies = const [];
|
|
Completer<List<NostrEvent>>? nextQueryGate;
|
|
|
|
@override
|
|
SessionState build() => const SessionState(status: SessionStatus.connected);
|
|
|
|
void setStatus(SessionStatus status) {
|
|
state = SessionState(status: status);
|
|
}
|
|
|
|
@override
|
|
Future<List<NostrEvent>> queryRelay(
|
|
List<NostrFilter> filters, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async {
|
|
queryCount++;
|
|
final gate = nextQueryGate;
|
|
if (gate != null) {
|
|
nextQueryGate = null;
|
|
return gate.future;
|
|
}
|
|
return replies;
|
|
}
|
|
}
|
|
|
|
NostrEvent _reply(String id, int createdAt) => NostrEvent(
|
|
id: id,
|
|
pubkey: 'bob',
|
|
createdAt: createdAt,
|
|
kind: EventKind.streamMessage,
|
|
tags: const [
|
|
['h', 'chan'],
|
|
['e', 'root', '', 'reply'],
|
|
],
|
|
content: 'reply $id',
|
|
sig: '',
|
|
);
|
|
|
|
void main() {
|
|
const args = ThreadRepliesArgs(channelId: 'chan', rootId: 'root');
|
|
|
|
(ProviderContainer, _FakeRelaySession, ProviderSubscription<Object?>)
|
|
makeHarness(List<NostrEvent> initialReplies) {
|
|
final fakeSession = _FakeRelaySession()..replies = initialReplies;
|
|
final container = ProviderContainer(
|
|
overrides: [relaySessionProvider.overrideWith(() => fakeSession)],
|
|
);
|
|
// An auto-disposed provider needs a listener to stay alive, mirroring an
|
|
// open thread page. Creating it starts the first load, so the fake's
|
|
// replies must be in place first.
|
|
final subscription = container.listen(
|
|
threadRepliesProvider(args),
|
|
(_, _) {},
|
|
);
|
|
return (container, fakeSession, subscription);
|
|
}
|
|
|
|
test('thread replies keep desktop same-second id order', () {
|
|
expect(
|
|
mergeThreadEvents(
|
|
[_reply('z', 1000)],
|
|
[_reply('a', 1000), _reply('m', 1000)],
|
|
).map((event) => event.id),
|
|
['a', 'm', 'z'],
|
|
);
|
|
});
|
|
|
|
test('does not refetch on the disconnect edge', () async {
|
|
final (container, fakeSession, _) = makeHarness([_reply('r1', 1000)]);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(threadRepliesProvider(args).future);
|
|
final queriesAfterFirstLoad = fakeSession.queryCount;
|
|
|
|
fakeSession.setStatus(SessionStatus.disconnected);
|
|
await container.pump();
|
|
|
|
expect(fakeSession.queryCount, queriesAfterFirstLoad);
|
|
});
|
|
|
|
test('refetches exactly once per reconnect edge', () async {
|
|
final (container, fakeSession, _) = makeHarness([_reply('r1', 1000)]);
|
|
addTearDown(container.dispose);
|
|
|
|
final first = await container.read(threadRepliesProvider(args).future);
|
|
expect(first.map((event) => event.id), ['r1']);
|
|
final queriesAfterFirstLoad = fakeSession.queryCount;
|
|
|
|
// A reply lands while the connection is down.
|
|
fakeSession.replies = [_reply('r1', 1000), _reply('r2', 2000)];
|
|
fakeSession.setStatus(SessionStatus.disconnected);
|
|
await container.pump();
|
|
fakeSession.setStatus(SessionStatus.connected);
|
|
await container.pump();
|
|
|
|
final second = await container.read(threadRepliesProvider(args).future);
|
|
expect(second.map((event) => event.id), ['r1', 'r2']);
|
|
expect(fakeSession.queryCount, queriesAfterFirstLoad + 1);
|
|
});
|
|
|
|
test(
|
|
'does not refetch on session emissions that keep the same status',
|
|
() async {
|
|
final (container, fakeSession, _) = makeHarness([_reply('r1', 1000)]);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(threadRepliesProvider(args).future);
|
|
final queriesAfterFirstLoad = fakeSession.queryCount;
|
|
|
|
// Same connected status, new state object (e.g. reconnectAttempt bump).
|
|
fakeSession.setStatus(SessionStatus.connected);
|
|
await container.pump();
|
|
|
|
expect(fakeSession.queryCount, queriesAfterFirstLoad);
|
|
},
|
|
);
|
|
|
|
test('keeps previous replies available while a refresh is pending', () async {
|
|
final (container, fakeSession, _) = makeHarness([_reply('r1', 1000)]);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(threadRepliesProvider(args).future);
|
|
|
|
// Hold the reconnect refresh open and verify the old data still reads.
|
|
final gate = Completer<List<NostrEvent>>();
|
|
fakeSession.nextQueryGate = gate;
|
|
fakeSession.setStatus(SessionStatus.disconnected);
|
|
await container.pump();
|
|
fakeSession.setStatus(SessionStatus.connected);
|
|
await container.pump();
|
|
|
|
final pending = container.read(threadRepliesProvider(args));
|
|
expect(pending.isLoading, isTrue);
|
|
expect(pending.value?.map((event) => event.id), ['r1']);
|
|
|
|
gate.complete([_reply('r1', 1000), _reply('r2', 2000)]);
|
|
final refreshed = await container.read(threadRepliesProvider(args).future);
|
|
expect(refreshed.map((event) => event.id), ['r1', 'r2']);
|
|
});
|
|
|
|
test(
|
|
'confirmation survives disposing the combined provider before its microtask',
|
|
() async {
|
|
final reply = _reply('r1', 1000);
|
|
final query = Completer<List<NostrEvent>>();
|
|
final fakeSession = _FakeRelaySession()..nextQueryGate = query;
|
|
final container = ProviderContainer(
|
|
overrides: [relaySessionProvider.overrideWith(() => fakeSession)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
container.read(threadLocalRepliesProvider(args).notifier).add(reply);
|
|
container
|
|
.read(pendingLocalMessagesProvider(args.channelId).notifier)
|
|
.add(reply);
|
|
|
|
late ProviderSubscription<AsyncValue<List<NostrEvent>>> subscription;
|
|
subscription = container.listen(threadRepliesWithLocalProvider(args), (
|
|
_,
|
|
next,
|
|
) {
|
|
if (next.value?.any((event) => event.id == reply.id) ?? false) {
|
|
subscription.close();
|
|
}
|
|
});
|
|
query.complete([reply]);
|
|
await container.pump();
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(container.read(threadLocalRepliesProvider(args)), isEmpty);
|
|
expect(
|
|
container.read(pendingLocalMessagesProvider(args.channelId)),
|
|
isEmpty,
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'mounted thread renders a reply missed while disconnected after reconnect',
|
|
() async {
|
|
final fakeSession = _FakeRelaySession()..replies = [_reply('r1', 1000)];
|
|
final container = ProviderContainer(
|
|
overrides: [relaySessionProvider.overrideWith(() => fakeSession)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final mountedThread = container.listen(
|
|
threadRepliesWithLocalProvider(args),
|
|
(_, _) {},
|
|
);
|
|
addTearDown(mountedThread.close);
|
|
|
|
await container.read(threadRepliesProvider(args).future);
|
|
expect(mountedThread.read().value?.map((event) => event.id), ['r1']);
|
|
|
|
fakeSession.setStatus(SessionStatus.disconnected);
|
|
await container.pump();
|
|
fakeSession.replies = [_reply('r1', 1000), _reply('r2', 2000)];
|
|
fakeSession.setStatus(SessionStatus.connected);
|
|
await container.pump();
|
|
await container.read(threadRepliesProvider(args).future);
|
|
|
|
expect(mountedThread.read().value?.map((event) => event.id), [
|
|
'r1',
|
|
'r2',
|
|
]);
|
|
},
|
|
);
|
|
|
|
test('reopening a disposed thread performs a fresh load', () async {
|
|
final (container, fakeSession, subscription) = makeHarness([
|
|
_reply('r1', 1000),
|
|
]);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(threadRepliesProvider(args).future);
|
|
final queriesAfterFirstLoad = fakeSession.queryCount;
|
|
|
|
// Close the page: the auto-disposed query is torn down…
|
|
subscription.close();
|
|
await container.pump();
|
|
|
|
// …so reopening loads fresh instead of serving a stale cache.
|
|
fakeSession.replies = [_reply('r1', 1000), _reply('r2', 2000)];
|
|
container.listen(threadRepliesProvider(args), (_, _) {});
|
|
final reopened = await container.read(threadRepliesProvider(args).future);
|
|
expect(reopened.map((event) => event.id), ['r1', 'r2']);
|
|
expect(fakeSession.queryCount, queriesAfterFirstLoad + 1);
|
|
});
|
|
}
|