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 <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
This commit is contained in:
Tom Brow
2026-11-13 14:28:49 -08:00
committed by GitHub
parent 04af7081c8
commit 3dcdf1c056
2 changed files with 56 additions and 1 deletions

View File

@@ -71,7 +71,18 @@ class ActivityNotifier extends AsyncNotifier<HomeFeedResponse> {
@override
Future<HomeFeedResponse> 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.

View File

@@ -40,6 +40,8 @@ class _RecordingSessionNotifier extends RelaySessionNotifier {
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
void setStatus(SessionState next) => state = next;
@override
Future<List<NostrEvent>> 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 <Channel>[]),
),
],
);
addTearDown(container.dispose);
final sub = container.listen(activityProvider, (_, _) {});
addTearDown(sub.close);
await container.read(channelsProvider.future);
await container.read(activityProvider.future);
await Future<void>.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<void>.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))