Summary
RecyclerView.tsx marks a nested list as layout-pending on its parent during the render phase:
// src/recyclerview/RecyclerView.tsx (~L412 in 2.3.2)
if (
!recyclerViewManager.getIsFirstLayoutComplete() &&
recyclerViewManager.getDataLength() > 0
) {
parentRecyclerViewContext?.markChildLayoutAsPending(recyclerViewId);
}
This is a render-phase side effect into the parent's mutable Set (pendingChildIds, L116). The only things that ever unmark are the child's own commit-phase relayout effect (after its first layout completes) and the horizontal early-unmark path (~L256). There is no unmount cleanup, and a render that never commits has no commit phase at all. That leaves two leak paths:
- Discarded render attempts. Under React 18/19 concurrent rendering (interrupted transitions, Suspense retries, StrictMode double-render), React can render a component and throw the attempt away. The mark into the parent's Set has already happened; the unmark never comes, because that instance never commits.
- Children unmounted before first layout. A nested list inside a cell that gets removed (or a section behind a lazy chunk that unmounts on a fast toggle) before completing its first layout never reaches the code path that unmarks it.
Either way the orphaned id stays in pendingChildIds forever, and the parent's relayout effect permanently early-returns:
// src/recyclerview/RecyclerView.tsx (~L204)
useLayoutEffect(() => {
if (pendingChildIds.size > 0) {
return;
}
...
From that point the parent never updates item offsets again. Symptoms: overlapping cells, phantom gaps, content height stuck short so trailing items are unreachable, scroll "jamming" mid-list. The corruption survives scrolling, data updates, and navigation — only a full remount of the parent list heals it.
This may explain reports where overlap persists rather than self-correcting after a frame or two, e.g. #2175.
Environment where we hit it
- FlashList 2.3.1 and 2.3.2 (code identical in this area)
- React Native 0.85.3, new architecture (Fabric), React 19
- Production trading app: a vertical
FlashList whose sections contain nested horizontal FlashLists, some sections mounted behind a lazy chunk, with the user rapidly toggling segments while live WebSocket ticks drive re-renders
It does not reproduce synthetically on a quiet screen — it accumulates under concurrent re-render load (we could only trigger it under live market data, never off-hours with identical interactions). Once triggered, the frozen state is trivially observable: the parent's relayout effect early-returns on every commit.
Fix we are shipping as a patch
Move the mark into a commit-phase layout effect, and always unmark on unmount. Child layout effects run before the parent's relayout effect within the same commit, so the parent's first-layout gating is preserved; a render attempt that never commits now never marks; a child that unmounts always unmarks. The horizontal early-unmark condition is replicated inside the mark effect so an effect declared after the relayout effect can't re-block a parent that the relayout effect deliberately unblocked.
--- a/src/recyclerview/RecyclerView.tsx
+++ b/src/recyclerview/RecyclerView.tsx
@@ -412,12 +412,26 @@ const RecyclerViewComponent = <T,>(
- if (
- !recyclerViewManager.getIsFirstLayoutComplete() &&
- recyclerViewManager.getDataLength() > 0
- ) {
- parentRecyclerViewContext?.markChildLayoutAsPending(recyclerViewId);
- }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useLayoutEffect(() => {
+ const horizontalEarlyUnmarked =
+ horizontal &&
+ recyclerViewManager.hasLayout() &&
+ recyclerViewManager.getWindowSize().height > 0;
+ if (
+ !recyclerViewManager.getIsFirstLayoutComplete() &&
+ recyclerViewManager.getDataLength() > 0 &&
+ !horizontalEarlyUnmarked
+ ) {
+ parentRecyclerViewContext?.markChildLayoutAsPending(recyclerViewId);
+ }
+ });
+ // Unmount-only cleanup — no-op unless actually marked; triggers one parent
+ // relayout so the freed parent integrates.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useLayoutEffect(() => {
+ return () => {
+ parentRecyclerViewContext?.unmarkChildLayoutAsPending(recyclerViewId);
+ };
+ }, []);
Known tradeoff: a child suspended before its first commit no longer blocks the parent — the parent integrates the estimated size and re-layouts when the child commits, which is the normal dynamic-height path (slightly more work, correct result).
We've been running this patch in production-track QA builds (via patch-package on the dist output plus the src mirror above) under heavy live-tick load with nested lists, with the previously-reproducible permanent corruption gone and no regressions observed.
LayoutCommitObserver.tsx has the same render-phase mark pattern (L52-55) and likely the same orphan exposure; we haven't patched it since our repro went through RecyclerView.
Happy to open a PR with this change if the approach looks right to maintainers.
Summary
RecyclerView.tsxmarks a nested list as layout-pending on its parent during the render phase:This is a render-phase side effect into the parent's mutable
Set(pendingChildIds, L116). The only things that ever unmark are the child's own commit-phase relayout effect (after its first layout completes) and the horizontal early-unmark path (~L256). There is no unmount cleanup, and a render that never commits has no commit phase at all. That leaves two leak paths:Either way the orphaned id stays in
pendingChildIdsforever, and the parent's relayout effect permanently early-returns:From that point the parent never updates item offsets again. Symptoms: overlapping cells, phantom gaps, content height stuck short so trailing items are unreachable, scroll "jamming" mid-list. The corruption survives scrolling, data updates, and navigation — only a full remount of the parent list heals it.
This may explain reports where overlap persists rather than self-correcting after a frame or two, e.g. #2175.
Environment where we hit it
FlashListwhose sections contain nested horizontalFlashLists, some sections mounted behind a lazy chunk, with the user rapidly toggling segments while live WebSocket ticks drive re-rendersIt does not reproduce synthetically on a quiet screen — it accumulates under concurrent re-render load (we could only trigger it under live market data, never off-hours with identical interactions). Once triggered, the frozen state is trivially observable: the parent's relayout effect early-returns on every commit.
Fix we are shipping as a patch
Move the mark into a commit-phase layout effect, and always unmark on unmount. Child layout effects run before the parent's relayout effect within the same commit, so the parent's first-layout gating is preserved; a render attempt that never commits now never marks; a child that unmounts always unmarks. The horizontal early-unmark condition is replicated inside the mark effect so an effect declared after the relayout effect can't re-block a parent that the relayout effect deliberately unblocked.
Known tradeoff: a child suspended before its first commit no longer blocks the parent — the parent integrates the estimated size and re-layouts when the child commits, which is the normal dynamic-height path (slightly more work, correct result).
We've been running this patch in production-track QA builds (via patch-package on the
distoutput plus thesrcmirror above) under heavy live-tick load with nested lists, with the previously-reproducible permanent corruption gone and no regressions observed.LayoutCommitObserver.tsxhas the same render-phase mark pattern (L52-55) and likely the same orphan exposure; we haven't patched it since our repro went throughRecyclerView.Happy to open a PR with this change if the approach looks right to maintainers.