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:
parent
3de01bd948
commit
f17ae7751c
9 changed files with 513 additions and 43 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
45
apps/app_seeds/test/ui/inventory_list_lazy_test.dart
Normal file
45
apps/app_seeds/test/ui/inventory_list_lazy_test.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue