From 3dcdf1c0562fb77cc724d9cb425f9156db24204b Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 13 Nov 2026 14:28:49 -0800 Subject: [PATCH] perf(mobile): read Activity once per reconnect (#9455) Activity watched the whole relay session state, so it rebuilt, and downloaded its whole feed again, for every socket state on the way back from a reconnect: disconnected, reconnecting (once per attempt) and connected. Only the last of those reads could subscribe live; the others were thrown away. On an iPhone 12 mini signed in to Builderlab, every resume after the background grace period read the 25 KB feed three times. Activity now reads again once per new connection, the way the profile provider already does: it listens for the session becoming connected and rebuilds then. Losing the connection no longer rebuilds it, so the feed it already shows stays until the reconnect read replaces it. ### Validation New test: a disconnected, two reconnecting and a connected state read the feed once. It fails without this change (four reads). Full mobile test suite and `flutter analyze` pass. On the iPhone 12 mini (profile build with this change, signed in to Builderlab), 60 s in the background, then the first 20 s after resuming: | | Activity feed reads | Bytes | | --- | --- | --- | | Before | 3 | 74.8 KB | | After | 1 | 25.0 KB | After the resume, Activity's live subscriptions opened again, and opening Activity showed the mention posted just before the resume. The automated run covered a cold start, the home list, a 60 s background, the resume and opening Activity. ### Regressions - While the relay is unreachable, Activity keeps showing the feed it last read instead of reading over HTTP on each socket state. Once the socket connects again it reads and subscribes as before. Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: studio --- .../features/activity/activity_provider.dart | 13 +++++- .../activity/activity_provider_test.dart | 44 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/activity/activity_provider.dart b/mobile/lib/features/activity/activity_provider.dart index 60df2efdfc..26a5882a55 100644 --- a/mobile/lib/features/activity/activity_provider.dart +++ b/mobile/lib/features/activity/activity_provider.dart @@ -71,7 +71,18 @@ class ActivityNotifier extends AsyncNotifier { @override Future build() async { ref.watch(relayConfigProvider); - final sessionState = ref.watch(relaySessionProvider); + // Read again once per new connection, for what it missed and for live + // subscriptions. The socket states on the way there (disconnected, + // reconnecting) would each download the whole feed again. + ref.listen( + relaySessionProvider.select( + (session) => session.status == SessionStatus.connected, + ), + (_, connected) { + if (connected) ref.invalidateSelf(); + }, + ); + final sessionState = ref.read(relaySessionProvider); // React to the DM channel set (loading → data, membership changes) so a // cold start where channels resolve after the first fetch still surfaces // DMs without a manual refresh. diff --git a/mobile/test/features/activity/activity_provider_test.dart b/mobile/test/features/activity/activity_provider_test.dart index 61280645bd..449325bb8f 100644 --- a/mobile/test/features/activity/activity_provider_test.dart +++ b/mobile/test/features/activity/activity_provider_test.dart @@ -40,6 +40,8 @@ class _RecordingSessionNotifier extends RelaySessionNotifier { @override SessionState build() => const SessionState(status: SessionStatus.connected); + void setStatus(SessionState next) => state = next; + @override Future> fetchHistory( NostrFilter filter, { @@ -305,6 +307,48 @@ void main() { expect(session.dmQueries, isEmpty); }); + test( + 'reads the feed once per reconnect, not once per socket state', + () async { + final session = _RecordingSessionNotifier() + ..seed(_mentionEvent('mention', 1_700_000_001)); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new), + myPubkeyProvider.overrideWithValue('me_pk'), + relaySessionProvider.overrideWith(() => session), + channelsProvider.overrideWith( + () => _FixedChannelsNotifier(const []), + ), + ], + ); + addTearDown(container.dispose); + final sub = container.listen(activityProvider, (_, _) {}); + addTearDown(sub.close); + + await container.read(channelsProvider.future); + await container.read(activityProvider.future); + await Future.delayed(Duration.zero); + await container.read(activityProvider.future); + final settled = session.mentionFetchCount; + + // Like a resume after the background grace period. + for (final next in const [ + SessionState(status: SessionStatus.disconnected), + SessionState(status: SessionStatus.reconnecting), + SessionState(status: SessionStatus.reconnecting, reconnectAttempt: 1), + SessionState(status: SessionStatus.connected), + ]) { + session.setStatus(next); + await Future.delayed(Duration.zero); + } + final feed = await container.read(activityProvider.future); + + expect(session.mentionFetchCount, settled + 1); + expect(feed.mentions.map((item) => item.id), ['mention']); + }, + ); + test('falls back to websocket history when the HTTP batch fails', () async { final session = _RecordingSessionNotifier() ..seed(_mentionEvent('fallback-mention', 1_700_000_001))