Skip to content

Render-phase markChildLayoutAsPending can orphan pendingChildIds, permanently freezing the parent relayout effect (items overlap/jam until remount) #2319

Description

@vurhd

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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Important but not urgentagent-triageTrigger agent triage on this issue

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions