@@ -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 ();
0 commit comments