perf(mobile): load only the directory when search opens

Opening search on a loaded list now reads only the kind:39000 directory
and merges it into the current list. Joined channels keep their entries,
and non-member open channels are rebuilt with the same visibility rules
as a full refresh. The first load still takes the full path.

A directory-only load does not retire an in-flight membership refresh.
A refresh that merged an older directory re-applies the newer one before
it installs its list, a newer directory load wins over an older one, and
a scope switch discards the response.

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:06:10 -08:00
parent 41587e0284
commit ba0d0a5ed2
3 changed files with 358 additions and 29 deletions

View File

@@ -136,8 +136,40 @@ class _StaleChannelRefresh implements Exception {
/// This token is captured once at the start of every ordinary, directory, and
/// reconnect refresh. It is re-checked after each relay await, so an older
/// request cannot regain ownership by reaching subscription setup last.
class _ChannelRefreshFence {
/// What a directory load checks before each write: the scope it serves.
abstract interface class _DirectoryLoadFence {
/// Relay-and-identity scope that started the load.
String get scope;
/// Throws [_StaleChannelRefresh] once the load must write nothing.
void ensureCurrent();
}
/// Fences a directory-only load to its scope alone.
///
/// Unlike [_ChannelRefreshFence] it holds no refresh generation: a directory
/// read does not depend on memberships, so it neither retires an in-flight
/// list refresh nor is retired by one. A community or identity switch, or
/// disposal, still retires it, so its response never reaches another scope.
class _DirectoryOnlyFence implements _DirectoryLoadFence {
@override
final String scope;
final _ChannelRefreshCoordinator _coordinator;
_DirectoryOnlyFence(this._coordinator, this.scope);
@override
void ensureCurrent() {
if (!_coordinator.isMounted() || scope != _coordinator.currentScope()) {
throw const _StaleChannelRefresh();
}
}
}
class _ChannelRefreshFence implements _DirectoryLoadFence {
/// Relay-and-identity scope that started the refresh.
@override
final String scope;
final _ChannelRefreshCoordinator _coordinator;
@@ -155,6 +187,7 @@ class _ChannelRefreshFence {
///
/// Call after every await and immediately before every write to metadata,
/// cache, load status, subscriptions or provider state.
@override
void ensureCurrent() {
if (!isCurrent) throw const _StaleChannelRefresh();
}
@@ -390,6 +423,16 @@ class _ChannelRefreshCoordinator {
/// Generation of the most recently issued or retired request.
int get generation => _generation;
// Directory loads are ordered among themselves: only the newest one writes
// the directory or its status. A directory-only load is not retired by a
// list refresh, so [beginRefresh] must leave its loading status alone.
int _directoryGeneration = 0;
int? _directoryOnlyGeneration;
/// Starts a directory-only load for the active scope.
_DirectoryOnlyFence beginDirectoryOnly() =>
_DirectoryOnlyFence(this, currentScope());
_ChannelRefreshCoordinator({
required this.isMounted,
required this.currentScope,
@@ -433,7 +476,8 @@ class _ChannelRefreshCoordinator {
fence.ensureCurrent();
if (!fetchesDirectory) {
final status = loadStatus();
if (status.isLoading(scope)) {
if (status.isLoading(scope) &&
_directoryOnlyGeneration != _directoryGeneration) {
// This refresh takes ownership of the shared generation, so the
// in-flight directory response will be discarded. Mark it idle, not
// error: nothing failed, so a caller may load it again, and search
@@ -456,18 +500,25 @@ class _ChannelRefreshCoordinator {
/// Fetches the directory under [fence], or throws if the fence is retired.
///
/// Returns null when the request failed inside the current scope, which means
/// "retain the cached discovery". The fence is re-checked after the await and
/// before every write, on both the success and the failure path.
/// "retain the cached discovery", or when a newer directory load started,
/// which then owns the directory and its status. The fence is re-checked
/// after the await and before every write, on both the success and the
/// failure path.
Future<List<NostrEvent>?> loadDirectory(
RelaySessionNotifier session,
_ChannelRefreshFence fence,
_DirectoryLoadFence fence,
) async {
final directoryGeneration = ++_directoryGeneration;
_directoryOnlyGeneration = fence is _DirectoryOnlyFence
? directoryGeneration
: null;
loadStatus().markLoading(fence.scope);
final List<NostrEvent> metas;
try {
metas = await _fetchChannelDirectoryMetas(session);
} catch (error, stackTrace) {
fence.ensureCurrent();
if (directoryGeneration != _directoryGeneration) return null;
loadStatus().markError(fence.scope);
debugPrint(
'[ChannelsNotifier] channel directory refresh failed; retaining '
@@ -476,6 +527,7 @@ class _ChannelRefreshCoordinator {
return null;
}
fence.ensureCurrent();
if (directoryGeneration != _directoryGeneration) return null;
loadStatus().markLoaded(fence.scope);
return metas;
}
@@ -621,3 +673,100 @@ extension ObservedThreadActivity on ChannelsNotifier {
}
}
}
/// Directory-only loads and the list edits they share with full refreshes.
///
/// Lives in this part file because `channels_provider.dart` sits against the
/// repository-wide 1200-line file ceiling enforced by `just file-size-check`.
extension _ChannelsNotifierDirectory on ChannelsNotifier {
/// Downloads only the open-channel directory and merges it into the list
/// [current] returns once it lands, for a list that already has its
/// memberships. Joined channels keep their entries; live updates keep them
/// current. Returns null when nothing should be written: the load failed
/// (its status says so), a newer directory load replaced it, the scope
/// changed, or there is no list to merge into.
Future<List<Channel>?> _loadDirectoryOnly(
List<Channel>? Function() current,
) async {
final fence = _refreshCoordinator.beginDirectoryOnly();
try {
final metas = await _refreshCoordinator.loadDirectory(
_lifecycleRef.read(relaySessionProvider.notifier),
fence,
);
if (metas == null) return null;
_directoryMetas = metas;
_directoryMetasScope = fence.scope;
} on _StaleChannelRefresh {
return null;
}
final previous = current();
if (previous == null) return null;
final channels = [...previous];
_replaceDirectoryChannels(channels);
_sortChannels(channels);
_invalidateChangedDetails(channels, previous);
return channels;
}
/// Rebuilds every channel in [channels] the user is not a member of from
/// [_directoryMetas], with a full refresh's visibility rules: the latest
/// revision of each open, non-DM channel. Joined channels are untouched.
void _replaceDirectoryChannels(List<Channel> channels) {
final previous = {
for (final channel in channels)
if (!channel.isMember) channel.id: channel,
};
channels.removeWhere((channel) => !channel.isMember);
final memberIds = {for (final channel in channels) channel.id};
for (final event in _latestChannelEvents(_directoryMetas, kind: 39000)) {
final data = ChannelData.fromEvent(event);
if (memberIds.contains(data.id) ||
data.visibility == 'private' ||
data.channelType == 'dm') {
continue;
}
final channel = _channelFromMeta(event, data, isMember: false);
final lastMessageAt = previous[data.id]?.lastMessageAt;
channels.add(
lastMessageAt == null
? channel
: channel.copyWith(lastMessageAt: lastMessageAt),
);
}
}
/// Invalidates the details of channels whose identity or archive state
/// changed from [previous], so opening one does not show stale Solo or
/// archive state. Returns [previous] by id.
Map<String, Channel> _invalidateChangedDetails(
List<Channel> channels,
List<Channel>? previous,
) {
final prevById = <String, Channel>{
for (final c in previous ?? const <Channel>[]) c.id: c,
};
for (final channel in channels) {
final prev = prevById[channel.id];
if (prev == null ||
prev.isArchived != channel.isArchived ||
prev.channelType != channel.channelType ||
prev.visibility != channel.visibility ||
prev.createdBy != channel.createdBy ||
!listEquals(prev.labels, channel.labels)) {
_lifecycleRef.invalidate(channelDetailsProvider(channel.id));
}
}
return prevById;
}
}
/// Orders the list by channel type, then case-insensitively by name to match
/// desktop's `localeCompare` ordering.
void _sortChannels(List<Channel> channels) => channels.sort((left, right) {
final typeOrder =
(_channelTypeOrder[left.channelType] ?? 99) -
(_channelTypeOrder[right.channelType] ?? 99);
if (typeOrder != 0) return typeOrder;
return left.name.toLowerCase().compareTo(right.name.toLowerCase());
});

View File

@@ -430,6 +430,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
_directoryMetasScope = fence.scope;
}
}
final directoryMetas = _directoryMetas;
// Merge and dedupe by `d` tag. Kind:39000 is parameterized-replaceable,
// but stale revisions from before the relay's d_tag backfill can linger.
@@ -580,14 +581,11 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
}
}
channels.sort((left, right) {
final typeOrder =
(_channelTypeOrder[left.channelType] ?? 99) -
(_channelTypeOrder[right.channelType] ?? 99);
if (typeOrder != 0) return typeOrder;
// Case-insensitive to match desktop's `localeCompare` ordering.
return left.name.toLowerCase().compareTo(right.name.toLowerCase());
});
// A directory-only load that landed during the awaits above is newer.
if (!identical(directoryMetas, _directoryMetas)) {
_replaceDirectoryChannels(channels);
}
_sortChannels(channels);
// Details are a separate cache. Invalidate changes that would otherwise
// overwrite fresh Solo identity or archive state when opening a channel.
@@ -597,20 +595,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
// after the await.
fence.ensureCurrent();
final prevById = <String, Channel>{
for (final c in state.value ?? const <Channel>[]) c.id: c,
};
for (final channel in channels) {
final prev = prevById[channel.id];
if (prev == null ||
prev.isArchived != channel.isArchived ||
prev.channelType != channel.channelType ||
prev.visibility != channel.visibility ||
prev.createdBy != channel.createdBy ||
!listEquals(prev.labels, channel.labels)) {
ref.invalidate(channelDetailsProvider(channel.id));
}
}
final prevById = _invalidateChangedDetails(channels, state.value);
// Layer live delivery onto the already-built snapshot without making EOSE
// part of the provider's readiness path. Every refresh queues a generation;
@@ -1086,11 +1071,14 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
fresh)) {
return;
}
await retryDirectory();
await retryDirectory(directoryOnly: true);
}
/// Retries channel discovery while retaining the current channel list.
Future<void> retryDirectory() async {
///
/// With [directoryOnly], a list that has loaded its memberships reads only
/// the directory again; joined channels keep their entries.
Future<void> retryDirectory({bool directoryOnly = false}) async {
final directoryStatus = ref.read(
channelDirectoryLoadStatusProvider.notifier,
);
@@ -1121,6 +1109,15 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
}
if (!ref.mounted || generation != _buildGeneration) return;
final previousChannels = state.value;
if (directoryOnly && _hasLoaded && previousChannels != null) {
// The list has its memberships, so only the directory is read again.
final saveSnapshot = _saveSnapshot;
final channels = await _loadDirectoryOnly(() => state.value);
if (channels == null) return;
state = AsyncData(channels);
saveSnapshot?.call(channels, _hiddenDmIds);
return;
}
try {
state = AsyncData(
await _fetch(subscribeLive: true, fetchDirectory: true),

View File

@@ -1790,6 +1790,181 @@ void main() {
expect(session.metadataPageRequestCount, initialDirectoryRequests);
});
group('search directory load on a loaded list', () {
const channelC = '33333333-3333-4333-8333-333333333333';
Future<ProviderContainer> loaded(_FakeRelaySession session) async {
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
// Let the first load's detached unread catch-up finish.
await _settle();
return container;
}
List<String> listedIds(ProviderContainer container) => container
.read(channelsProvider)
.requireValue
.map((channel) => channel.id)
.toList();
test('reads only the directory and keeps joined channels', () async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
metadata: [
_meta(id: _channelA, name: 'general'),
_meta(id: _channelB, name: 'discoverable'),
],
);
final container = await loaded(session);
final joined = container.read(channelsProvider).requireValue.single;
final memberships = session.membershipRequestCount;
final histories = session.historyFilters.length;
final batches = session.queryBatches.length;
session.metadata = [
_meta(id: _channelA, name: 'general'),
_meta(
id: _channelB,
name: 'discoverable',
visibility: 'private',
createdAt: 2,
),
_meta(id: channelC, name: 'new-open'),
];
await container
.read(channelsProvider.notifier)
.ensureDirectoryLoaded(maxAge: Duration.zero);
// No roster, member metadata, hidden-DM, Huddle or message reads.
expect(session.membershipRequestCount, memberships);
expect(session.historyFilters, hasLength(histories));
expect(session.queryBatches, hasLength(batches));
expect(session.directoryQueryFilters, isNotEmpty);
expect(listedIds(container), unorderedEquals([_channelA, channelC]));
final listed = container.read(channelsProvider).requireValue;
expect(listed.firstWhere((c) => c.id == _channelA), same(joined));
expect(listed.firstWhere((c) => c.id == channelC).isMember, isFalse);
expect(
container.read(channelDirectoryLoadStatusProvider).status,
ChannelDirectoryLoadStatus.loaded,
);
});
test('a membership refresh during the load is kept', () async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
metadata: [
_meta(id: _channelA, name: 'general'),
_meta(id: _channelB, name: 'discoverable'),
_meta(id: _channelD, name: 'joined-elsewhere'),
],
);
final container = await loaded(session);
session.pauseNextDirectoryQuery();
final directory = container
.read(channelsProvider.notifier)
.ensureDirectoryLoaded(maxAge: Duration.zero);
await session.nextDirectoryQueryStarted;
// Joining on another device lands while the directory is in flight.
session.memberships = [
_membership(_channelA, myPk),
_membership(_channelD, myPk),
];
await container.read(channelsProvider.notifier).refresh();
expect(
container.read(channelDirectoryLoadStatusProvider).status,
ChannelDirectoryLoadStatus.loading,
);
session.resumePausedDirectoryQuery();
await directory;
await _settle();
final listed = container.read(channelsProvider).requireValue;
expect(
listed.map((c) => c.id),
unorderedEquals([_channelA, _channelB, _channelD]),
);
expect(listed.firstWhere((c) => c.id == _channelD).isMember, isTrue);
expect(listed.firstWhere((c) => c.id == _channelB).isMember, isFalse);
expect(
container.read(channelDirectoryLoadStatusProvider).status,
ChannelDirectoryLoadStatus.loaded,
);
});
test(
'a refresh that merged the directory before it landed keeps it',
() async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
metadata: [
_meta(id: _channelA, name: 'general'),
_meta(id: _channelB, name: 'discoverable'),
],
);
final container = await loaded(session);
// The refresh merges the directory it has (none) and then waits for
// its latest messages while the search load completes.
final gate = Completer<void>();
session.latestBatchGate = gate;
final refresh = container.read(channelsProvider.notifier).refresh();
await _waitUntil(() => session.latestBatchGate == null);
await container
.read(channelsProvider.notifier)
.ensureDirectoryLoaded(maxAge: Duration.zero);
expect(listedIds(container), unorderedEquals([_channelA, _channelB]));
gate.complete();
await refresh;
expect(listedIds(container), unorderedEquals([_channelA, _channelB]));
},
);
test('a community switch discards the response', () 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 communityB = channelDirectoryScope(
'https://community-b.example',
container.read(myPubkeyProvider),
);
session.pauseNextDirectoryQuery();
final directory = container
.read(channelsProvider.notifier)
.ensureDirectoryLoaded(maxAge: Duration.zero);
await session.nextDirectoryQueryStarted;
session.memberships = [_membership(_channelD, myPk)];
session.metadata = [_meta(id: _channelD, name: 'community-b-general')];
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'https://community-b.example');
await container.read(channelsProvider.future);
session.resumePausedDirectoryQuery();
await directory;
await _settle();
expect(listedIds(container), [_channelD]);
expect(
container.read(channelDirectoryLoadStatusProvider).scope,
isNot(communityB),
);
});
});
test('membership refresh does not refetch a loaded directory', () async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
@@ -3290,6 +3465,9 @@ class _FakeRelaySession extends RelaySessionNotifier {
/// While set, member metadata reads wait for it to complete.
Completer<void>? metadataGate;
// Holds the next latest-message batch, which a list refresh awaits after it
// has merged the directory.
Completer<void>? latestBatchGate;
final List<List<NostrFilter>> queryBatches = [];
Object? messageBatchError;
Object? latestBatchError;
@@ -3672,6 +3850,11 @@ class _FakeRelaySession extends RelaySessionNotifier {
final isUnreadCatchUp =
filters.isNotEmpty && filters.every((filter) => filter.since != null);
if (!isUnreadCatchUp && latestBatchError != null) throw latestBatchError!;
if (!isUnreadCatchUp) {
final gate = latestBatchGate;
latestBatchGate = null;
await gate?.future;
}
final messageSnapshot = List.of(recentMessages);
if (isUnreadCatchUp) {
// Claim the parked slot so the refresh that follows the switch can run