Skip to content

Commit cc47ee4

Browse files
camrothmeta-codesync[bot]
authored andcommitted
Add IGListExperimentSkipNilIndexPathFiltering to opt into nil-index-path-aware visible cell filtering
Summary: There's a subtle bug in `-[IGListAdapter visibleCellsForSectionController:]` and `-[IGListAdapter fullyVisibleCellsForSectionController:]`... both methods filter `[collectionView visibleCells]` by `[collectionView indexPathForCell:cell].section == section`, but `indexPathForCell:` is allowed to return `nil` for cells in transitional states — mid-`prepareForReuse`, mid-detach during a `performBatchUpdates`, or in flight between dequeue and final attachment. When that happens, sending `-section` to a `nil` `NSIndexPath` returns `0` in Objective-C, so any visible cell with a cleared index path is silently treated as belonging to **section 0**. For most consumers this is harmless or invisible. But callers that walk these results and then read layout / window-relative geometry from each returned cell can end up touching cells whose model has been swapped out. We've seen this surface as crashes when a consumer triggers a layout pass on a cell whose backing data is mid-replacement (for example, text typesetting on an `NSAttributedString` that's being swapped out as the cell prepares for reuse). It's also a logical correctness issue: section 0 is the only section that can ever observe transitional cells from other sections leaking in. The fix itself is essentially one line: skip cells where `indexPathForCell:` returned `nil` before doing the section comparison. The interesting question is how to roll it out. Given how far-reaching this could be for code depending on IGListKit, I've introduced this as an opt-in experiment so we can effectively test this in Instagram and Threads before iterating or making an informed decision. The fix is strictly subtractive — it can only narrow the result set, never expand it. Most consumers will just get more correct results. But these methods are called from a wide variety of places across multiple apps that depend on IGListKit, and we can't realistically audit every caller for code that may have implicitly leaned on the section-0 leak (for example, a state machine that flips the moment any cell becomes visible). Once it's been baked in across enough downstream consumers we can revisit and consider flipping the default or removing the flag entirely. If you'd like to opt in yourself, you can set: ```objc adapter.experiments |= IGListExperimentSkipNilIndexPathFiltering; ``` Differential Revision: D106699470 fbshipit-source-id: 966934aa7b1eefee3289d7d06c9a34e5a2ef0d35
1 parent ed04ffc commit cc47ee4

3 files changed

Lines changed: 84 additions & 4 deletions

File tree

Source/IGListDiffKit/IGListExperiments.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ typedef NS_OPTIONS (NSInteger, IGListExperiment) {
2828
IGListExperimentRemoveDataSourceChangeEarlyExit = 1 << 4,
2929
/// Avoids creating off-screen cells
3030
IGListExperimentFixPreferredFocusedView = 1 << 5,
31+
/// Excludes cells whose `[UICollectionView indexPathForCell:]` returns nil from
32+
/// `-visibleCellsForSectionController:` and `-fullyVisibleCellsForSectionController:`.
33+
/// Without this experiment, transitional cells (mid-prepareForReuse, mid-detach during
34+
/// batch updates, or in flight between dequeue and final attachment) are mis-attributed
35+
/// to section 0 because `-section` sent to a nil `NSIndexPath` returns 0.
36+
IGListExperimentSkipNilIndexPathFiltering = 1 << 6,
3137
};
3238

3339
/**

Source/IGListKit/IGListAdapter.m

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,27 @@
3434
/// Returns the cells from `[collectionView visibleCells]` whose index path is in `section`.
3535
/// When `fullyVisibleOnly` is YES, additionally requires the cell's frame to be fully contained
3636
/// within the collection view's content-inset-adjusted bounds.
37+
///
38+
/// When `skipNilIndexPath` is YES, cells whose `-indexPathForCell:` returns nil are skipped.
39+
/// Such cells are in a transitional state (mid-prepareForReuse, mid-detach during batch updates,
40+
/// or in flight between dequeue and final attachment). Without that guard, `-section` sent to a
41+
/// nil index path returns 0, which mis-attributes transitional cells to section 0.
3742
static NSArray<UICollectionViewCell *> *IGListAdapterCellsInSection(UICollectionView *collectionView,
3843
NSInteger section,
39-
BOOL fullyVisibleOnly) {
44+
BOOL fullyVisibleOnly,
45+
BOOL skipNilIndexPath) {
4046
NSMutableArray<UICollectionViewCell *> *const cells = [NSMutableArray new];
4147
NSArray<UICollectionViewCell *> *const visibleCells = [collectionView visibleCells];
4248
const CGRect insetBounds = fullyVisibleOnly
4349
? UIEdgeInsetsInsetRect(collectionView.bounds, collectionView.contentInset)
4450
: CGRectZero;
4551

4652
for (UICollectionViewCell *cell in visibleCells) {
47-
if ([collectionView indexPathForCell:cell].section != section) {
53+
NSIndexPath *const cellIndexPath = [collectionView indexPathForCell:cell];
54+
if (skipNilIndexPath && cellIndexPath == nil) {
55+
continue;
56+
}
57+
if (cellIndexPath.section != section) {
4858
continue;
4959
}
5060
if (fullyVisibleOnly) {
@@ -1167,7 +1177,8 @@ - (nullable UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndex:(N
11671177
// The section controller is not in the map, which can happen if the associated object was deleted or after a full reload.
11681178
return @[];
11691179
}
1170-
return IGListAdapterCellsInSection(self.collectionView, section, /*fullyVisibleOnly=*/YES);
1180+
const BOOL skipNilIndexPath = IGListExperimentEnabled(self.experiments, IGListExperimentSkipNilIndexPathFiltering);
1181+
return IGListAdapterCellsInSection(self.collectionView, section, /*fullyVisibleOnly=*/YES, skipNilIndexPath);
11711182
}
11721183

