Skip to content

[iOS][Fabric] Recycled ScrollView with a stable non-zero contentInset keeps the zeroed inset from prepareForRecycle #57314

Description

@gimi-anders

Description

On the New Architecture (iOS), RCTScrollViewComponentView.prepareForRecycle resets the native contentInset to UIEdgeInsetsZero. When that recycled native view is later reused by a different ScrollView component whose contentInset prop is non-zero but equal to the previous component's contentInset, the inset is never restored: updateProps: only re-applies contentInset when the value changed (old != new), or on a centerContent true→false transition. Neither condition holds, so the new ScrollView ends up with a native contentInset of zero while its props say otherwise. Content is displaced to the top and the inset region becomes unreachable.

This is a follow-on gap from the #55090 fix (#56832): that PR restores the inset for the centerContent path, but not for the general case of a plain, stable contentInset.

Lineage

Root cause (verifiable on main, no repro needed)

packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm:

prepareForRecycle zeroes the inset:

// Reset contentInset to prevent stale insets leaking into recycled scroll views.
_scrollView.contentInset = UIEdgeInsetsZero;

updateProps: only restores it on a centerContent transition or when the value changed:

// When disabling centerContent, reset inset to prop value
if (oldScrollViewProps.centerContent && !newScrollViewProps.centerContent) {
  _scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
}
// Only apply contentInset from props if centerContent is disabled
if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset && !newScrollViewProps.centerContent) {
  _scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
}

For a recycled view reused by a component with the same non-zero contentInset and no centerContent:

  • prepareForRecycle has set the native inset to 0.
  • The recycled view still holds the previous component's props, so oldScrollViewProps.contentInset == newScrollViewProps.contentInset → the old != new branch is skipped.
  • centerContent never transitions → that branch is skipped too.

Result: the native inset stays at 0, out of sync with the props.

Confirmation of the mechanism

A workaround that nudges contentInset by 1px for the first committed frame (forcing old != new so updateProps re-applies the inset) fully resolves the symptom. This only works if the old == new diff-skip is the cause, confirming the analysis.

Proposed fix

Restore the inset from props in prepareForRecycle instead of zeroing it, mirroring the contentOffset line directly above (which already restores from props.contentOffset). Guard so the centerContent path from #56832 is left untouched:

// Restore from props rather than zeroing: updateProps only re-applies contentInset
// when old != new, so a recycled view reused by a component with the same inset would
// otherwise keep this zeroed value.
_scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(props.contentInset);

React Native version

Reproduced on 0.85.3; confirmed still present on main by source inspection. Platform: iOS. Architecture: New (Fabric).

Steps to reproduce

This reliably reproduces with react-native-collapsible-tab-view on iOS + RN 0.85 (New Architecture). On iOS the library positions tab content using the native contentInset (header height + tab-bar height) with an initial contentOffset of -contentInset. With lazy tabs:

  1. Open a screen with a collapsible tab view (shared header → every tab uses the same contentInset).
  2. Switch to a tab that wasn't mounted yet. Its ScrollView/SectionList/FlatList mounts and reuses a recycled native scroll view from the pool.
  3. Because every tab shares the same header height, the recycled view's retained contentInset equals the new tab's → updateProps skips re-applying it → native inset stays 0.

Expected: the tab scrolls normally; the collapsing header can be revealed by scrolling up.

Actual: the list is jammed at the top — the missing inset means the scroll view has no room above the content, so it clamps to offset 0 and the header can never be revealed again.

Why no minimal library-free repro is attached

The bug requires the recycle pool to hand the new component a view whose retained contentInset equals the new component's non-zero inset. The library's shared-header lazy tabs arrange this naturally (all tabs share one inset value). In a synthetic two-ScrollView sample we were not able to reliably force the pool to reuse a same-inset view, so the symptom did not surface there. The source analysis above is exact and independently verifiable, and the library case reproduces deterministically.

React Native Version

0.85.3

Affected Platforms

Runtime - iOS

Areas

Fabric - The New Renderer

Output of npx @react-native-community/cli info

System:
  OS: macOS 26.5.1
  CPU: (12) arm64 Apple M2 Pro
  Memory: 255.45 MB / 16.00 GB
  Shell:
    version: "5.9"
    path: /bin/zsh
Binaries:
  Node:
    version: 24.14.0
    path: ~/.volta/tools/image/node/24.14.0/bin/node
  Yarn:
    version: 4.16.0
    path: ~/.volta/tools/image/yarn/4.16.0/bin/yarn
  npm:
    version: 11.9.0
    path: ~/.volta/tools/image/node/24.14.0/bin/npm
  Watchman:
    version: 2025.06.30.00
    path: /opt/homebrew/bin/watchman
Managers:
  CocoaPods: Not Found
SDKs:
  iOS SDK:
    Platforms:
      - DriverKit 25.5
      - iOS 26.5
      - macOS 26.5
      - tvOS 26.5
      - visionOS 26.5
      - watchOS 26.5
  Android SDK:
    API Levels:
      - "28"
      - "30"
      - "31"
      - "33"
      - "34"
      - "35"
      - "36"
      - "36"
    Build Tools:
      - 30.0.3
      - 33.0.0
      - 33.0.1
      - 34.0.0
      - 35.0.0
      - 36.0.0
      - 37.0.0
    System Images:
      - android-34-ext10 | Google Play Intel x86_64 Atom
      - android-34 | Google APIs ARM 64 v8a
      - android-34 | Google Play ARM 64 v8a
      - android-36.1 | Google Play ARM 64 v8a
    Android NDK: Not Found
IDEs:
  Android Studio: 2026.1 AI-261.23567.138.2611.15503007
  Xcode:
    version: 26.5/17F42
    path: /usr/bin/xcodebuild
Languages:
  Java:
    version: 17.0.14
    path: /opt/homebrew/opt/openjdk@17/bin/javac
  Ruby:
    version: 3.3.10
    path: /Users/anders/.rbenv/shims/ruby
npmPackages:
  "@react-native-community/cli":
    installed: 20.1.3
    wanted: ~20.1.3
  react:
    installed: 19.2.3
    wanted: 19.2.3
  react-native:
    installed: 0.85.3
    wanted: patch:react-native@npm%3A0.85.3#~/.yarn/patches/react-native-npm-0.85.3-2292697f2f.patch
  react-native-macos: Not Found
npmGlobalPackages:
  "*react-native*": Not Found
Android:
  hermesEnabled: true
  newArchEnabled: true
iOS:
  hermesEnabled: true
  newArchEnabled: true

Stacktrace or Logs

See the description and reproduction.

MANDATORY Reproducer

https://github.com/gimi-anders/rn-contentinset-recycle-repro

Screenshots and Videos

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    Component: ScrollViewNeeds: AttentionIssues where the author has responded to feedback.Needs: ReproThis issue could be improved with a clear list of steps to reproduce the issue.Platform: iOSiOS applications.Type: New ArchitectureIssues and PRs related to new architecture (Fabric/Turbo Modules)

    Type

    No type
    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