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 <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
Tom Brow
2026-11-13 08:38:35 -08:00
parent ba0d0a5ed2
commit f216dd6afc
3 changed files with 118 additions and 2 deletions

View File

@@ -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

View File

@@ -228,7 +228,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
_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) ??

View File

@@ -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<void>? _pausedSubscribe;
Completer<void>? _subscribeStarted;
Completer<void>? _pausedDirectory;
bool _pauseClaimed = false;
Completer<void>? _directoryStarted;
Completer<void>? _pausedHiddenDm;
Completer<void>? _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--;