Skip to content

fix(recycling): guard layout reads for indices that no longer exist - #2460

Open
dennytosp wants to merge 1 commit into
Shopify:mainfrom
dennytosp:fix/stale-layout-index-reads
Open

fix(recycling): guard layout reads for indices that no longer exist#2460
dennytosp wants to merge 1 commit into
Shopify:mainfrom
dennytosp:fix/stale-layout-index-reads

Conversation

@dennytosp

@dennytosp dennytosp commented Aug 22, 2026

Copy link
Copy Markdown

Fixes #2440
Fixes #2291

Problem

Two reads of the layout table use RecyclerViewManager.getLayout(), which throws index out of bounds, not enough layouts when the index is past the end:

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 through recomputeEngagedIndices(), and EngagedIndicesTracker.updateScrollOffset() returns undefined when 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 the data reference in useRecyclerViewManager, so a data array shrunk in place never reaches it — only the measurement useLayoutEffect sees the new length, and that path has no equivalent fallback.

I swept the props-consistent shrink path (updateProps + modifyChildrenLayout with 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:

target=5..80 via updateProps   ->  needsRerender=true   oob=[]
in-place `data.length = 20`    ->  needsRerender=false  oob=[20,21,22,23,24,25]

needsRerender=false means nothing schedules a re-render to repair it either.

Fix

Read 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 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 truncation layouts.length is the new data length), so nothing visible is dropped; the key is reused on the next render stack sync.

Verification

src/__tests__/StaleLayoutIndices.test.tsx drives the real component, not the manager in isolation.

Each half of the fix was reverted on its own to confirm neither test is vacuous:

Mutation shrink-in-place test stale-onLayout test
none (this PR) pass pass
render path back to getLayout failindex out of bounds, not enough layouts pass
drop the undefined skip in ViewHolderCollection fail — renders 6 phantom cells with an undefined item pass
validateItemSize back to getLayout pass fail — same throw

yarn test 189 passed / 15 suites, yarn type-check and yarn lint clean.

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-sync processDataUpdate() 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.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant