Skip to content

ListPublisher publishList with fetchOffset returns incomplete results #518

Description

@KodaKoder

Description

When using publishList() with a query that includes fetchOffset via .tweak { fr in fr.fetchOffset = N }, the resulting snapshot contains fewer items than expected. The fetchOffset parameter is being applied twice: once by Core Data's NSFetchedResultsController and again during snapshot construction, causing items to be incorrectly skipped.

Environment

  • CoreStore Version: 9.3.0
  • Platform: iOS/macOS
  • Swift Version: 5.x
  • Xcode Version: 15.x

Steps to Reproduce

let dataStack: DataStack = // ... initialized DataStack

// Create query with fetchOffset
let query = From<MyEntity>()
    .where(\.someField == someValue)
    .orderBy(.descending(\.date))
    .tweak { fetchRequest in
        fetchRequest.fetchOffset = 5
        fetchRequest.fetchLimit = 10
    }

// Create publisher
let publisher = dataStack.publishList(query)
let snapshot = publisher.snapshot

// Check results
print("Snapshot count: \(snapshot.count)")  // Expected: 10, Actual: 5

Expected Behavior

When fetchOffset = 5 and fetchLimit = 10, the publisher should return items 5-14 (10 items total).

Actual Behavior

The publisher returns only items 10-14 (5 items total). The first 5 items from the Core Data fetch are skipped during snapshot creation.

Debug Evidence

Using LLDB breakpoint in controllerDidChangeContent:

controllerDidChangeContent snapshot.itemIdentifiers: 5       ❌ WRONG
controllerDidChangeContent fetchedObjects.count: 10          ✓ CORRECT
controllerDidChangeContent fetchOffset: 5 fetchLimit: 10

This proves:

  1. NSFetchedResultsController correctly fetched 10 objects (items 5-14)
  2. The snapshot incorrectly contains only 5 items (items 10-14)

Comparison with fetchAll

The bug does not occur with fetchAll():

let results = try dataStack.fetchAll(
    From<MyEntity>(),
    Where<MyEntity>(predicate),
    OrderBy<MyEntity>(sortDescriptors),
    Tweak { fetchRequest in
        fetchRequest.fetchOffset = 5
        fetchRequest.fetchLimit = 10
    }
)
print("Results count: \(results.count)")  // Correctly returns 10 items

Root Cause

In Internals.FetchedDiffableDataSourceSnapshotDelegate.swift, the controllerDidChangeContent method passes fetchOffset to the snapshot initializer:

var snapshot = Internals.DiffableDataSourceSnapshot(
    sections: controller.sections ?? [],
    sectionIndexTransformer: self.handler?.sectionIndexTransformer ?? { _ in nil },
    fetchOffset: controller.fetchRequest.fetchOffset,  // ❌ DOUBLE APPLICATION
    fetchLimit: controller.fetchRequest.fetchLimit
)

The problem: controller.sections already contains pre-filtered objects (items 5-14) because NSFetchedResultsController applied fetchOffset. The snapshot initializer then applies fetchOffset again to these already-offset results, causing items 5-9 to be skipped.

Flow of the bug:

  1. fetchRequest.fetchOffset = 5, fetchLimit = 10
  2. NSFetchedResultsController fetches items 5-14 from Core Data (10 items) ✓
  3. controller.sections contains objects: [item5, item6, ..., item14]
  4. Snapshot init receives fetchOffset: 5 and skips another 5 items
  5. Result: snapshot contains [item10, ..., item14] (only 5 items) ❌

Proposed Fix

Change to always pass fetchOffset: 0:

// FIXED CODE
var snapshot = Internals.DiffableDataSourceSnapshot(
    sections: controller.sections ?? [],
    sectionIndexTransformer: self.handler?.sectionIndexTransformer ?? { _ in nil },
    fetchOffset: 0,  // ✓ Already applied by NSFetchedResultsController
    fetchLimit: controller.fetchRequest.fetchLimit
)

The sections passed from NSFetchedResultsController are already offset, so the snapshot initializer should not apply any additional offset.

Test Case

To verify the fix works:

// Query 1: offset=0, limit=10
let query1 = From<MyEntity>()
    .orderBy(.descending(\.date))
    .tweak { fr in
        fr.fetchOffset = 0
        fr.fetchLimit = 10
    }
let pub1 = dataStack.publishList(query1)
let items1 = pub1.snapshot.itemIdentifiers  // [item0...item9]

// Query 2: offset=5, limit=10
let query2 = From<MyEntity>()
    .orderBy(.descending(\.date))
    .tweak { fr in
        fr.fetchOffset = 5
        fr.fetchLimit = 10
    }
let pub2 = dataStack.publishList(query2)
let items2 = pub2.snapshot.itemIdentifiers  // Should be [item5...item14]

// Verify overlap: items1[5...9] should equal items2[0...4]
XCTAssertEqual(items1.suffix(5), items2.prefix(5))
XCTAssertEqual(items2.count, 10)  // Currently fails: returns 5

Impact

This bug breaks pagination implementations that rely on fetchOffset with publishList(). Developers using sliding window pagination or infinite scroll will see:

  • Missing items in the list
  • Incorrect item counts
  • Items appearing at wrong positions

Workaround

Until fixed, avoid using fetchOffset with publishList():

  • Use fetchAll() instead of publishList() for paginated queries
  • Or fetch all items without offset and slice in memory

Additional Notes

The BackingStructure.init method (lines 402-452 in Internals.DiffableDataSourceSnapshot.swift) correctly handles fetchOffset for its intended use case (un-offset sections). The bug is specifically in how controllerDidChangeContent calls this initializer with already-offset sections.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions