mirror of
https://github.com/block/buzz.git
synced 2026-11-15 13:51:12 +00:00
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:
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user