Skip to content

Conversation

@kirkmorrow
Copy link
Contributor

@kirkmorrow kirkmorrow commented Dec 4, 2025

Pull Request

Issue

Closes: #787

Approach

Problem

ParseLiveList.getAt() used an async* generator that created a new stream on every call. When multiple listeners subscribed to the same element, each subscription re-executed the function body, causing duplicate network requests. This resulted in an N+1 query problem where accessing a single element could trigger multiple identical database queries.

As reported in #787, subscribing to the same element 17 times resulted in 17 separate network requests instead of 1.

Root Cause

The async* generator pattern (Stream<T> getAt()) creates a fresh stream instance for each invocation. Every subscription to that stream re-executes the generator's body, including any database queries. This is fundamentally incompatible with the goal of sharing state across multiple listeners.

Solution

Replaced the async* generator architecture with a broadcast stream pattern:

  1. Broadcast Stream Architecture: Each ParseLiveListElement now maintains a single StreamController<T>.broadcast() that persists for the element's lifetime. The getAt() method returns a reference to this existing stream rather than generating a new one.

  2. On-Demand Loading: Created _loadElementAt() method that triggers data loading when an unloaded element is first accessed. This supports lazy loading while ensuring the load operation happens only once per element.

  3. Race Condition Protection: Added _isLoading flag to prevent concurrent load attempts when multiple subscribers access the same unloaded element simultaneously.

  4. Proper Lazy Loading Logic: Field restriction now only applies when lazyLoading=true AND preloadedColumns.isNotEmpty. Empty preloadedColumns fetches all fields regardless of lazyLoading flag.

  5. Error Handling: Added emitError() method to propagate query errors and exceptions through the element's stream to all listeners.

  6. Debug Logging: Implemented one-time initialization logging using _debugLoggedInit flag to prevent log spam on repeated queries, while preserving error logging functionality.

Key Changes

lib/src/utils/parse_live_list.dart:

  • getAt(): Changed from async* generator to method returning element.stream (broadcast stream)
  • _loadElementAt(): New method for on-demand data loading with race condition protection
  • _runQuery(): Added one-time debug logging; field restriction logic updated
  • _init(): Calculates fieldsRestricted based on actual query behavior
  • ParseLiveListElement: Added StreamController<T>.broadcast(), _isLoading flag, and emitError() method
  • Added _debugLoggedInit variable initialized alongside _debug for one-time logging

test/src/utils/parse_live_list_test.dart:

  • Added comprehensive documentation explaining broadcast stream architecture
  • Added 8 tests verifying lazy loading implementation details
  • Added 3 tests demonstrating async* vs broadcast stream behavior patterns
  • Documented why unit tests cannot detect this specific regression (requires network request monitoring)
  • Although a complete integration test is not included, I did create a simple app based on the sample code provided in the bug report. I was able to reproduce the multiple requests with the latest code and prove that this fix reduced it to a single request.

Backward Compatibility

No breaking changes. All existing functionality is preserved while eliminating redundant queries. The getAt() method signature remains unchanged; only the internal implementation was modified.

Tasks

  • Add tests
  • Add changes to documentation (guides, repository pages, code comments)

Summary by CodeRabbit

  • New Features

    • Per-item on-demand lazy loading with visible per-item loading state, reusable broadcast streams, and element-level error propagation.
  • Bug Fixes

    • Prevents duplicate loads and race conditions, respects preloaded-field behavior for correct loaded state, and suppresses repeated initialization logs.
  • Tests

    • Added unit tests covering lazy-loading modes, field-restricted initialization, and stream/listener behaviors.
  • Chores

    • Removed an internal stream-error helper (no observable behavior change).

✏️ Tip: You can customize this high-level summary in your review settings.

Copilot AI review requested due to automatic review settings December 4, 2025 19:48
@parse-github-assistant
Copy link

parse-github-assistant bot commented Dec 4, 2025

🚀 Thanks for opening this pull request!

@coderabbitai
Copy link

coderabbitai bot commented Dec 4, 2025

Warning

Rate limit exceeded

@kirkmorrow has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 19 minutes and 40 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between 8f89f91 and ee5e2dc.

📒 Files selected for processing (1)
  • packages/dart/lib/src/utils/parse_live_list.dart (12 hunks)

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

