From 4ed8774ff0359d4147eade067ec37500d57cfab1 Mon Sep 17 00:00:00 2001 From: Petro Rovenskyi Date: Thu, 14 May 2026 00:49:42 +0300 Subject: [PATCH] fix(TextureIGListKitExtensions): guard viewForSupplementaryElementOfKind against stale section indices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After performUpdates reduces the section count, UICollectionView may still hold cached layout attributes for removed sections and request supplementary views for them during the next layout pass. Without a guard, the bridge forwards to IGListAdapter which throws NSInternalInconsistencyException because the section controller at that index is nil. Add a sectionController(forSection:) nil-check before forwarding to IGListAdapter in the viewForSupplementaryElementOfKind:atIndexPath: Interop forwarder. When the section is out of range, return a zero-size UICollectionReusableView placeholder that satisfies UIKit without crashing. Add a regression test that calls the bridge selector directly with a section index that was removed by a preceding performUpdates — verifying the placeholder path is taken instead of crashing. --- .../IGListAdapter+Texture.swift | 6 ++ .../Tests/IGListAdapterBridgeTests.swift | 70 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/Sources/TextureIGListKitExtensions/IGListAdapter+Texture.swift b/Sources/TextureIGListKitExtensions/IGListAdapter+Texture.swift index 12cd83f58..d094914bb 100644 --- a/Sources/TextureIGListKitExtensions/IGListAdapter+Texture.swift +++ b/Sources/TextureIGListKitExtensions/IGListAdapter+Texture.swift @@ -463,7 +463,13 @@ public import IGListDiffKit func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + // Guard against stale layout attributes: after performUpdates reduces the + // section count, UICollectionView may still request supplementary views for + // sections that no longer exist in the adapter's section map. IGListAdapter + // throws NSInternalInconsistencyException in that case, so check validity + // here before forwarding. guard let dataSource = dataSource, + sectionController(forSection: indexPath.section) != nil, let view = dataSource.collectionView?(collectionView, viewForSupplementaryElementOfKind: kind, at: indexPath) else { diff --git a/examples/SPMWithIGListKit/Tests/IGListAdapterBridgeTests.swift b/examples/SPMWithIGListKit/Tests/IGListAdapterBridgeTests.swift index b5a30c427..9acd2ecc0 100644 --- a/examples/SPMWithIGListKit/Tests/IGListAdapterBridgeTests.swift +++ b/examples/SPMWithIGListKit/Tests/IGListAdapterBridgeTests.swift @@ -219,6 +219,76 @@ struct IGListAdapterBridgeTests { "allowsBackgroundDiffing must be false for ASCollectionNode consumers; with it on, IGListKit races between the background diff snapshot and the main-thread apply and throws at IGListBatchUpdateTransaction.m:145") } + /// After `performUpdates` removes sections, UICollectionView may still hold + /// cached layout attributes for the removed sections and request supplementary + /// views for them during a subsequent layout pass. Without a guard, the bridge + /// forwards to IGListAdapter which throws `NSInternalInconsistencyException` + /// because the section controller at that index is nil. + /// + /// This test calls the bridge's Interop selector directly with an index that + /// is outside the adapter's current section map — the call must return a + /// placeholder view without crashing. + @Test("bridge returns placeholder view for stale supplementary section after removal") + func bridge_returnsPlaceholder_forStaleSuppViewAfterSectionRemoval() async { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 568)) + let viewController = UIViewController() + window.rootViewController = viewController + window.makeKeyAndVisible() + + let collectionNode = ASCollectionNode(collectionViewLayout: UICollectionViewFlowLayout()) + collectionNode.frame = window.bounds + viewController.view.addSubnode(collectionNode) + _ = collectionNode.view + + let adapter = ListAdapter(updater: ListAdapterUpdater(), + viewController: viewController, + workingRangeSize: 0) + let dataSource = HeaderTestDataSource(items: []) + adapter.dataSource = dataSource + adapter.setCollectionNode(collectionNode) + + // Load 3 sections so IGListAdapter has a valid section map for [0,1,2]. + dataSource.items = [TestItem(id: 1), TestItem(id: 2), TestItem(id: 3)] + await withCheckedContinuation { continuation in + adapter.performUpdates(animated: false) { _ in continuation.resume() } + } + collectionNode.view.layoutIfNeeded() + #expect(collectionNode.view.numberOfSections == 3) + + // Reduce to 1 section. Section map in IGListAdapter now only covers [0]. + // UICollectionView may still hold stale layout attributes for sections 1 + // and 2 until its next full layout invalidation. + dataSource.items = [TestItem(id: 1)] + await withCheckedContinuation { continuation in + adapter.performUpdates(animated: false) { _ in continuation.resume() } + } + #expect(collectionNode.view.numberOfSections == 1) + + // Simulate UICollectionView requesting a supplementary view for the now- + // removed section 2. Without the bridge guard, IGListAdapter would throw + // NSInternalInconsistencyException here. + let bridge = collectionNode.dataSource + guard let bridgeObj = bridge as? NSObject else { + Issue.record("collectionNode.dataSource is not NSObject — bridge not installed") + return + } + let sel = NSSelectorFromString("collectionView:viewForSupplementaryElementOfKind:atIndexPath:") + guard bridgeObj.responds(to: sel) else { + Issue.record("Bridge does not respond to viewForSupplementaryElementOfKind:atIndexPath:") + return + } + let imp = bridgeObj.method(for: sel) + typealias SupplementaryFn = @convention(c) (NSObject, Selector, UICollectionView, NSString, IndexPath) -> UICollectionReusableView + let fn = unsafeBitCast(imp, to: SupplementaryFn.self) + let staleIndexPath = IndexPath(item: 0, section: 2) + // Must not crash — returns a zero-size placeholder instead of throwing. + let view = fn(bridgeObj, sel, collectionNode.view, + UICollectionView.elementKindSectionHeader as NSString, + staleIndexPath) + #expect(view is UICollectionReusableView, + "Bridge must return a placeholder view for a stale section, not crash via IGListAdapter") + } + /// Exercises the supplementary header layout path on the bridge. /// /// UICollectionView populates `layoutAttributesForSupplementaryElement`