Skip to content

Commit 9114d4a

Browse files
authored
fix: ParseLiveList.getAt() causes unnecessary requests to server (#1099)
1 parent 2de7303 commit 9114d4a

File tree

3 files changed

+554
-43
lines changed

3 files changed

+554
-43
lines changed

packages/dart/lib/src/utils/parse_live_list.dart

Lines changed: 207 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,23 @@ class ParseLiveList<T extends ParseObject> {
99
List<String>? preloadedColumns,
1010
}) : _preloadedColumns = preloadedColumns ?? const <String>[] {
1111
_debug = isDebugEnabled();
12+
_debugLoggedInit = isDebugEnabled();
1213
}
1314

15+
/// Creates a new [ParseLiveList] for the given [query].
16+
///
17+
/// [lazyLoading] enables lazy loading of full object data. When `true` and
18+
/// [preloadedColumns] is provided, the initial query fetches only those columns,
19+
/// and full objects are loaded on-demand when accessed via [getAt].
20+
/// When [preloadedColumns] is empty or null, all fields are fetched regardless
21+
/// of [lazyLoading] value. Default is `true`.
22+
///
23+
/// [preloadedColumns] specifies which fields to fetch in the initial query when
24+
/// lazy loading is enabled. Order fields are automatically included to ensure
25+
/// proper sorting. If null or empty, all fields are fetched.
26+
///
27+
/// [listenOnAllSubItems] and [listeningIncludes] control which nested objects
28+
/// receive live query updates.
1429
static Future<ParseLiveList<T>> create<T extends ParseObject>(
1530
QueryBuilder<T> query, {
1631
bool? listenOnAllSubItems,
@@ -26,7 +41,7 @@ class ParseLiveList<T extends ParseObject> {
2641
)
2742
: _toIncludeMap(listeningIncludes ?? <String>[]),
2843
lazyLoading,
29-
preloadedColumns: preloadedColumns ?? const <String>[],
44+
preloadedColumns: preloadedColumns,
3045
);
3146

3247
return parseLiveList._init().then((_) {
@@ -45,6 +60,9 @@ class ParseLiveList<T extends ParseObject> {
4560
late StreamController<ParseLiveListEvent<T>> _eventStreamController;
4661
int _nextID = 0;
4762
late bool _debug;
63+
// Separate from _debug to allow one-time initialization logging
64+
// while still logging all errors/warnings when _debug is true
65+
late bool _debugLoggedInit;
4866

4967
int get nextID => _nextID++;
5068

@@ -134,12 +152,22 @@ class ParseLiveList<T extends ParseObject> {
134152

135153
Future<ParseResponse> _runQuery() async {
136154
final QueryBuilder<T> query = QueryBuilder<T>.copy(_query);
137-
if (_debug) {
138-
print('ParseLiveList: lazyLoading is ${_lazyLoading ? 'on' : 'off'}');
155+
156+
// Log lazy loading mode only once during initialization to avoid log spam
157+
if (_debugLoggedInit) {
158+
print(
159+
'ParseLiveList: Initialized with lazyLoading=${_lazyLoading ? 'on' : 'off'}, preloadedColumns=${_preloadedColumns.isEmpty ? 'none' : _preloadedColumns.join(", ")}',
160+
);
161+
_debugLoggedInit = false;
139162
}
140-
if (_lazyLoading) {
163+
164+
// Only restrict fields if lazy loading is enabled AND preloaded columns are specified
165+
// This allows fetching minimal data upfront and loading full objects on-demand
166+
if (_lazyLoading && _preloadedColumns.isNotEmpty) {
141167
final List<String> keys = _preloadedColumns.toList();
142-
if (_lazyLoading && query.limiters.containsKey('order')) {
168+
169+
// Automatically include order fields to ensure sorting works correctly
170+
if (query.limiters.containsKey('order')) {
143171
keys.addAll(
144172
query.limiters['order'].toString().split(',').map((String string) {
145173
if (string.startsWith('-')) {
@@ -149,10 +177,11 @@ class ParseLiveList<T extends ParseObject> {
149177
}),
150178
);
151179
}
152-
if (keys.isNotEmpty) {
153-
query.keysToReturn(keys);
154-
}
180+
181+
// Deduplicate keys to minimize request size
182+
query.keysToReturn(keys.toSet().toList());
155183
}
184+
156185
return await query.query<T>();
157186
}
158187

@@ -161,13 +190,20 @@ class ParseLiveList<T extends ParseObject> {
161190

162191
final ParseResponse parseResponse = await _runQuery();
163192
if (parseResponse.success) {
193+
// Determine if fields were actually restricted in the query
194+
// Only mark as not loaded if lazy loading AND we actually restricted fields
195+
final bool fieldsRestricted =
196+
_lazyLoading && _preloadedColumns.isNotEmpty;
197+
164198
_list =
165199
parseResponse.results
166200
?.map<ParseLiveListElement<T>>(
167201
(dynamic element) => ParseLiveListElement<T>(
168202
element,
169203
updatedSubItems: _listeningIncludes,
170-
loaded: !_lazyLoading,
204+
// Mark as loaded if we fetched all fields (no restriction)
205+
// Mark as not loaded only if fields were actually restricted
206+
loaded: !fieldsRestricted,
171207
),
172208
)
173209
.toList() ??
@@ -223,6 +259,11 @@ class ParseLiveList<T extends ParseObject> {
223259
final List<T> newList =
224260
parseResponse.results as List<T>? ?? <T>[];
225261

262+
// Determine if fields were actually restricted in the query,
263+
// same logic as in _init().
264+
final bool fieldsRestricted =
265+
_lazyLoading && _preloadedColumns.isNotEmpty;
266+
226267
//update List
227268
for (int i = 0; i < _list.length; i++) {
228269
final ParseObject currentObject = _list[i].object;
@@ -265,7 +306,11 @@ class ParseLiveList<T extends ParseObject> {
265306
}
266307

267308
for (int i = 0; i < newList.length; i++) {
268-
tasks.add(_objectAdded(newList[i], loaded: false));
309+
// Mark as loaded when all fields were fetched; only treat as
310+
// not loaded when fields are actually restricted.
311+
tasks.add(
312+
_objectAdded(newList[i], loaded: !fieldsRestricted),
313+
);
269314
}
270315
}
271316
await Future.wait(tasks);
@@ -486,34 +531,147 @@ class ParseLiveList<T extends ParseObject> {
486531
}
487532
}
488533

489-
Stream<T> getAt(final int index) async* {
490-
if (index < _list.length) {
491-
if (!_list[index].loaded) {
492-
final QueryBuilder<T> queryBuilder = QueryBuilder<T>.copy(_query)
493-
..whereEqualTo(
494-
keyVarObjectId,
495-
_list[index].object.get<String>(keyVarObjectId),
496-
)
497-
..setLimit(1);
498-
final ParseResponse response = await queryBuilder.query<T>();
499-
if (_list.isEmpty) {
500-
yield* _createStreamError<T>(
501-
ParseError(message: 'ParseLiveList: _list is empty'),
502-
);
534+
/// Returns a stream for the element at the given [index].
535+
///
536+
/// Returns the element's existing broadcast stream, which allows multiple
537+
/// listeners without creating redundant network requests or stream instances.
538+
///
539+
/// When lazy loading is enabled and an element is not yet loaded, the first
540+
/// access will trigger loading. This is useful for pagination scenarios.
541+
/// Subsequent calls return the same stream without additional loads.
542+
///
543+
/// The returned stream is a broadcast stream from ParseLiveListElement,
544+
/// preventing the N+1 query bug that occurred with async* generators.
545+
Stream<T> getAt(final int index) {
546+
if (index < 0 || index >= _list.length) {
547+
// Return an empty stream for out-of-bounds indices
548+
return const Stream.empty();
549+
}
550+
551+
final element = _list[index];
552+
553+
// If not yet loaded (happens with lazy loading), trigger loading
554+
// This will only happen once per element due to the loaded and isLoading flags
555+
if (!element.loaded) {
556+
_loadElementAt(index);
557+
}
558+
559+
// Return the element's broadcast stream
560+
// Multiple subscriptions to this stream won't trigger multiple loads
561+
return element.stream;
562+
}
563+
564+
/// Asynchronously loads the full data for the element at [index].
565+
///
566+
/// Called when an element is accessed for the first time.
567+
/// Errors are emitted to the element's stream so listeners can handle them.
568+
Future<void> _loadElementAt(int index) async {
569+
if (index < 0 || index >= _list.length) {
570+
return;
571+
}
572+
573+
final element = _list[index];
574+
575+
// Race condition protection: skip if element is already loaded or
576+
// currently being loaded by another concurrent call
577+
if (element.loaded || element.isLoading) {
578+
return;
579+
}
580+
581+
// Set loading flag to prevent concurrent load operations
582+
element.isLoading = true;
583+
584+
try {
585+
final QueryBuilder<T> queryBuilder = QueryBuilder<T>.copy(_query)
586+
..whereEqualTo(
587+
keyVarObjectId,
588+
element.object.get<String>(keyVarObjectId),
589+
)
590+
..setLimit(1);
591+
592+
final ParseResponse response = await queryBuilder.query<T>();
593+
594+
// Check if list was modified during async operation
595+
if (_list.isEmpty || index >= _list.length) {
596+
if (_debug) {
597+
print('ParseLiveList: List was modified during element load');
598+
}
599+
return;
600+
}
601+
602+
if (response.success &&
603+
response.results != null &&
604+
response.results!.isNotEmpty) {
605+
// Verify we're still updating the same object (list may have been modified)
606+
final currentElement = _list[index];
607+
if (currentElement.object.objectId != element.object.objectId) {
608+
if (_debug) {
609+
print('ParseLiveList: Element at index $index changed during load');
610+
}
503611
return;
504612
}
505-
if (response.success) {
506-
_list[index].object = response.results?.first;
507-
} else {
508-
ParseError? error = response.error;
509-
if (error != null) yield* _createStreamError<T>(error);
613+
// Setting the object will mark it as loaded and emit it to the stream
614+
_list[index].object = response.results!.first;
615+
} else if (response.error != null) {
616+
// Emit error to the element's stream so listeners can handle it.
617+
// Guard against list mutations so we don't emit on the wrong element.
618+
final currentElement = _list[index];
619+
if (currentElement.object.objectId != element.object.objectId) {
620+
if (_debug) {
621+
print(
622+
'ParseLiveList: Element at index $index changed during load (error)',
623+
);
624+
}
510625
return;
511626
}
627+
currentElement.emitError(response.error!, StackTrace.current);
628+
if (_debug) {
629+
print(
630+
'ParseLiveList: Error loading element at index $index: ${response.error}',
631+
);
632+
}
633+
} else {
634+
// Object not found (possibly deleted between initial query and load).
635+
// Note: Element remains loaded=false, so subsequent getAt() calls will
636+
// retry the query. This is acceptable because:
637+
// 1. LiveQuery will send a delete event to remove the element if needed
638+
// 2. Retries are rare (object would need to be deleted mid-load)
639+
// 3. No error is emitted to avoid alarming users for transient issues
640+
if (_debug) {
641+
print('ParseLiveList: Element at index $index not found during load');
642+
}
643+
}
644+
} catch (e, stackTrace) {
645+
// List may have changed while the query was in flight
646+
if (_list.isEmpty || index >= _list.length) {
647+
if (_debug) {
648+
print(
649+
'ParseLiveList: List was modified during element load (exception)',
650+
);
651+
}
652+
return;
653+
}
654+
655+
final currentElement = _list[index];
656+
if (currentElement.object.objectId != element.object.objectId) {
657+
if (_debug) {
658+
print(
659+
'ParseLiveList: Element at index $index changed during load (exception)',
660+
);
661+
}
662+
return;
663+
}
664+
665+
// Emit exception to the element's stream
666+
currentElement.emitError(e, stackTrace);
667+
if (_debug) {
668+
print(
669+
'ParseLiveList: Exception loading element at index $index: $e\n$stackTrace',
670+
);
512671
}
513-
// just for testing
514-
// await Future<void>.delayed(const Duration(seconds: 2));
515-
yield _list[index].object;
516-
yield* _list[index].stream;
672+
} finally {
673+
// Clear loading flag to allow future retry attempts
674+
element.isLoading = false;
517675
}
518676
}
519677

@@ -579,18 +737,16 @@ class ParseLiveElement<T extends ParseObject> extends ParseLiveListElement<T> {
579737
if (includeObject != null) {
580738
queryBuilder.includeObject(includeObject);
581739
}
582-
_init(object, loaded: loaded, includeObject: includeObject);
740+
// Fire-and-forget initialization; errors surface through element stream
741+
// ignore: unawaited_futures
742+
_init(object, loaded: loaded);
583743
}
584744

585745
Subscription<T>? _subscription;
586746
Map<String, dynamic>? _includes;
587747
late QueryBuilder<T> queryBuilder;
588748

589-
Future<void> _init(
590-
T object, {
591-
bool loaded = false,
592-
List<String>? includeObject,
593-
}) async {
749+
Future<void> _init(T object, {bool loaded = false}) async {
594750
if (!loaded) {
595751
final ParseResponse parseResponse = await queryBuilder.query();
596752
if (parseResponse.success) {
@@ -663,6 +819,10 @@ class ParseLiveListElement<T extends ParseObject> {
663819
final StreamController<T> _streamController = StreamController<T>.broadcast();
664820
T _object;
665821
bool _loaded = false;
822+
823+
/// Indicates whether this element is currently being loaded.
824+
/// Used to prevent concurrent load operations.
825+
bool isLoading = false;
666826
late Map<PathKey, dynamic> _updatedSubItems;
667827
LiveQuery? _liveQuery;
668828
final Future<void> _subscriptionQueue = Future<void>.value();
@@ -791,6 +951,14 @@ class ParseLiveListElement<T extends ParseObject> {
791951

792952
bool get loaded => _loaded;
793953

954+
/// Emits an error to the stream for listeners to handle.
955+
/// Used when lazy loading fails to fetch the full object data.
956+
void emitError(Object error, StackTrace stackTrace) {
957+
if (!_streamController.isClosed) {
958+
_streamController.addError(error, stackTrace);
959+
}
960+
}
961+
794962
void dispose() {
795963
_unsubscribe(_updatedSubItems);
796964
_streamController.close();

packages/dart/lib/src/utils/parse_utils.dart

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,6 @@ Future<ParseResponse> batchRequest(
123123
}
124124
}
125125

126-
Stream<T> _createStreamError<T>(Object error) async* {
127-
throw error;
128-
}
129-
130126
List removeDuplicateParseObjectByObjectId(Iterable iterable) {
131127
final list = iterable.toList();
132128

0 commit comments

Comments
 (0)