perf(market): paginate Nostr offer discovery, infinite scroll, memory cap

Bound market memory and let large areas page instead of loading everything:
- DiscoveryQuery gains an until cursor; OfferTransport gains discoverPage()
  (one-shot, EOSE-bounded, newest-first) alongside the existing live discover()
  stream, which now accepts since so it only carries NEW offers going forward.
- NostrOfferTransport.discoverPage sorts by created_at desc and derives the
  next cursor from the oldest event seen (not the oldest type-matched one, so
  filtering never skips a page).
- OffersCubit: discover() fetches the first page + opens a since=now live sub;
  loadNextPage() pages further back; the in-memory list is capped at 400
  offers (each can carry a ~40KB inline photo thumbnail, so unbounded growth in
  a busy area was a real OOM risk).
- market_screen: infinite scroll via a scroll-position trigger + footer
  spinner, instead of a single unbounded ListView.
- Added discoverPage unit tests (commons_core) and cubit pagination tests
  (first page/cursor, accumulation, cap, cross-page dedup).
This commit is contained in:
vjrj 2026-07-20 22:47:12 +02:00
parent 3de01bd948
commit f17ae7751c
9 changed files with 513 additions and 43 deletions

View file

@ -156,7 +156,11 @@ class _SeededOffersCubit extends OffersCubit {
/// A transport that is present (so the market reads as online) but never used.
class _NoopOfferTransport implements OfferTransport {
@override
Stream<Offer> discover(DiscoveryQuery query) => const Stream.empty();
Stream<Offer> discover(DiscoveryQuery query, {int? since}) =>
const Stream.empty();
@override
Future<OfferPage> discoverPage(DiscoveryQuery query) async =>
const OfferPage(offers: []);
@override
Future<PublishResult> publish(Offer offer) => throw UnimplementedError();
@override

View file

@ -25,7 +25,7 @@ class FakeOfferTransport implements OfferTransport {
}
@override
Stream<Offer> discover(DiscoveryQuery query) {
Stream<Offer> discover(DiscoveryQuery query, {int? since}) {
final controller = StreamController<Offer>();
for (final o in _offers) {
if (o.approxGeohash.startsWith(query.geohashPrefix)) controller.add(o);
@ -33,6 +33,15 @@ class FakeOfferTransport implements OfferTransport {
return controller.stream; // left open (live), like a real subscription
}
@override
Future<OfferPage> discoverPage(DiscoveryQuery query) async {
final matches = [
for (final o in _offers)
if (o.approxGeohash.startsWith(query.geohashPrefix)) o,
];
return OfferPage(offers: matches); // short page nothing older to load
}
@override
Future<void> retract(String offerId) async =>
_offers.removeWhere((o) => o.id == offerId);
@ -54,7 +63,7 @@ class DuplicatingOfferTransport implements OfferTransport {
}
@override
Stream<Offer> discover(DiscoveryQuery query) {
Stream<Offer> discover(DiscoveryQuery query, {int? since}) {
final controller = StreamController<Offer>();
for (final o in _offers) {
if (o.approxGeohash.startsWith(query.geohashPrefix)) {
@ -65,6 +74,17 @@ class DuplicatingOfferTransport implements OfferTransport {
return controller.stream;
}
@override
Future<OfferPage> discoverPage(DiscoveryQuery query) async {
// The stored copy (deduped at relay level); the live echo (the duplicate)
// still arrives via [discover], so the cubit's dedup is what's under test.
final matches = [
for (final o in _offers)
if (o.approxGeohash.startsWith(query.geohashPrefix)) o,
];
return OfferPage(offers: matches);
}
@override
Future<void> retract(String offerId) async =>
_offers.removeWhere((o) => o.id == offerId);
@ -73,6 +93,59 @@ class DuplicatingOfferTransport implements OfferTransport {
Future<void> close() async {}
}
/// A transport that serves offers in `until`-cursored pages (like a relay's
/// stored events), so the cubit's pagination and memory cap can be exercised.
/// Each seeded offer gets a descending createdAt; [discover] (live) is empty.
class PaginatingOfferTransport implements OfferTransport {
PaginatingOfferTransport(int count, {this.geohash = 'sp3e9'}) {
// Newest (highest createdAt) first: offer 0 is newest.
for (var i = 0; i < count; i++) {
_byCreatedAt.add((
createdAt: 1000000 - i,
offer: Offer(
id: 'o$i',
authorPubkeyHex: 'ab' * 32,
summary: 'offer $i',
type: OfferType.gift,
approxGeohash: geohash,
),
));
}
}
final String geohash;
final List<({int createdAt, Offer offer})> _byCreatedAt = [];
@override
Stream<Offer> discover(DiscoveryQuery query, {int? since}) =>
const Stream.empty();
@override
Future<OfferPage> discoverPage(DiscoveryQuery query) async {
final matched = _byCreatedAt
.where((e) => e.offer.approxGeohash.startsWith(query.geohashPrefix))
.where((e) => query.until == null || e.createdAt <= query.until!)
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
final page = matched.take(query.limit).toList();
final nextCursor = page.length >= query.limit && page.isNotEmpty
? page.last.createdAt - 1
: null;
return OfferPage(
offers: [for (final e in page) e.offer],
nextCursor: nextCursor,
);
}
@override
Future<PublishResult> publish(Offer offer) async =>
PublishResult(accepted: true, transportRef: offer.id);
@override
Future<void> retract(String offerId) async {}
@override
Future<void> close() async {}
}
void main() {
group('OfferMapper', () {
test('maps local sharing intent to the network offer type', () {
@ -701,4 +774,65 @@ void main() {
await cubit.close();
});
});
group('OffersCubit pagination', () {
test('discover loads the first page and exposes a next-page cursor',
() async {
final cubit = OffersCubit(PaginatingOfferTransport(250));
await cubit.discover('sp3');
await pumpEventQueue();
// One page (default limit 100) not the whole 250 is kept in memory.
expect(cubit.state.offers, hasLength(100));
expect(cubit.state.canLoadMore, isTrue);
expect(cubit.state.searching, isFalse);
await cubit.close();
});
test('loadNextPage accumulates older offers until the source is exhausted',
() async {
final cubit = OffersCubit(PaginatingOfferTransport(250));
await cubit.discover('sp3');
await pumpEventQueue();
await cubit.loadNextPage();
expect(cubit.state.offers, hasLength(200));
expect(cubit.state.canLoadMore, isTrue);
await cubit.loadNextPage();
// Only 250 exist: the third page is short, so there is nothing older.
expect(cubit.state.offers, hasLength(250));
expect(cubit.state.canLoadMore, isFalse);
// Paging past the end is a harmless no-op.
await cubit.loadNextPage();
expect(cubit.state.offers, hasLength(250));
await cubit.close();
});
test('the in-memory list is capped and paging stops at the cap', () async {
final cubit = OffersCubit(PaginatingOfferTransport(600));
await cubit.discover('sp3');
await pumpEventQueue();
// Keep paging until the cubit says there is no more.
var guard = 0;
while (cubit.state.canLoadMore && guard++ < 20) {
await cubit.loadNextPage();
}
expect(cubit.state.offers, hasLength(OffersCubit.maxOffersKept));
expect(cubit.state.canLoadMore, isFalse);
await cubit.close();
});
test('offers are de-duplicated across pages by (author, id)', () async {
// Two pages that overlap on the boundary id must not double it.
final cubit = OffersCubit(PaginatingOfferTransport(150));
await cubit.discover('sp3');
await pumpEventQueue();
await cubit.loadNextPage();
final ids = cubit.state.offers.map((o) => o.id).toList();
expect(ids.toSet(), hasLength(ids.length)); // no duplicates
expect(cubit.state.offers, hasLength(150));
await cubit.close();
});
});
}

View file

@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/ui/inventory_list_screen.dart';
import '../support/test_support.dart';
/// The inventory list must render lazily: with a large catalogue only the tiles
/// near the viewport are built, not one widget per row. Guards the regression
/// from `ListView(children: )` (every tile built upfront) back in.
void main() {
testWidgets('builds only the on-screen tiles for a large inventory',
(tester) async {
final db = newTestDatabase();
final repo = newTestRepository(db);
// Enough rows that an eager list would build hundreds of tiles at once.
for (var i = 0; i < 300; i++) {
await repo.addQuickVariety(
label: 'Variety ${i.toString().padLeft(3, '0')}',
category: i.isEven ? 'Poaceae' : 'Fabaceae',
);
}
await tester.pumpWidget(
wrapScreen(repository: repo, child: const InventoryListScreen()),
);
// Let the debounced inventory stream emit and the async load resolve
// (bounded pumps the screen holds a live Drift stream, so pumpAndSettle
// would hang; see testing.md).
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
await tester.pump(const Duration(milliseconds: 100));
// Every variety tile is a ListTile; a lazy list builds only a viewport-worth.
final built = find.byType(ListTile).evaluate().length;
expect(built, greaterThan(0), reason: 'the list rendered some tiles');
expect(
built,
lessThan(50),
reason: 'lazy list should build ~a screenful, not all 300 ($built built)',
);
await disposeTree(tester);
await db.close();
});
}