📝 Walkthrough

Walkthrough

Replaces per-call async* element streams with persistent per-element broadcast streams via getAt(index); adds on-demand single-element loader _loadElementAt(index) with isLoading/loaded guards and per-element error propagation; refines lazy-loading field-restriction logic and adds one-time _debugLoggedInit init logging.

Changes

Cohort / File(s) Summary
ParseLiveList core
packages/dart/lib/src/utils/parse_live_list.dart
Added Stream<T> getAt(int index) returning a persistent per-element broadcast stream. Implemented private _loadElementAt(int) to fetch single objects with isLoading/loaded guards, race checks, object-id verification, error propagation via emitError, and finally cleanup. Introduced _debugLoggedInit for one-time init logging. Adjusted initialization to compute fieldsRestricted and set element loaded based on preloadedColumns, wiring lazy-loading state through list updates.
ParseLiveList element API
packages/dart/lib/src/utils/parse_live_list.dart (same file — element class)
Added public bool isLoading on ParseLiveListElement, initialized in constructor, and void emitError(Object, StackTrace) to forward errors into an element's stream.
Unit tests (new)
packages/dart/test/src/utils/parse_live_list_test.dart
New tests covering lazyLoading permutations (true/false with/without preloadedColumns), verification of keysToReturn behavior and ordering-field inclusion, and comparisons of async* generator vs broadcast stream behaviors to validate stream reuse and elimination of redundant per-object requests.
Parse utils cleanup
packages/dart/lib/src/utils/parse_utils.dart
Removed private async* helper _createStreamError; no behavioral change otherwise.

Sequence Diagram(s)

mermaid
sequenceDiagram
autonumber
actor Client
participant ParseLiveList
participant Element as ParseLiveListElement
participant Server as Parse Server
Client->>ParseLiveList: getAt(index)
ParseLiveList->>Element: return Element.broadcastStream
alt Element.loaded == false and not Element.isLoading
ParseLiveList->>Element: call _loadElementAt(index)
Element->>Server: single-object GET (keysToReturn: preloaded + order fields)
Server-->>Element: response (success / notFound / error)
alt success
Element->>Element: set loaded = true; update value
Element->>Element.broadcastStream: emit value
else notFound
Element->>Element.broadcastStream: emit null / complete
else error
Element->>Element.broadcastStream: emitError(error, stack)
end
end
Note right of Client: multiple subscribers reuse same Element.broadcastStream

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Inspect concurrency and state transitions in _loadElementAt (race conditions, isLoading toggling, finally cleanup).
  • Verify correct initialization of loaded and fieldsRestricted across lazyLoading / preloadedColumns permutations.
  • Confirm broadcast StreamController lifecycle, reuse across getAt calls, completion semantics, and emitError propagation.
  • Review keysToReturn construction to ensure ordering fields are included and no unintended field-restriction when lazyLoading is false.
  • Validate new tests are deterministic and effectively assert reduction of redundant per-object requests.

Pre-merge checks and finishing touches

✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary fix: eliminating unnecessary server requests caused by ParseLiveList.getAt(), which directly addresses the core issue reported in #787.
Description check ✅ Passed The description is comprehensive and well-structured, covering problem statement, root cause, solution architecture, key changes, backward compatibility, and testing. It follows the template structure and fills all required sections.
Linked Issues check ✅ Passed The implementation addresses all key objectives from #787: eliminates N+1 queries through broadcast streams, prevents re-fetching of already-loaded data, implements proper lazy loading with field restriction logic, includes race-condition protection, and validates the fix with testing and reproduction.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the lazy loading issue: broadcast stream implementation, on-demand loading, race condition protection, error propagation, and debug logging. Minor removal of unused _createStreamError helper in parse_utils.dart is a natural cleanup from the architecture change.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link

codecov bot commented Dec 4, 2025

Codecov Report

❌ Patch coverage is 0% with 69 lines in your changes missing coverage. Please review.
✅ Project coverage is 44.01%. Comparing base (2de7303) to head (ee5e2dc).

Files with missing lines Patch % Lines
packages/dart/lib/src/utils/parse_live_list.dart 0.00% 69 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1099      +/-   ##
==========================================
+ Coverage   43.96%   44.01%   +0.05%     
==========================================
  Files          61       61              
  Lines        3596     3637      +41     
