From f216dd6afc3f38f509b2c73e44824acf4ec785b5 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 13 Nov 2026 08:38:35 -0800 Subject: [PATCH] fix(mobile): retire directory-only loads on a scope switch A directory-only search load kept its ownership marker after a community or identity switch retired it. On returning to the first community, the list refresh treated the abandoned loading status as owned by a live load and left it in place, so search never loaded the directory again. A scope switch now retires directory-only loads too: it advances the directory generation and clears the marker. Tests cover returning both while the old request is in flight and after it ended. Co-authored-by: studio Signed-off-by: Tom Brow --- .../features/channels/channel_directory.dart | 10 ++ .../features/channels/channels_provider.dart | 2 +- .../channels/channels_provider_test.dart | 108 +++++++++++++++++- 3 files changed, 118 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/channels/channel_directory.dart b/mobile/lib/features/channels/channel_directory.dart index 9126b044e3..d45a8db731 100644 --- a/mobile/lib/features/channels/channel_directory.dart +++ b/mobile/lib/features/channels/channel_directory.dart @@ -459,6 +459,16 @@ class _ChannelRefreshCoordinator { /// wire cannot be written into the new scope. void retireInFlight() => _generation++; + /// Retires every in-flight request, directory-only loads included, for a + /// relay or identity change. A directory-only load survives ordinary list + /// refreshes, but not a scope change: its loading status must not look + /// owned when the user returns to the old scope. + void retireScope() { + _generation++; + _directoryGeneration++; + _directoryOnlyGeneration = null; + } + /// Starts a fenced refresh without issuing the directory query. /// /// Used by callers that must carry the scope across later awaits even when diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index bde0068f09..268409a20d 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -228,7 +228,7 @@ class ChannelsNotifier extends AsyncNotifier> { _confirmedJoins.clear(); // Retire any in-flight directory request: its response describes the // previous relay or identity and must not reach this scope's state. - _refreshCoordinator.retireInFlight(); + _refreshCoordinator.retireScope(); } final snapshot = (_hasLoaded ? state.value : null) ?? diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 7f27543693..dc7f460ad6 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -1963,6 +1963,107 @@ void main() { isNot(communityB), ); }); + + test( + 'returning to a community during a discarded load loads again', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'community-a-open'), + ], + ); + final container = await loaded(session); + final communityA = container.read(relayConfigProvider).baseUrl; + + session.pauseNextDirectoryQuery(); + final directory = container + .read(channelsProvider.notifier) + .ensureDirectoryLoaded(maxAge: Duration.zero); + await session.nextDirectoryQueryStarted; + + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); + container + .read(relayConfigProvider.notifier) + .update(baseUrl: communityA); + await container.read(channelsProvider.future); + await _settle(); + final directoryQueries = session.directoryQueryFilters.length; + await container + .read(channelsProvider.notifier) + .ensureDirectoryLoaded(maxAge: Duration.zero); + + expect( + session.directoryQueryFilters.length, + greaterThan(directoryQueries), + ); + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.loaded, + ); + + // The old request ending later leaves the newer load's status alone. + session.resumePausedDirectoryQuery(); + await directory; + await _settle(); + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.loaded, + ); + }, + ); + + test( + 'returning to a community after a discarded load loads again', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'community-a-open'), + ], + ); + final container = await loaded(session); + final communityA = container.read(relayConfigProvider).baseUrl; + + session.pauseNextDirectoryQuery(); + final directory = container + .read(channelsProvider.notifier) + .ensureDirectoryLoaded(maxAge: Duration.zero); + await session.nextDirectoryQueryStarted; + + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); + session.resumePausedDirectoryQuery(); + await directory; + await _settle(); + + container + .read(relayConfigProvider.notifier) + .update(baseUrl: communityA); + await container.read(channelsProvider.future); + await _settle(); + final directoryQueries = session.directoryQueryFilters.length; + await container + .read(channelsProvider.notifier) + .ensureDirectoryLoaded(maxAge: Duration.zero); + + expect( + session.directoryQueryFilters.length, + greaterThan(directoryQueries), + ); + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.loaded, + ); + }, + ); }); test('membership refresh does not refetch a loaded directory', () async { @@ -3490,6 +3591,7 @@ class _FakeRelaySession extends RelaySessionNotifier { Completer? _pausedSubscribe; Completer? _subscribeStarted; Completer? _pausedDirectory; + bool _pauseClaimed = false; Completer? _directoryStarted; Completer? _pausedHiddenDm; Completer? _hiddenDmStarted; @@ -3812,11 +3914,15 @@ class _FakeRelaySession extends RelaySessionNotifier { // active when the response is released. final directorySnapshot = List.of(metadata); final paused = _pausedDirectory; - if (paused != null) { + // Only the first query after the pause waits; a later one meanwhile + // (a new scope's load) answers at once. + if (paused != null && !_pauseClaimed) { + _pauseClaimed = true; _directoryStarted!.complete(); await paused.future; _pausedDirectory = null; _directoryStarted = null; + _pauseClaimed = false; } if (directoryFailures > 0) { directoryFailures--;