From 476409547ee25f94e273bd05d1a0fdc12d4f2271 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:40:24 +0100 Subject: [PATCH 1/5] fix(ui_firestore): keep loaded pages while the next page is only in cache --- .../lib/src/query_builder.dart | 14 ++ .../test/query_builder_test.dart | 151 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 packages/firebase_ui_firestore/test/query_builder_test.dart diff --git a/packages/firebase_ui_firestore/lib/src/query_builder.dart b/packages/firebase_ui_firestore/lib/src/query_builder.dart index 8419972c..19ac4e47 100644 --- a/packages/firebase_ui_firestore/lib/src/query_builder.dart +++ b/packages/firebase_ui_firestore/lib/src/query_builder.dart @@ -178,10 +178,24 @@ 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 pages until + // the server responds. + var awaitingServer = nextPage; + _querySubscription = query .snapshots(includeMetadataChanges: widget.includeMetadataChanges) .listen( (event) { + if (awaitingServer) { + if (event.metadata.isFromCache && + event.size < expectedDocsCount) { + return; + } + awaitingServer = false; + } + setState(() { if (nextPage) { _snapshot = _snapshot.copyWith(isFetchingMore: false); 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..3cbc6a29 --- /dev/null +++ b/packages/firebase_ui_firestore/test/query_builder_test.dart @@ -0,0 +1,151 @@ +// 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; + + Future pumpBuilder(WidgetTester tester) { + return tester.pumpWidget( + FirestoreQueryBuilder( + query: query, + pageSize: 2, + builder: (context, s, _) { + snapshot = s; + return const SizedBox(); + }, + ), + ); + } + + setUp(() => query = FakeQuery()); + + 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 only holds one doc. + query.emit(5, size: 1, 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); + }); +} + +class FakeQuery extends Fake implements Query { + final _controllers = >>{}; + + void emit(int limit, {required int size, required bool fromCache}) { + _controllers[limit]!.add(FakeQuerySnapshot(size, fromCache)); + } + + @override + Query limit(int limit) => FakeLimitedQuery( + _controllers + .putIfAbsent(limit, () => StreamController.broadcast(sync: true)) + .stream, + ); +} + +class FakeLimitedQuery extends Fake implements Query { + FakeLimitedQuery(this._stream); + + final Stream> _stream; + + @override + Stream> snapshots({ + bool includeMetadataChanges = false, + ListenSource source = ListenSource.defaultSource, + }) => _stream; +} + +class FakeQuerySnapshot extends Fake implements QuerySnapshot { + FakeQuerySnapshot(this.size, bool fromCache) + : metadata = FakeMetadata(fromCache); + + @override + final int size; + + @override + final SnapshotMetadata metadata; + + @override + List> get docs => + List.generate(size, (_) => FakeDocumentSnapshot()); +} + +class FakeMetadata extends Fake implements SnapshotMetadata { + FakeMetadata(this.isFromCache); + + @override + final bool isFromCache; +} + +class FakeDocumentSnapshot extends Fake + implements QueryDocumentSnapshot {} From a1bab7d72f83bd6bc365bfc623ca1711341212b4 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:52:07 +0100 Subject: [PATCH 2/5] fix(ui_firestore): avoid hanging fetchMore when the cache holds the rest of the list --- .../lib/src/query_builder.dart | 34 ++--- .../test/query_builder_test.dart | 144 ++++++++++++++++-- 2 files changed, 148 insertions(+), 30 deletions(-) diff --git a/packages/firebase_ui_firestore/lib/src/query_builder.dart b/packages/firebase_ui_firestore/lib/src/query_builder.dart index 19ac4e47..845b3b82 100644 --- a/packages/firebase_ui_firestore/lib/src/query_builder.dart +++ b/packages/firebase_ui_firestore/lib/src/query_builder.dart @@ -151,11 +151,11 @@ class _FirestoreQueryBuilderState // preserving the current progress. final previousItemCount = (oldWidget.pageSize + 1) * _pageCount; _pageCount = (previousItemCount / widget.pageSize).ceil(); - _listenQuery(); + _listenQuery(keepDocs: true); } } - void _listenQuery({bool nextPage = false}) { + void _listenQuery({bool nextPage = false, bool keepDocs = false}) { _querySubscription?.cancel(); if (nextPage) { @@ -180,30 +180,30 @@ class _FirestoreQueryBuilderState // 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 pages until - // the server responds. - var awaitingServer = nextPage; + // the list and reset the scroll position, so keep the current docs until + // the server responds. Metadata changes are included so that a server + // result identical to the cache still arrives as an event. + var awaitingServer = nextPage || keepDocs; _querySubscription = query - .snapshots(includeMetadataChanges: widget.includeMetadataChanges) + .snapshots( + includeMetadataChanges: + widget.includeMetadataChanges || awaitingServer, + ) .listen( (event) { if (awaitingServer) { if (event.metadata.isFromCache && - event.size < expectedDocsCount) { + event.size < _snapshot.docs.length) { return; } awaitingServer = false; } setState(() { - if (nextPage) { - _snapshot = _snapshot.copyWith(isFetchingMore: false); - } else { - _snapshot = _snapshot.copyWith(isFetching: false); - } - _snapshot = _snapshot.copyWith( + isFetching: false, + isFetchingMore: false, hasData: true, docs: event.size < expectedDocsCount ? event.docs @@ -217,13 +217,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 index 3cbc6a29..a4c70f1d 100644 --- a/packages/firebase_ui_firestore/test/query_builder_test.dart +++ b/packages/firebase_ui_firestore/test/query_builder_test.dart @@ -17,11 +17,15 @@ void main() { late FakeQuery query; late FirestoreQueryBuilderSnapshot snapshot; - Future pumpBuilder(WidgetTester tester) { + Future pumpBuilder( + WidgetTester tester, { + Query? otherQuery, + int pageSize = 2, + }) { return tester.pumpWidget( FirestoreQueryBuilder( - query: query, - pageSize: 2, + query: otherQuery ?? query, + pageSize: pageSize, builder: (context, s, _) { snapshot = s; return const SizedBox(); @@ -96,33 +100,151 @@ void main() { 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: 1, fromCache: true); + await tester.pump(); + + // The server confirms the cached result, which the SDK only reports as + // a metadata change. + query.emit(5, size: 1, fromCache: false); + await tester.pump(); + expect(snapshot.docs, hasLength(1)); + 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: 1, 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); + }); + + testWidgets('keeps loaded docs while the page size changes', (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)); + + await pumpBuilder(tester, pageSize: 3); + await tester.pump(); + + query.emit(7, size: 1, fromCache: true); + await tester.pump(); + expect(snapshot.docs, hasLength(4)); + expect(snapshot.isFetching, isTrue); + + query.emit(7, size: 7, fromCache: false); + await tester.pump(); + expect(snapshot.docs, hasLength(6)); + expect(snapshot.isFetching, isFalse); + }); } class FakeQuery extends Fake implements Query { final _controllers = >>{}; + final includeMetadataChanges = {}; void emit(int limit, {required int size, required bool fromCache}) { _controllers[limit]!.add(FakeQuerySnapshot(size, fromCache)); } + void emitError(int limit) { + _controllers[limit]!.addError(Exception('permission-denied')); + } + @override - Query limit(int limit) => FakeLimitedQuery( - _controllers - .putIfAbsent(limit, () => StreamController.broadcast(sync: true)) - .stream, - ); + Query limit(int limit) => FakeLimitedQuery(this, limit); } class FakeLimitedQuery extends Fake implements Query { - FakeLimitedQuery(this._stream); + FakeLimitedQuery(this._parent, this._limit); - final Stream> _stream; + final FakeQuery _parent; + final int _limit; @override Stream> snapshots({ bool includeMetadataChanges = false, ListenSource source = ListenSource.defaultSource, - }) => _stream; + }) { + _parent.includeMetadataChanges[_limit] = includeMetadataChanges; + return _parent._controllers + .putIfAbsent(_limit, () => StreamController.broadcast(sync: true)) + .stream; + } } class FakeQuerySnapshot extends Fake implements QuerySnapshot { From 6cbf28f479f8aee26bb1b55a36641f9eb41c82c2 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:02:55 +0100 Subject: [PATCH 3/5] fix(ui_firestore): render local writes while waiting for the next page --- .../lib/src/query_builder.dart | 12 +++-- .../test/query_builder_test.dart | 49 +++++++++---------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/packages/firebase_ui_firestore/lib/src/query_builder.dart b/packages/firebase_ui_firestore/lib/src/query_builder.dart index 845b3b82..27baafce 100644 --- a/packages/firebase_ui_firestore/lib/src/query_builder.dart +++ b/packages/firebase_ui_firestore/lib/src/query_builder.dart @@ -151,11 +151,11 @@ class _FirestoreQueryBuilderState // preserving the current progress. final previousItemCount = (oldWidget.pageSize + 1) * _pageCount; _pageCount = (previousItemCount / widget.pageSize).ceil(); - _listenQuery(keepDocs: true); + _listenQuery(); } } - void _listenQuery({bool nextPage = false, bool keepDocs = false}) { + void _listenQuery({bool nextPage = false}) { _querySubscription?.cancel(); if (nextPage) { @@ -181,9 +181,10 @@ class _FirestoreQueryBuilderState // 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. Metadata changes are included so that a server - // result identical to the cache still arrives as an event. - var awaitingServer = nextPage || keepDocs; + // the server responds or the user writes locally. Metadata changes are + // included so that a server result identical to the cache still arrives + // as an event. + var awaitingServer = nextPage; _querySubscription = query .snapshots( @@ -194,6 +195,7 @@ class _FirestoreQueryBuilderState (event) { if (awaitingServer) { if (event.metadata.isFromCache && + !event.metadata.hasPendingWrites && event.size < _snapshot.docs.length) { return; } diff --git a/packages/firebase_ui_firestore/test/query_builder_test.dart b/packages/firebase_ui_firestore/test/query_builder_test.dart index a4c70f1d..4b88f55f 100644 --- a/packages/firebase_ui_firestore/test/query_builder_test.dart +++ b/packages/firebase_ui_firestore/test/query_builder_test.dart @@ -17,15 +17,11 @@ void main() { late FakeQuery query; late FirestoreQueryBuilderSnapshot snapshot; - Future pumpBuilder( - WidgetTester tester, { - Query? otherQuery, - int pageSize = 2, - }) { + Future pumpBuilder(WidgetTester tester, {Query? otherQuery}) { return tester.pumpWidget( FirestoreQueryBuilder( query: otherQuery ?? query, - pageSize: pageSize, + pageSize: 2, builder: (context, s, _) { snapshot = s; return const SizedBox(); @@ -186,7 +182,9 @@ void main() { expect(snapshot.isFetchingMore, isFalse); }); - testWidgets('keeps loaded docs while the page size changes', (tester) async { + testWidgets('renders local writes while waiting for the server', ( + tester, + ) async { await pumpBuilder(tester); query.emit(3, size: 3, fromCache: false); @@ -194,22 +192,12 @@ void main() { snapshot.fetchMore(); await tester.pump(); - query.emit(5, size: 5, fromCache: false); - await tester.pump(); - expect(snapshot.docs, hasLength(4)); - - await pumpBuilder(tester, pageSize: 3); - await tester.pump(); - query.emit(7, size: 1, fromCache: true); + // e.g. offline, the user deletes a doc after the cache evicted the rest. + query.emit(5, size: 1, fromCache: true, hasPendingWrites: true); await tester.pump(); - expect(snapshot.docs, hasLength(4)); - expect(snapshot.isFetching, isTrue); - - query.emit(7, size: 7, fromCache: false); - await tester.pump(); - expect(snapshot.docs, hasLength(6)); - expect(snapshot.isFetching, isFalse); + expect(snapshot.docs, hasLength(1)); + expect(snapshot.isFetchingMore, isFalse); }); } @@ -217,8 +205,15 @@ class FakeQuery extends Fake implements Query { final _controllers = >>{}; final includeMetadataChanges = {}; - void emit(int limit, {required int size, required bool fromCache}) { - _controllers[limit]!.add(FakeQuerySnapshot(size, fromCache)); + void emit( + int limit, { + required int size, + required bool fromCache, + bool hasPendingWrites = false, + }) { + _controllers[limit]!.add( + FakeQuerySnapshot(size, FakeMetadata(fromCache, hasPendingWrites)), + ); } void emitError(int limit) { @@ -248,8 +243,7 @@ class FakeLimitedQuery extends Fake implements Query { } class FakeQuerySnapshot extends Fake implements QuerySnapshot { - FakeQuerySnapshot(this.size, bool fromCache) - : metadata = FakeMetadata(fromCache); + FakeQuerySnapshot(this.size, this.metadata); @override final int size; @@ -263,10 +257,13 @@ class FakeQuerySnapshot extends Fake implements QuerySnapshot { } class FakeMetadata extends Fake implements SnapshotMetadata { - FakeMetadata(this.isFromCache); + FakeMetadata(this.isFromCache, this.hasPendingWrites); @override final bool isFromCache; + + @override + final bool hasPendingWrites; } class FakeDocumentSnapshot extends Fake From 09efc294e6e7e064bd9639e91fa36e6bc2800fca Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:26:53 +0100 Subject: [PATCH 4/5] fix(ui_firestore): ignore metadata-only events unless includeMetadataChanges is set --- .../lib/src/query_builder.dart | 10 +++ .../test/query_builder_test.dart | 76 +++++++++++++++++-- .../test/table_builder_test.dart | 12 +++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/packages/firebase_ui_firestore/lib/src/query_builder.dart b/packages/firebase_ui_firestore/lib/src/query_builder.dart index 27baafce..3a194832 100644 --- a/packages/firebase_ui_firestore/lib/src/query_builder.dart +++ b/packages/firebase_ui_firestore/lib/src/query_builder.dart @@ -185,6 +185,7 @@ class _FirestoreQueryBuilderState // included so that a server result identical to the cache still arrives // as an event. var awaitingServer = nextPage; + var hasRendered = false; _querySubscription = query .snapshots( @@ -202,6 +203,15 @@ class _FirestoreQueryBuilderState 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, diff --git a/packages/firebase_ui_firestore/test/query_builder_test.dart b/packages/firebase_ui_firestore/test/query_builder_test.dart index 4b88f55f..5eab1971 100644 --- a/packages/firebase_ui_firestore/test/query_builder_test.dart +++ b/packages/firebase_ui_firestore/test/query_builder_test.dart @@ -16,13 +16,20 @@ typedef Json = Map; void main() { late FakeQuery query; late FirestoreQueryBuilderSnapshot snapshot; + late int builds; - Future pumpBuilder(WidgetTester tester, {Query? otherQuery}) { + 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(); }, @@ -30,7 +37,10 @@ void main() { ); } - setUp(() => query = FakeQuery()); + setUp(() { + query = FakeQuery(); + builds = 0; + }); testWidgets('keeps loaded pages while the next page is only in cache', ( tester, @@ -135,7 +145,7 @@ void main() { // The server confirms the cached result, which the SDK only reports as // a metadata change. - query.emit(5, size: 1, fromCache: false); + query.emit(5, size: 1, fromCache: false, metadataOnly: true); await tester.pump(); expect(snapshot.docs, hasLength(1)); expect(snapshot.isFetchingMore, isFalse); @@ -199,6 +209,49 @@ void main() { expect(snapshot.docs, hasLength(1)); 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 { @@ -210,9 +263,14 @@ class FakeQuery extends Fake implements Query { required int size, required bool fromCache, bool hasPendingWrites = false, + bool metadataOnly = false, }) { _controllers[limit]!.add( - FakeQuerySnapshot(size, FakeMetadata(fromCache, hasPendingWrites)), + FakeQuerySnapshot( + size, + FakeMetadata(fromCache, hasPendingWrites), + hasDocChanges: !metadataOnly, + ), ); } @@ -243,7 +301,9 @@ class FakeLimitedQuery extends Fake implements Query { } class FakeQuerySnapshot extends Fake implements QuerySnapshot { - FakeQuerySnapshot(this.size, this.metadata); + FakeQuerySnapshot(this.size, this.metadata, {required this.hasDocChanges}); + + final bool hasDocChanges; @override final int size; @@ -254,6 +314,10 @@ class FakeQuerySnapshot extends Fake implements QuerySnapshot { @override List> get docs => List.generate(size, (_) => FakeDocumentSnapshot()); + + @override + List> get docChanges => + hasDocChanges ? [FakeDocumentChange()] : []; } class FakeMetadata extends Fake implements SnapshotMetadata { @@ -266,5 +330,7 @@ class FakeMetadata extends Fake implements SnapshotMetadata { 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(); From c0daf08f584c43a0b44a5838851da88b5b9c1ca3 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:34:33 +0100 Subject: [PATCH 5/5] fix(ui_firestore): ignore cache snapshots that drop more than one doc while fetching more --- .../lib/src/query_builder.dart | 9 ++-- .../test/query_builder_test.dart | 46 ++++++++++++++----- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/packages/firebase_ui_firestore/lib/src/query_builder.dart b/packages/firebase_ui_firestore/lib/src/query_builder.dart index 3a194832..8511453a 100644 --- a/packages/firebase_ui_firestore/lib/src/query_builder.dart +++ b/packages/firebase_ui_firestore/lib/src/query_builder.dart @@ -181,9 +181,9 @@ class _FirestoreQueryBuilderState // 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 or the user writes locally. Metadata changes are - // included so that a server result identical to the cache still arrives - // as an event. + // 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; @@ -196,8 +196,7 @@ class _FirestoreQueryBuilderState (event) { if (awaitingServer) { if (event.metadata.isFromCache && - !event.metadata.hasPendingWrites && - event.size < _snapshot.docs.length) { + event.size < _snapshot.docs.length - 1) { return; } awaitingServer = false; diff --git a/packages/firebase_ui_firestore/test/query_builder_test.dart b/packages/firebase_ui_firestore/test/query_builder_test.dart index 5eab1971..8328bcfe 100644 --- a/packages/firebase_ui_firestore/test/query_builder_test.dart +++ b/packages/firebase_ui_firestore/test/query_builder_test.dart @@ -55,8 +55,8 @@ void main() { snapshot.fetchMore(); await tester.pump(); - // The new listener's cache only holds one doc. - query.emit(5, size: 1, fromCache: true); + // 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); @@ -140,14 +140,14 @@ void main() { await tester.pump(); expect(query.includeMetadataChanges[5], isTrue); - query.emit(5, size: 1, fromCache: true); + 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: 1, fromCache: false, metadataOnly: true); + query.emit(5, size: 0, fromCache: false, metadataOnly: true); await tester.pump(); - expect(snapshot.docs, hasLength(1)); + expect(snapshot.docs, isEmpty); expect(snapshot.isFetchingMore, isFalse); }); @@ -162,7 +162,7 @@ void main() { snapshot.fetchMore(); await tester.pump(); - query.emit(5, size: 1, fromCache: true); + query.emit(5, size: 0, fromCache: true); query.emitError(5); await tester.pump(); expect(snapshot.hasError, isTrue); @@ -192,9 +192,7 @@ void main() { expect(snapshot.isFetchingMore, isFalse); }); - testWidgets('renders local writes while waiting for the server', ( - tester, - ) async { + Future loadTwoPages(WidgetTester tester) async { await pumpBuilder(tester); query.emit(3, size: 3, fromCache: false); @@ -202,11 +200,35 @@ void main() { snapshot.fetchMore(); await tester.pump(); + query.emit(5, size: 5, fromCache: false); + await tester.pump(); + expect(snapshot.docs, hasLength(4)); - // e.g. offline, the user deletes a doc after the cache evicted the rest. - query.emit(5, size: 1, fromCache: true, hasPendingWrites: true); + snapshot.fetchMore(); await tester.pump(); - expect(snapshot.docs, hasLength(1)); + } + + 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); });