Shopify / Shopify/flash-list

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

Open
#2,319 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

agent-triage P1
Dominant language
TypeScript
Stars
7.2k
Forks
393
Avg merge
1d 3h
Merged PRs (30d)
1

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in src/recyclerview/RecyclerView.tsx, reviewing the render-phase mark near L412, the parent relayout effect near L204, and the pendingChildIds handling near L116. Compare the proposed lifecycle change with the horizontal early-unmark path near L256, then inspect LayoutCommitObserver.tsx around L52-55 for the same pattern. Done means discarded renders and unmounted children no longer leave orphaned pending IDs or permanently freeze parent relayout.

Written by the indexing model from the issue text.

Assessment

Tech stack
react-native, typescript
Domain
mobile
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.