11731184
- (NSArray<UICollectionViewCell *> *)visibleCellsForSectionController:(IGListSectionController *)sectionController {
@@ -1176,7 +1187,8 @@ - (nullable UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndex:(N
11761187
// The section controller is not in the map, which can happen if the associated object was deleted or after a full reload.
11771188
return @[];
11781189
}
1179-
return IGListAdapterCellsInSection(self.collectionView, section, /*fullyVisibleOnly=*/NO);
1190+
const BOOL skipNilIndexPath = IGListExperimentEnabled(self.experiments, IGListExperimentSkipNilIndexPathFiltering);
1191+
return IGListAdapterCellsInSection(self.collectionView, section, /*fullyVisibleOnly=*/NO, skipNilIndexPath);
11801192
}
11811193

11821194
- (NSArray<NSIndexPath *> *)visibleIndexPathsForSectionController:(IGListSectionController *) sectionController {

Tests/IGListAdapterTests.m

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2345,6 +2345,68 @@ - (void)test_whenSectionControllerRemoved_thatVisibleCellsIsEmpty {
23452345
XCTAssertEqual(cells.count, 0);
23462346
}
23472347

2348+
// Regression test for IGListExperimentSkipNilIndexPathFiltering. When a cell's index path has
2349+
// been cleared (mid-prepareForReuse, mid-detach during batch updates, or in flight between
2350+
// dequeue and final attachment), `-[NSIndexPath section]` sent to nil returns 0, so without
2351+
// the experiment flag the filter erroneously includes any visible cell with a nil index path
2352+
// in section 0's result. With the flag, those cells are skipped.
2353+
- (void)test_whenSkipNilIndexPathExperimentEnabled_andCellHasNilIndexPath_thatVisibleCellsForSection0DoesNotIncludeIt {
2354+
self.adapter.experiments |= IGListExperimentSkipNilIndexPathFiltering;
2355+
2356+
// Set up two sections so we have cells in section 1 that should never appear in section 0.
2357+
self.dataSource.objects = @[@1, @2];
2358+
[self.adapter reloadDataWithCompletion:nil];
2359+
[self.collectionView layoutIfNeeded];
2360+
2361+
IGListSectionController *const section0Controller = [self.adapter sectionControllerForObject:@1];
2362+
IGListSectionController *const section1Controller = [self.adapter sectionControllerForObject:@2];
2363+
UICollectionViewCell *const section1Cell = [self.adapter cellForItemAtIndex:0 sectionController:section1Controller];
2364+
XCTAssertNotNil(section1Cell, @"Setup precondition: a real section-1 cell must exist.");
2365+
XCTAssertTrue([[self.collectionView visibleCells] containsObject:section1Cell],
2366+
@"Setup precondition: section-1 cell must be in the collection view's visible cells.");
2367+
2368+
// Force [collectionView indexPathForCell:section1Cell] to return nil, simulating a cell that
2369+
// is mid-prepareForReuse / mid-detach. Other cells continue to return their real index paths.
2370+
id mockCollectionView = OCMPartialMock(self.collectionView);
2371+
OCMStub([mockCollectionView indexPathForCell:section1Cell]).andReturn(nil);
2372+
2373+
NSArray<UICollectionViewCell *> *const section0VisibleCells = [self.adapter visibleCellsForSectionController:section0Controller];
2374+
XCTAssertFalse([section0VisibleCells containsObject:section1Cell],
2375+
@"With experiment enabled, cell with nil index path must not be mis-attributed to section 0 by visibleCellsForSectionController:.");
2376+
2377+
NSArray<UICollectionViewCell *> *const section0FullyVisibleCells = [self.adapter fullyVisibleCellsForSectionController:section0Controller];
2378+
XCTAssertFalse([section0FullyVisibleCells containsObject:section1Cell],
2379+
@"With experiment enabled, cell with nil index path must not be mis-attributed to section 0 by fullyVisibleCellsForSectionController:.");
2380+
2381+
[mockCollectionView stopMocking];
2382+
}
2383+
2384+
// Counter-test: when the experiment flag is OFF (default), the legacy mis-attribution behavior
2385+
// is preserved. Verifies the experiment flag is observably gating the behavior change.
2386+
- (void)test_whenSkipNilIndexPathExperimentDisabled_andCellHasNilIndexPath_thatVisibleCellsForSection0IncludesIt_legacyBehavior {
2387+
XCTAssertFalse(IGListExperimentEnabled(self.adapter.experiments, IGListExperimentSkipNilIndexPathFiltering),
2388+
@"Setup precondition: experiment must default to off.");
2389+
2390+
self.dataSource.objects = @[@1, @2];
2391+
[self.adapter reloadDataWithCompletion:nil];
2392+
[self.collectionView layoutIfNeeded];
2393+
2394+
IGListSectionController *const section0Controller = [self.adapter sectionControllerForObject:@1];
2395+
IGListSectionController *const section1Controller = [self.adapter sectionControllerForObject:@2];
2396+
UICollectionViewCell *const section1Cell = [self.adapter cellForItemAtIndex:0 sectionController:section1Controller];
2397+
XCTAssertNotNil(section1Cell);
2398+
2399+
id mockCollectionView = OCMPartialMock(self.collectionView);
2400+
OCMStub([mockCollectionView indexPathForCell:section1Cell]).andReturn(nil);
2401+
2402+
// Legacy buggy behavior: [nil section] == 0, so the section-1 cell is mis-attributed to section 0.
2403+
NSArray<UICollectionViewCell *> *const section0VisibleCells = [self.adapter visibleCellsForSectionController:section0Controller];
2404+
XCTAssertTrue([section0VisibleCells containsObject:section1Cell],
2405+
@"Without experiment, legacy mis-attribution must be preserved (cell appears in section 0).");
2406+
2407+
[mockCollectionView stopMocking];
2408+
}
2409+
23482410
- (void)test_whenSectionControllerRemoved_thatVisibleIndexPathIsEmpty {
23492411
self.dataSource.objects = @[@1];
23502412
[self.adapter performUpdatesAnimated:NO completion:nil];

0 commit comments

Comments
 (0)