diff --git a/packages/firebase_ui_firestore/lib/src/query_builder.dart b/packages/firebase_ui_firestore/lib/src/query_builder.dart index 8419972c..8511453a 100644 --- a/packages/firebase_ui_firestore/lib/src/query_builder.dart +++ b/packages/firebase_ui_firestore/lib/src/query_builder.dart @@ -178,18 +178,43 @@ class _FirestoreQueryBuilderState final query = widget.query.limit(expectedDocsCount); + // A new listener first emits whatever the local cache holds, which is + // often less than the pages already shown. Rendering that would shrink + // the list and reset the scroll position, so keep the current docs until + // the server responds. A drop of one doc is still rendered, since that is + // what a local delete looks like. Metadata changes are included so that a + // server result identical to the cache still arrives as an event. + var awaitingServer = nextPage; + var hasRendered = false; + _querySubscription = query - .snapshots(includeMetadataChanges: widget.includeMetadataChanges) + .snapshots( + includeMetadataChanges: + widget.includeMetadataChanges || awaitingServer, + ) .listen( (event) { - setState(() { - if (nextPage) { - _snapshot = _snapshot.copyWith(isFetchingMore: false); - } else { - _snapshot = _snapshot.copyWith(isFetching: false); + if (awaitingServer) { + if (event.metadata.isFromCache && + event.size < _snapshot.docs.length - 1) { + return; } + awaitingServer = false; + } + + // Metadata-only events have no doc changes. Only render them if + // the consumer asked for metadata changes. + if (hasRendered && + !widget.includeMetadataChanges && + event.docChanges.isEmpty) { + return; + } + hasRendered = true; + setState(() { _snapshot = _snapshot.copyWith( + isFetching: false, + isFetchingMore: false, hasData: true, docs: event.size < expectedDocsCount ? event.docs @@ -203,13 +228,9 @@ class _FirestoreQueryBuilderState }, onError: (Object error, StackTrace stackTrace) { setState(() { - if (nextPage) { - _snapshot = _snapshot.copyWith(isFetchingMore: false); - } else { - _snapshot = _snapshot.copyWith(isFetching: false); - } - _snapshot = _snapshot.copyWith( + isFetching: false, + isFetchingMore: false, error: error, stackTrace: stackTrace, hasError: true, diff --git a/packages/firebase_ui_firestore/test/query_builder_test.dart b/packages/firebase_ui_firestore/test/query_builder_test.dart new file mode 100644 index 00000000..8328bcfe --- /dev/null +++ b/packages/firebase_ui_firestore/test/query_builder_test.dart @@ -0,0 +1,358 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// ignore_for_file: subtype_of_sealed_class + +import 'dart:async'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_ui_firestore/firebase_ui_firestore.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +typedef Json = Map; + +void main() { + late FakeQuery query; + late FirestoreQueryBuilderSnapshot snapshot; + late int builds; + + Future pumpBuilder( + WidgetTester tester, { + Query? otherQuery, + bool includeMetadataChanges = false, + }) { + return tester.pumpWidget( + FirestoreQueryBuilder( + query: otherQuery ?? query, + pageSize: 2, + includeMetadataChanges: includeMetadataChanges, + builder: (context, s, _) { + builds++; + snapshot = s; + return const SizedBox(); + }, + ), + ); + } + + setUp(() { + query = FakeQuery(); + builds = 0; + }); + + testWidgets('keeps loaded pages while the next page is only in cache', ( + tester, + ) async { + await pumpBuilder(tester); + + query.emit(3, size: 3, fromCache: false); + await tester.pump(); + expect(snapshot.docs, hasLength(2)); + expect(snapshot.hasMore, isTrue); + + snapshot.fetchMore(); + await tester.pump(); + + // The new listener's cache holds none of the shown docs. + query.emit(5, size: 0, fromCache: true); + await tester.pump(); + expect(snapshot.docs, hasLength(2)); + expect(snapshot.hasMore, isTrue); + expect(snapshot.isFetchingMore, isTrue); + + query.emit(5, size: 5, fromCache: false); + await tester.pump(); + expect(snapshot.docs, hasLength(4)); + expect(snapshot.hasMore, isTrue); + expect(snapshot.isFetchingMore, isFalse); + }); + + testWidgets('renders a full cache snapshot while fetching the next page', ( + tester, + ) async { + await pumpBuilder(tester); + + query.emit(3, size: 3, fromCache: false); + await tester.pump(); + + snapshot.fetchMore(); + await tester.pump(); + + query.emit(5, size: 5, fromCache: true); + await tester.pump(); + expect(snapshot.docs, hasLength(4)); + expect(snapshot.isFetchingMore, isFalse); + }); + + testWidgets('renders cache updates after the server snapshot', ( + tester, + ) async { + await pumpBuilder(tester); + + query.emit(3, size: 3, fromCache: false); + await tester.pump(); + + snapshot.fetchMore(); + await tester.pump(); + + query.emit(5, size: 5, fromCache: false); + await tester.pump(); + + // e.g. a doc deleted while offline. + query.emit(5, size: 3, fromCache: true); + await tester.pump(); + expect(snapshot.docs, hasLength(3)); + expect(snapshot.hasMore, isFalse); + }); + + testWidgets( + 'renders a partial cache snapshot that does not shrink the list', + (tester) async { + await pumpBuilder(tester); + + query.emit(3, size: 3, fromCache: false); + await tester.pump(); + + snapshot.fetchMore(); + await tester.pump(); + + // e.g. offline, or the cache already holds the end of the collection. + query.emit(5, size: 3, fromCache: true); + await tester.pump(); + expect(snapshot.docs, hasLength(3)); + expect(snapshot.hasMore, isFalse); + expect(snapshot.isFetchingMore, isFalse); + }, + ); + + testWidgets('listens to metadata changes while waiting for the server', ( + tester, + ) async { + await pumpBuilder(tester); + expect(query.includeMetadataChanges[3], isFalse); + + query.emit(3, size: 3, fromCache: false); + await tester.pump(); + + snapshot.fetchMore(); + await tester.pump(); + expect(query.includeMetadataChanges[5], isTrue); + + query.emit(5, size: 0, fromCache: true); + await tester.pump(); + + // The server confirms the cached result, which the SDK only reports as + // a metadata change. + query.emit(5, size: 0, fromCache: false, metadataOnly: true); + await tester.pump(); + expect(snapshot.docs, isEmpty); + expect(snapshot.isFetchingMore, isFalse); + }); + + testWidgets( + 'clears isFetchingMore on an error while waiting for the server', + (tester) async { + await pumpBuilder(tester); + + query.emit(3, size: 3, fromCache: false); + await tester.pump(); + + snapshot.fetchMore(); + await tester.pump(); + + query.emit(5, size: 0, fromCache: true); + query.emitError(5); + await tester.pump(); + expect(snapshot.hasError, isTrue); + expect(snapshot.isFetchingMore, isFalse); + }, + ); + + testWidgets('clears isFetchingMore when the query changes mid-fetch', ( + tester, + ) async { + await pumpBuilder(tester); + + query.emit(3, size: 3, fromCache: false); + await tester.pump(); + + snapshot.fetchMore(); + await tester.pump(); + await tester.pump(); + expect(snapshot.isFetchingMore, isTrue); + + final otherQuery = FakeQuery(); + await pumpBuilder(tester, otherQuery: otherQuery); + + otherQuery.emit(3, size: 1, fromCache: true); + await tester.pump(); + expect(snapshot.docs, hasLength(1)); + expect(snapshot.isFetchingMore, isFalse); + }); + + Future loadTwoPages(WidgetTester tester) async { + await pumpBuilder(tester); + + query.emit(3, size: 3, fromCache: false); + await tester.pump(); + + snapshot.fetchMore(); + await tester.pump(); + query.emit(5, size: 5, fromCache: false); + await tester.pump(); + expect(snapshot.docs, hasLength(4)); + + snapshot.fetchMore(); + await tester.pump(); + } + + testWidgets( + 'ignores a cache snapshot with local writes that shrinks the list', + (tester) async { + await loadTwoPages(tester); + + // e.g. the only cached doc is listened to elsewhere and has a local write. + query.emit(7, size: 1, fromCache: true, hasPendingWrites: true); + await tester.pump(); + expect(snapshot.docs, hasLength(4)); + expect(snapshot.isFetchingMore, isTrue); + }, + ); + + testWidgets('renders a local delete while waiting for the server', ( + tester, + ) async { + await loadTwoPages(tester); + + query.emit(7, size: 3, fromCache: true, hasPendingWrites: true); + await tester.pump(); + expect(snapshot.docs, hasLength(3)); + expect(snapshot.isFetchingMore, isFalse); + }); + + testWidgets('ignores metadata-only events after the server snapshot', ( + tester, + ) async { + await pumpBuilder(tester); + + query.emit(3, size: 3, fromCache: false); + await tester.pump(); + + snapshot.fetchMore(); + await tester.pump(); + query.emit(5, size: 5, fromCache: false); + await tester.pump(); + final buildsBefore = builds; + + // e.g. the device goes offline, or a write is confirmed. + query.emit(5, size: 5, fromCache: true, metadataOnly: true); + await tester.pump(); + expect(builds, buildsBefore); + }); + + testWidgets('renders metadata-only events when includeMetadataChanges', ( + tester, + ) async { + await pumpBuilder(tester, includeMetadataChanges: true); + + query.emit(3, size: 3, fromCache: false); + await tester.pump(); + final buildsBefore = builds; + + query.emit(3, size: 3, fromCache: true, metadataOnly: true); + await tester.pump(); + expect(builds, greaterThan(buildsBefore)); + }); + + testWidgets('renders an empty first page', (tester) async { + await pumpBuilder(tester); + + query.emit(3, size: 0, fromCache: false, metadataOnly: true); + await tester.pump(); + expect(snapshot.hasData, isTrue); + expect(snapshot.isFetching, isFalse); + }); +} + +class FakeQuery extends Fake implements Query { + final _controllers = >>{}; + final includeMetadataChanges = {}; + + void emit( + int limit, { + required int size, + required bool fromCache, + bool hasPendingWrites = false, + bool metadataOnly = false, + }) { + _controllers[limit]!.add( + FakeQuerySnapshot( + size, + FakeMetadata(fromCache, hasPendingWrites), + hasDocChanges: !metadataOnly, + ), + ); + } + + void emitError(int limit) { + _controllers[limit]!.addError(Exception('permission-denied')); + } + + @override + Query limit(int limit) => FakeLimitedQuery(this, limit); +} + +class FakeLimitedQuery extends Fake implements Query { + FakeLimitedQuery(this._parent, this._limit); + + final FakeQuery _parent; + final int _limit; + + @override + Stream> snapshots({ + bool includeMetadataChanges = false, + ListenSource source = ListenSource.defaultSource, + }) { + _parent.includeMetadataChanges[_limit] = includeMetadataChanges; + return _parent._controllers + .putIfAbsent(_limit, () => StreamController.broadcast(sync: true)) + .stream; + } +} + +class FakeQuerySnapshot extends Fake implements QuerySnapshot { + FakeQuerySnapshot(this.size, this.metadata, {required this.hasDocChanges}); + + final bool hasDocChanges; + + @override + final int size; + + @override + final SnapshotMetadata metadata; + + @override + List> get docs => + List.generate(size, (_) => FakeDocumentSnapshot()); + + @override + List> get docChanges => + hasDocChanges ? [FakeDocumentChange()] : []; +} + +class FakeMetadata extends Fake implements SnapshotMetadata { + FakeMetadata(this.isFromCache, this.hasPendingWrites); + + @override + final bool isFromCache; + + @override + final bool hasPendingWrites; +} + +class FakeDocumentChange extends Fake implements DocumentChange {} + +class FakeDocumentSnapshot extends Fake + implements QueryDocumentSnapshot {} diff --git a/packages/firebase_ui_firestore/test/table_builder_test.dart b/packages/firebase_ui_firestore/test/table_builder_test.dart index 755ad1a8..487496f9 100644 --- a/packages/firebase_ui_firestore/test/table_builder_test.dart +++ b/packages/firebase_ui_firestore/test/table_builder_test.dart @@ -428,8 +428,20 @@ class MockQuerySnapshot extends Mock implements Snapshot { returnValueForMissingStub: [bobSnapshot, bob2Snapshot], ); } + + @override + List>> get docChanges { + return super.noSuchMethod( + Invocation.getter(#docChanges), + returnValue: [MockDocumentChange()], + returnValueForMissingStub: [MockDocumentChange()], + ); + } } +class MockDocumentChange extends Mock + implements DocumentChange> {} + final mockQuerySnapshot = MockQuerySnapshot(); final mockCtrl = StreamController.broadcast();