==========================================
+ Hits         1581     1601      +20     
- Misses       2015     2036      +21     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot finished reviewing on behalf of kirkmorrow December 4, 2025 19:51
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (5)
packages/dart/lib/src/utils/parse_live_list.dart (2)

549-577: Verify list index safety after async boundary.

The implementation correctly re-checks bounds after the async query (lines 571-577), but the element reference captured at line 554 may become stale if the list is modified during the query. The current code correctly uses _list[index] at line 583 for the update, but consider also verifying the objectId still matches to ensure we're updating the correct element:

       if (response.success &&
           response.results != null &&
           response.results!.isNotEmpty) {
+        // Verify we're still updating the same object
+        final currentElement = _list[index];
+        if (currentElement.object.objectId != element.object.objectId) {
+          if (_debug) {
+            print('ParseLiveList: Element at index $index changed during load');
+          }
+          return;
+        }
         // Setting the object will mark it as loaded and emit it to the stream
         _list[index].object = response.results!.first;

579-592: Consider handling the case when response has no results but no error.

If response.success is true but response.results is empty (object was deleted between initial query and load), the element remains in an unloaded state with no notification to listeners:

       if (response.success &&
           response.results != null &&
           response.results!.isNotEmpty) {
         // Setting the object will mark it as loaded and emit it to the stream
         _list[index].object = response.results!.first;
       } else if (response.error != null) {
         // Emit error to the element's stream so listeners can handle it
         element.emitError(response.error!, StackTrace.current);
         if (_debug) {
           print(
             'ParseLiveList: Error loading element at index $index: ${response.error}',
           );
         }
+      } else {
+        // Object not found (possibly deleted) - emit an error or handle gracefully
+        if (_debug) {
+          print(
+            'ParseLiveList: Element at index $index not found during load',
+          );
+        }
       }
packages/dart/test/src/utils/parse_live_list_test.dart (3)

145-158: Test validates query behavior but doesn't call the actual _runQuery() method.

This test creates a fresh QueryBuilder and checks that it doesn't have 'keys' by default, but it doesn't actually test _runQuery() behavior. Consider adding a comment clarifying this tests the expected baseline, not the actual implementation:

     test('lazyLoading=false should NOT restrict fields automatically', () {
-      // When lazy loading is disabled, fetch all fields
+      // Verifies baseline: a fresh QueryBuilder has no 'keys' restriction.
+      // The actual _runQuery() behavior with lazyLoading=false is tested
+      // indirectly through the loaded flag tests above.
       final query = QueryBuilder<ParseObject>(ParseObject('Room'))

160-175: Test manually applies keysToReturn() rather than testing _runQuery() directly.

The test simulates what _runQuery() does by manually calling keysToReturn(). While this verifies the QueryBuilder API works, it doesn't test that ParseLiveList._runQuery() actually applies this restriction. Consider adding a comment to clarify:

     test('lazyLoading=true with preloadedColumns should restrict fields', () {
-      // When lazy loading with preloaded columns, the initial query should
-      // only fetch those columns
+      // Verifies that keysToReturn() sets the 'keys' limiter as expected.
+      // Note: This simulates _runQuery() behavior; actual integration testing
+      // would require mocking the network layer.
       final query = QueryBuilder<ParseObject>(ParseObject('Room'))

52-56: Consider adding teardown to dispose of Parse resources.

If initializeParse() creates any persistent resources (singletons, connections), consider adding a tearDownAll to clean up:

   setUpAll(() async {
     await initializeParse();
   });
+
+  // Optional: Add teardown if initializeParse() creates persistent resources
+  // tearDownAll(() async {
+  //   // Cleanup if needed
+  // });
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2de7303 and 594a671.

📒 Files selected for processing (2)
  • packages/dart/lib/src/utils/parse_live_list.dart (7 hunks)
  • packages/dart/test/src/utils/parse_live_list_test.dart (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Agent
  • GitHub Check: Test Flutter 3.38, Ubuntu, WASM
  • GitHub Check: Test Flutter beta
  • GitHub Check: Test Flutter 3.38, Windows
  • GitHub Check: Test Flutter 3.38, Ubuntu
🔇 Additional comments (9)
packages/dart/lib/src/utils/parse_live_list.dart (5)

14-27: Documentation is clear and accurate.

The docstring clearly explains the lazy loading behavior and the relationship between lazyLoading and preloadedColumns parameters, which aligns with the implementation.


154-173: Field restriction logic correctly fixes the N+1 query root cause.

The updated condition _lazyLoading && _preloadedColumns.isNotEmpty properly ensures fields are only restricted when both lazy loading is enabled AND preloaded columns are specified. This prevents unnecessary per-object queries when preloadedColumns is empty.


183-200: Consistent fieldsRestricted calculation aligns with _runQuery() logic.

The loaded flag is correctly set based on whether fields were actually restricted, ensuring elements are marked as loaded when all fields were fetched upfront.


526-543: The broadcast stream approach correctly eliminates the N+1 query bug.

Returning element.stream (from StreamController<T>.broadcast()) instead of using an async* generator ensures multiple subscriptions share the same stream instance and don't trigger duplicate loads. This is the core fix for issue #787.


878-884: Error propagation implementation is correct.

The emitError() method properly checks if the stream is closed before adding errors, preventing exceptions from being thrown on disposed elements.

packages/dart/test/src/utils/parse_live_list_test.dart (4)

8-51: Excellent architectural documentation in test file.

The detailed documentation explaining the stream implementation, broadcast stream benefits, and testing limitations provides valuable context for future maintainers. This is especially important given that the core architecture cannot be fully verified through unit tests alone.


58-82: Test correctly validates element loaded state for lazyLoading=false.

The test verifies that elements are marked as loaded when lazy loading is disabled, which prevents unnecessary _loadElementAt() calls.


178-262: Comprehensive demonstration of async vs broadcast stream behavior.*

These tests effectively illustrate the core problem (async* creating new streams) and the solution (broadcast streams allowing multiple listeners). This serves as both documentation and regression protection.


264-335: Test clearly demonstrates the N+1 query root cause and fix.

The generatorCallCount increment pattern clearly shows how async* generators execute their body on each subscription, while broadcast streams share initialization. This test serves as excellent documentation for the fix.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes an N+1 query bug in ParseLiveList.getAt() where multiple subscriptions to the same element triggered duplicate network requests. The solution replaces the async* generator pattern with a broadcast stream architecture, ensuring that each element maintains a single persistent stream that all subscribers share.

Key Changes:

  • Refactored getAt() from async* generator to method returning broadcast stream
  • Added _loadElementAt() method for on-demand lazy loading with proper error handling
  • Updated lazy loading logic to only restrict fields when both lazyLoading=true AND preloadedColumns is non-empty

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 8 comments.

File Description
packages/dart/lib/src/utils/parse_live_list.dart Refactored getAt() to return broadcast stream; added _loadElementAt() for on-demand loading; updated lazy loading field restriction logic; added emitError() method to ParseLiveListElement
packages/dart/test/src/utils/parse_live_list_test.dart Added comprehensive documentation on broadcast stream architecture; added 8 tests for lazy loading scenarios; added 3 tests demonstrating async* vs broadcast stream behavior patterns

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

coderabbitai[bot]
coderabbitai bot previously approved these changes Dec 4, 2025
Documentation and formatting fixes
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
packages/dart/lib/src/utils/parse_live_list.dart (2)

523-538: Optional: treat negative indices as out-of-bounds in getAt

Right now getAt treats index >= _list.length as out-of-bounds (returning Stream.empty()), but a negative index will still hit _list[index] and throw:

if (index >= _list.length) {
  return const Stream.empty();
}
final element = _list[index];

If you want getAt to be more defensive and symmetrical, you could also treat negative indices as out-of-bounds:

-    if (index >= _list.length) {
+    if (index < 0 || index >= _list.length) {
       // Return an empty stream for out-of-bounds indices
       return const Stream.empty();
     }

Not strictly required, but it makes the API more forgiving.


768-769: Element-level loading state and error propagation are well-structured; minor encapsulation idea

  • Initializing _isLoading in ParseLiveListElement and guarding _loadElementAt() with loaded || _isLoading correctly prevents concurrent loads while still allowing retries on failure.
  • emitError() cleanly centralizes error delivery via the per-element broadcast stream and is used consistently from _loadElementAt().

If you’d like to tighten encapsulation a bit, consider exposing bool get isLoading / set isLoading(...) on ParseLiveListElement and using that from ParseLiveList instead of touching element._isLoading directly. This keeps the element’s internal state changeable without affecting callers, but is purely optional given the single-library scope.

Also applies to: 781-782, 910-916

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 007797a and 6866834.

📒 Files selected for processing (2)
  • packages/dart/lib/src/utils/parse_live_list.dart (10 hunks)
  • packages/dart/test/src/utils/parse_live_list_test.dart (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/dart/test/src/utils/parse_live_list_test.dart
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Test Flutter 3.38, Ubuntu, WASM
  • GitHub Check: Test Flutter beta
  • GitHub Check: Test Flutter 3.38, Windows
  • GitHub Check: Test Flutter 3.38, macOS
  • GitHub Check: Test Flutter 3.38, Ubuntu
🔇 Additional comments (2)
packages/dart/lib/src/utils/parse_live_list.dart (2)

15-28: Lazy-loading and preloadedColumns documentation matches implementation

The new constructor docs for lazyLoading / preloadedColumns align with the logic in _runQuery() and _init() (restricting fields only when lazyLoading == true and preloadedColumns.isNotEmpty). No behavioral issues here.

Also applies to: 34-45


12-13: One-time init logging and field restriction behavior look solid

  • _debugLoggedInit correctly ensures the initialization message is printed only on the first _runQuery() call, avoiding log spam while leaving _debug-guarded error logging untouched.
  • Field restriction is now applied only when lazyLoading is enabled and preloadedColumns is non-empty, and fieldsRestricted is used to mark elements as loaded vs. not loaded, which cleanly reflects whether full objects were fetched.

No changes needed here.

Also applies to: 63-65, 152-182, 191-205

Tighten encapsulation on ParseLiveListElement
Improve comments
@mtrezza mtrezza changed the title fix: Eliminate N+1 query bug in ParseLiveList.getAt() with broadcast streams fix: ParseLiveList.getAt() causes unnecessary requests to the server Dec 4, 2025
@mtrezza mtrezza changed the title fix: ParseLiveList.getAt() causes unnecessary requests to the server fix: ParseLiveList.getAt() causes unnecessary requests to server Dec 4, 2025
coderabbitai[bot]
coderabbitai bot previously approved these changes Dec 4, 2025
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fc46d7a and 2f034af.

📒 Files selected for processing (1)
  • packages/dart/lib/src/utils/parse_live_list.dart (10 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Test Flutter 3.38, Windows
  • GitHub Check: Test Flutter beta
  • GitHub Check: Test Flutter 3.38, Ubuntu, WASM
  • GitHub Check: Test Flutter 3.38, Ubuntu
  • GitHub Check: Test Flutter 3.38, macOS
🔇 Additional comments (3)
packages/dart/lib/src/utils/parse_live_list.dart (3)

10-45: Constructor, docs, and debug init align with lazy-loading behavior

The constructor wiring of _lazyLoading and _preloadedColumns, the added documentation on lazyLoading / preloadedColumns, and the introduction of _debugLoggedInit for one-time initialization logging all look consistent and help clarify behavior without changing the external API.

Also applies to: 57-57, 63-65


153-183: Field restriction and loaded flag correctly mirror lazyLoading + preloadedColumns contract

_runQuery() only applies keysToReturn when lazyLoading is enabled and preloadedColumns is non-empty, and _init() uses the same condition via fieldsRestricted to decide the initial loaded state for each element. This keeps the runtime behavior aligned with the documented semantics (lazy loading only when fields are actually restricted).

Also applies to: 192-206


783-803: Element-level _isLoading and emitError integration looks correct

Initializing _isLoading in ParseLiveListElement, exposing isLoading via getter/setter, and using it from _loadElementAt provides the intended race protection, while emitError() cleanly surfaces load failures through the element’s broadcast stream with a closed-stream guard. This matches the new lazy-loading design and avoids duplicate requests.

Also applies to: 931-941

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/dart/lib/src/utils/parse_live_list.dart (1)

251-307: New objects after LiveQuery reconnect are always marked loaded = false, causing unnecessary per-element loads

On reconnect, new objects discovered in newList are added via:

for (int i = 0; i < newList.length; i++) {
  tasks.add(_objectAdded(newList[i], loaded: false));
}

But _runQuery() on reconnect already applies the same field-restriction logic as during _init (only restricting when lazyLoading && _preloadedColumns.isNotEmpty). When fields are not restricted (e.g., lazyLoading == false or preloadedColumns is empty), these new elements are fully fetched, yet they are still marked loaded = false. That means subsequent getAt(index) calls will invoke _loadElementAt(index) and perform an extra limit=1 fetch per new element, partially reintroducing the N+1 pattern after reconnect.

You already correctly compute fieldsRestricted in _init; mirroring that here would avoid unnecessary requests and keep semantics consistent across initial load and reconnect.

Suggested refactor:

@@
           if (event == LiveQueryClientEvent.connected) {
             _updateQueue.whenComplete(() async {
               List<Future<void>> tasks = <Future<void>>[];
               final ParseResponse parseResponse = await _runQuery();
               if (parseResponse.success) {
                 final List<T> newList =
                     parseResponse.results as List<T>? ?? <T>[];
+
+                // Determine if fields were actually restricted in the query,
+                // same logic as in _init().
+                final bool fieldsRestricted =
+                    _lazyLoading && _preloadedColumns.isNotEmpty;
@@
                 for (int i = 0; i < newList.length; i++) {
-                  tasks.add(_objectAdded(newList[i], loaded: false));
+                  // Mark as loaded when all fields were fetched; only treat as
+                  // not loaded when fields are actually restricted.
+                  tasks.add(
+                    _objectAdded(
+                      newList[i],
+                      loaded: !fieldsRestricted,
+                    ),
+                  );
                 }
               }

Optionally, you could extract fieldsRestricted into a private getter (e.g. bool get _fieldsRestricted => _lazyLoading && _preloadedColumns.isNotEmpty;) and reuse it in both _runQuery / _init and this reconnect path to keep the logic centralized.

🧹 Nitpick comments (1)
packages/dart/lib/src/utils/parse_live_list.dart (1)

554-662: Single-flight _loadElementAt implementation is robust; consider minor defensive improvements

The loader now:

  • Guards against out-of-range indices.
  • Uses isLoading to prevent concurrent loads for the same element.
  • Verifies list bounds and element identity (via objectId) after the async query before applying results or emitting errors.
  • Emits both response.error and thrown exceptions to the element’s stream, with debug logging, and always clears isLoading in a finally block.

This is a solid, race-safe single-flight implementation.

Two minor, optional hardening ideas:

  • Also guard against negative indices for future callers:
    if (index < 0 || index >= _list.length) {
      return;
    }
  • If you want to avoid repeated “not found during load” requests when an object was deleted but no LiveQuery delete event arrives (edge case), you could mark the element as logically removed or set loaded = true and rely solely on LiveQuery to clear it later.

These are not blockers; the current behavior is acceptable.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2f034af and ce4ed3e.

📒 Files selected for processing (2)
  • packages/dart/lib/src/utils/parse_live_list.dart (10 hunks)
  • packages/dart/lib/src/utils/parse_utils.dart (0 hunks)
💤 Files with no reviewable changes (1)
  • packages/dart/lib/src/utils/parse_utils.dart
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: Test Flutter beta
  • GitHub Check: Test Flutter 3.38, Ubuntu, WASM
  • GitHub Check: Test Flutter 3.38, Windows
  • GitHub Check: Test Flutter 3.38, macOS
  • GitHub Check: Test Dart 3.10, Windows
  • GitHub Check: Test Flutter 3.38, Ubuntu
🔇 Additional comments (6)
packages/dart/lib/src/utils/parse_live_list.dart (6)

12-13: One-time debug init logging looks correct

Initializing _debugLoggedInit from isDebugEnabled() and flipping it to false after the first _runQuery() call achieves the “log once on init, but still honor _debug for other logs” behavior described in the PR. No issues here.

Also applies to: 63-65


15-28: Constructor documentation and preloadedColumns plumbing match runtime semantics

The updated create docs clearly explain the interaction between lazyLoading and preloadedColumns, and the preloadedColumns parameter is correctly threaded into the private constructor. The behavior in _runQuery / _init (only restricting fields when lazyLoading && preloadedColumns.isNotEmpty) aligns with this contract.

Also applies to: 29-45


153-183: Field restriction & initial loaded state logic is consistent with lazy-loading design

Restricting keys only when lazyLoading && _preloadedColumns.isNotEmpty and then computing fieldsRestricted in _init to decide the initial loaded flag is a clean way to distinguish:

  • “true lazy loading with preloaded columns” (elements start as not loaded), from
  • “full fetch” modes (either lazyLoading == false or no preloadedColumns, elements start loaded).

Including order fields into keys ensures correct sorting even when restricting fields. This block looks sound.

Also applies to: 192-206


524-552: getAt broadcast stream design correctly avoids duplicate loads and per-subscriber queries

Returning the element’s broadcast Stream<T> and lazily kicking off _loadElementAt(index) only when !element.loaded solves the original async* problem:

  • Multiple listeners share the same element stream, so they no longer trigger separate generator executions or redundant network calls.
  • Out-of-bounds indices safely return an empty stream rather than throwing.

The implementation matches the intended behavior from the PR description.


792-799: ParseLiveListElement loading flags and broadcast stream setup align with new lazy-loading flow

Initializing _loaded from the constructor parameter and isLoading = false, plus exposing the broadcast _streamController, integrates cleanly with getAt / _loadElementAt:

  • loaded is flipped to true via the object setter when a full object is assigned.
  • isLoading is managed solely by _loadElementAt, preventing concurrent loads.
  • The broadcast controller allows multiple Stream subscribers per element.

No correctness issues spotted here.

Also applies to: 808-811


940-946: emitError provides a clean error path for element streams

The emitError helper cleanly encapsulates error propagation to listeners while respecting _streamController.isClosed. This is consistent with its use in _loadElementAt for both ParseResponse.error and caught exceptions.

coderabbitai[bot]
coderabbitai bot previously approved these changes Dec 4, 2025
@kirkmorrow
Copy link
Contributor Author

The coverage gaps are primarily in:

  • Debug logging (typically excluded from coverage)
  • Race condition guards (require integration tests with LiveQuery mocking)
  • Error paths (require mocked Parse server failures)

The core functionality is verified by existing unit tests. The new code paths work correctly in production (all tests pass). Adding coverage for these paths would require substantial integration testing infrastructure that's beyond the scope of this bug fix.

coderabbitai[bot]
coderabbitai bot previously approved these changes Dec 4, 2025
@kirkmorrow
Copy link
Contributor Author

@copilot please re-review. Button to request re-review isn't available and says a review is pending. However, I see no re-review request since the last @copilot review 2 hours ago.

Copilot finished reviewing on behalf of kirkmorrow December 5, 2025 13:48
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@mtrezza
Copy link
Member

mtrezza commented Dec 5, 2025

@kirkmorrow is this ready for merge?

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
coderabbitai[bot]
coderabbitai bot previously approved these changes Dec 5, 2025
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
kirkmorrow and others added 3 commits December 5, 2025 08:48
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
coderabbitai[bot]
coderabbitai bot previously approved these changes Dec 5, 2025
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
packages/dart/lib/src/utils/parse_live_list.dart (2)

534-562: LGTM: Broadcast stream architecture correctly prevents N+1 queries.

The method returns the element's persistent broadcast stream immediately, allowing multiple subscribers without triggering duplicate loads. The bounds checking prevents crashes on invalid indices.

However, the comment at line 554 mentions "loaded and isLoading flags" but the actual field is named isLoading without an underscore prefix (declared as public at line 825). This matches a past review comment about naming consistency.

Consider updating the comment for consistency:

-    // This will only happen once per element due to the loaded and isLoading flags
+    // This will only happen once per element due to the loaded and isLoading flags (element.loaded, element.isLoading)

804-825: LGTM: Loading state flag properly documented and initialized.

The isLoading field is now documented as requested in past reviews, and the initialization correctly defaults to false.

One minor style note: the field is initialized both in the initializer list (line 810) and at the field declaration (line 825). While this works in Dart (field declaration initializer is used as default for the initializer list), the double initialization is redundant. Per Dart style, non-final fields are typically initialized at declaration or in the initializer list, not both.

Consider removing the redundant initialization:

  ParseLiveListElement(
    this._object, {
    bool loaded = false,
    Map<String, dynamic>? updatedSubItems,
  }) : _loaded = loaded,
-       isLoading = false {
+  {

The field declaration at line 825 (bool isLoading = false;) already provides the default value.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 794821d and a914276.

📒 Files selected for processing (2)
  • packages/dart/lib/src/utils/parse_live_list.dart (13 hunks)
  • packages/dart/test/src/utils/parse_live_list_test.dart (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/dart/test/src/utils/parse_live_list_test.dart
🔇 Additional comments (13)
packages/dart/lib/src/utils/parse_live_list.dart (13)

5-13: LGTM: Constructor correctly initializes debug logging flags.

The _debugLoggedInit flag is properly initialized and will control one-time initialization logging as described in the PR objectives.


15-28: LGTM: Documentation clearly explains lazy loading behavior.

The updated documentation accurately describes when field restriction occurs (only when lazyLoading == true AND preloadedColumns is non-empty), which matches the implementation and resolves the original issue.


63-65: LGTM: Clean separation of one-time init logging from debug logging.

The comment clearly explains the purpose and the implementation correctly prevents log spam while preserving error logs.


156-162: LGTM: One-time debug logging correctly implemented.

The logging occurs only once during initialization and provides useful diagnostic information about lazy loading configuration.


164-183: LGTM: Field restriction logic correctly implements the fix.

The dual condition check (_lazyLoading && _preloadedColumns.isNotEmpty) ensures fields are only restricted when explicitly configured, preventing the N+1 query bug. The automatic inclusion of order fields and deduplication are good defensive practices.


193-210: LGTM: Initialization correctly sets loaded state based on field restriction.

Elements are correctly marked as loaded when all fields were fetched, eliminating unnecessary lazy loads. This is a key part of the N+1 fix.


262-314: LGTM: Reconnection logic maintains consistency with initialization.

The same field restriction logic is correctly applied when reconnecting, ensuring elements have the appropriate loaded state regardless of how they enter the list.


568-582: LGTM: Race protection correctly prevents concurrent loads.

The dual guard (element.loaded || element.isLoading) with the finally block ensures only one load attempt occurs per element and that the loading flag is always cleared, even on errors.


584-614: LGTM: Success path correctly handles list mutations during async operations.

The bounds and identity checks ensure loaded data is never assigned to the wrong element, even if the list is modified while the query is in flight.


615-632: LGTM: Error path correctly guards against misrouted errors.

The identity check mirrors the success path, ensuring errors are emitted only to the element that initiated the load.


633-643: LGTM: Not-found case appropriately handled.

The decision to allow retries without emitting errors is reasonable for transient issues, and the comment clearly explains the design rationale and reliance on LiveQuery for actual deletions.


644-676: LGTM: Exception handler comprehensively guards against crashes.

The exception path mirrors the success and error paths with full bounds and identity checking, ensuring robust error handling even during list mutations.


954-960: LGTM: Error propagation method cleanly implemented.

The emitError method provides a safe way to propagate load failures to all stream listeners, completing the broadcast-stream architecture. The closed-stream check prevents crashes.

Copilot finished reviewing on behalf of kirkmorrow December 5, 2025 14:56
coderabbitai[bot]
coderabbitai bot previously approved these changes Dec 5, 2025
@kirkmorrow
Copy link
Contributor Author

@kirkmorrow is this ready for merge?

@mtrezza I am now. I applied the Copilot nitpick fixes.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@mtrezza
Copy link
Member

mtrezza commented Dec 5, 2025

@kirkmorrow this can be merged?

@kirkmorrow
Copy link
Contributor Author

kirkmorrow commented Dec 5, 2025

@kirkmorrow this can be merged?

Yes, I'm happy now that @copilot is happy.

@mtrezza mtrezza merged commit 9114d4a into parse-community:master Dec 5, 2025
13 of 14 checks passed
parseplatformorg pushed a commit that referenced this pull request Dec 5, 2025
# [dart-v9.4.7](dart-9.4.6...dart-9.4.7) (2025-12-05)

### Bug Fixes

* `ParseLiveList.getAt()` causes unnecessary requests to server ([#1099](#1099)) ([9114d4a](9114d4a))
@parseplatformorg
Copy link
Contributor

🎉 This change has been released in version dart-v9.4.7

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ParseLiveList has bug in lazyLoading

3 participants