fix(recycling): guard layout reads for indices that no longer exist - #2460
Open
dennytosp wants to merge 1 commit into
Open
fix(recycling): guard layout reads for indices that no longer exist#2460dennytosp wants to merge 1 commit into
dennytosp wants to merge 1 commit into
Conversation
ViewHolderCollection's render path and validateItemSize both read the layout table with RecyclerViewManager.getLayout(), which throws "index out of bounds, not enough layouts" once the index is gone. Both can be reached with an index the layout table no longer has: - A render stack entry outlives a truncation. modifyChildrenLayout() shrinks the layout table on its first line but only re-syncs the render stack when the engaged range changes, so keys pointing past the new end survive and get rendered. - A ViewHolder's onLayout lands after its row was dropped, and validateItemSize() still looks up the render-time index. Read both through tryGetLayout(), as StickyHeaders, the measurement effect and the public getLayout ref already do, and skip render stack entries with no layout. The guard is inert whenever the index is valid, so nothing changes for a list that is not in this state. Fixes Shopify#2440 Fixes Shopify#2291
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2440
Fixes #2291
Problem
Two reads of the layout table use
RecyclerViewManager.getLayout(), which throwsindex out of bounds, not enough layoutswhen the index is past the end:ViewHolderCollection.tsx:195— the render path, wired to the unguarded read atRecyclerView.tsx:589(ViewHolderCollection render throws "index out of bounds, not enough layouts" when the render stack outlives a layout-table shrink #2440)RecyclerView.tsx:377—validateItemSize(), reached from aViewHolder'sonLayout(Stale ViewHolder onLayout can throw when its layout index no longer exists #2291)Both are reachable with an index the layout table no longer has, and the throw takes the tree down. Both reporters carry the guard below as a package patch in production; #2440 reports ~116 users over ~2.5 months.
How the index goes stale
modifyChildrenLayout()truncates the layout table on its first line, but the render stack is only re-synced throughrecomputeEngagedIndices(), andEngagedIndicesTracker.updateScrollOffset()returnsundefinedwhen the recomputed range has the same endpoints as before. When it reports no change, keys pointing past the new end survive — and the recycler legitimately holds keys well beyond the engaged range, so there are usually plenty.processDataUpdate()covers this: it re-syncs the render stack even when the engaged range is unchanged. But it is memoized on thedatareference inuseRecyclerViewManager, so a data array shrunk in place never reaches it — only the measurementuseLayoutEffectsees the new length, and that path has no equivalent fallback.I swept the props-consistent shrink path (
updateProps+modifyChildrenLayoutwith the same length) across 11 shrink targets and it never leaves a stale entry — the engaged range always changes, so the sync always runs. The in-place shrink does leave them:needsRerender=falsemeans nothing schedules a re-render to repair it either.Fix
Read through
tryGetLayout()— asStickyHeaders, the measurement effect and the publicgetLayoutref already do — and skip render stack entries with no layout.The guard is inert whenever the index is valid, so no list that isn't already in this state changes behaviour. A skipped entry always points at an item that no longer exists (the crash needs
index >= layouts.length, and after a truncationlayouts.lengthis the new data length), so nothing visible is dropped; the key is reused on the next render stack sync.Verification
src/__tests__/StaleLayoutIndices.test.tsxdrives the real component, not the manager in isolation.Each half of the fix was reverted on its own to confirm neither test is vacuous:
getLayoutindex out of bounds, not enough layoutsundefinedskip inViewHolderCollectionvalidateItemSizeback togetLayoutyarn test189 passed / 15 suites,yarn type-checkandyarn lintclean.Not addressed
This makes the reads safe; it does not repair the render-stack / layout-table disagreement itself. The deeper fix is to give
modifyChildrenLayout()the same unconditional re-syncprocessDataUpdate()has, which changes recycling keys and is a larger behavioural change than a crash guard — happy to follow up with it separately if that's preferred.