ListPublisher publishList with fetchOffset returns incomplete results
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:
NSFetchedResultsControllercorrectly fetched 10 objects (items 5-14)- 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:
fetchRequest.fetchOffset = 5, fetchLimit = 10NSFetchedResultsControllerfetches items 5-14 from Core Data (10 items) ✓controller.sectionscontains objects:[item5, item6, ..., item14]- Snapshot init receives
fetchOffset: 5and skips another 5 items - 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 ofpublishList()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.