Skip to content

[iOS][Fabric] automaticallyAdjustKeyboardInsets state survives ScrollView recycling; recycled views apply keyboard insets they never opted into #57755

Description

@PragathiJ

Description

Reproduced on 0.86.2 (latest stable) and on 0.81.4; the responsible code is linked at both versions below.

Mechanism

On the New Architecture, RCTScrollViewComponentView registers its UIKeyboardWillChangeFrameNotification observer once and keeps it for the native view's whole lifetime, gated only by the _automaticallyAdjustKeyboardInsets ivar:

  • ivar declared: v0.81.4 L105, initialized only in init: L136
  • observer registered: L163-L170. The comment there notes explicit unregistration is unnecessary because removal is automatic. That automatic removal happens at dealloc, a point a recycled view never reaches.
  • handler gated only by the ivar: L173-L176
  • the ivar is assigned only inside an old-vs-new props diff in updateProps: L347-L349
  • prepareForRecycle (L616-L637) resets contentOffset and even resets the adjacent _shouldUpdateContentInsetAdjustmentBehavior flag (L629), but neither contentInset nor _automaticallyAdjustKeyboardInsets. On current main, contentInset and contentOffset are both reset in prepareForRecycle; the ivar and the observer still are not.

Consequence: when a ScrollView with automaticallyAdjustKeyboardInsets is unmounted while keyboard activity is in flight (for example a modal that auto-dismisses after a form submission while an autofocused input's keyboard is still animating), its recycled native view keeps armed keyboard machinery. A subsequently mounted plain ScrollView that receives this view from the pool gets keyboard insets applied to it, although it never enabled the prop and contains no inputs. On 0.81.4 the un-reset contentInset can additionally carry over directly; on newer versions the values are cleaned, but the pooled view keeps receiving keyboard notifications and re-stamps an inset onto itself, which the next component to mount inherits.

Expected

A freshly mounted ScrollView with no inputs and without automaticallyAdjustKeyboardInsets reports contentInset.bottom = 0 with the keyboard hidden and never reacts to keyboard events.

Actual (iPhone, iOS 26.5.2, New Architecture, release builds; reproduced on RN 0.86.2 and 0.81.4)

  • The victim ScrollView reports a phantom contentInset.bottom = 217.3 (its geometric overlap with the keyboard) with the keyboard hidden, on every poisoned cycle.
  • Scrolling parks settled at maxOffset + 217 in blank space with no bounce-back, because the phantom inset legalizes the region.
  • Native logging per keyboard delivery: the pooled view receives the notification and writes the inset onto itself while state=POOLED. The component that mounts on it next inherits that value. On mount, updateProps clears the ivar, so the mounted view takes no further writes.
  • The value never grows across further keyboard events, consistent with the handler assigning the geometric overlap absolutely rather than accumulating.
  • Contamination persists across further cycles and config changes until the process dies.
  • Controls, each from a fresh process: with automaticallyAdjustKeyboardInsets off, 0 findings across identical cycles; with the keyboard dismissed before the modal closes, 0 findings. Both ingredients are required.
  • On 0.86.2 the same harness files reproduce unchanged, with the same phantom inset value; in our run a rogue keyboard event (keyboard activity while the inputless victim was mounted) fired on every cycle.
  • The only notification the observer subscribes to is UIKeyboardWillChangeFrameNotification, so the re-stamp necessarily comes from those deliveries; our JS-side log shows the keyboard-hide event of each cycle arriving after the modal's teardown. We did not instrument the native side per delivery.

Impact

We hit this in a production app: closing a logging modal reset its flow in the same render batch that hid the modal, so an autofocused input remounted during the dismiss animation and opened a keyboard inside the closing modal; the aAKI ScrollView was pooled while that keyboard was up; from then on every freshly mounted screen or modal opened normally but ran into un-escapable blank space on first scroll, until app restart. Full-screen victims inherit the full keyboard height. The blank region has no view beneath it, so it takes no touches: once the content has scrolled out of view there is nothing left to grab and no gesture recovers it. Removing automaticallyAdjustKeyboardInsets app-wide eliminated it, validated by deliberately re-arming the trigger.

Suggested direction

Reset _automaticallyAdjustKeyboardInsets in prepareForRecycle (or remove/re-add the observer around recycling), and consider assigning the ivar unconditionally in updateProps instead of inside the diff, once the ivar is cleared on recycle, _props still holds the previous owner's value, so a true-to-true reuse would skip the assignment and the next component would silently lose keyboard insets.

Related

#55090 (scroll state surviving recycling; #56832 fixed the centerContent path), #56189 (stale keyboard inset after dismissal on the same view, open), #47731, #41397, #34165. Distinct from the stale-value family: the surviving element here is behavior, which re-creates the values even where they are reset.

Steps to reproduce

Minimal reproducer with an automated harness: https://github.com/PragathiJ/rn-aaki-recycle-repro

  1. Run on a physical device, default config (automaticallyAdjustKeyboardInsets on, close after 0ms).
  2. Tap Auto x10. Each cycle opens a Modal whose aAKI ScrollView autofocuses a TextInput, closes it mid-keyboard without dismissing, then freshly mounts a plain instrumented ScrollView ("victim").
  3. Watch the counters and the red log lines: the victim reports the phantom contentInset via its scroll events, so no native inspection is needed to observe the defect.

On the iOS Simulator the harness runs and shows the same keyboard-hide-after-teardown pattern in its event log, but we did not observe corruption in brief simulator runs; the primary evidence is from physical devices.

The reproducer is a bare @react-native-community/cli init app with no dependencies beyond react-native. The harness files are version-portable: they reproduce unchanged on both 0.81.4 and 0.86.2 (see the README results table). A Snack is unsuitable because the race requires physical-device keyboard timing and process-restart control. Happy to port onto the reproducer-react-native template or RNTesterPlayground if that helps triage.

React Native Version

RN 0.86.2

Affected Platforms

Runtime - iOS

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

System:
  OS: macOS 26.5.2
  CPU: (10) arm64 Apple M2 Pro
  Memory: 118.98 MB / 16.00 GB
  Shell:
    version: "5.9"
    path: /bin/zsh
Binaries:
  Node:
    version: 20.19.5
    path: /Users/pragathijayaram/.nvm/versions/node/v20.19.5/bin/node
  Yarn: Not Found
  npm:
    version: 10.8.2
    path: /Users/pragathijayaram/.nvm/versions/node/v20.19.5/bin/npm
  Watchman: Not Found
Managers:
  CocoaPods:
    version: 1.16.2
    path: /opt/homebrew/bin/pod
SDKs:
  iOS SDK:
    Platforms:
      - DriverKit 25.2
      - iOS 26.2
      - macOS 26.2
      - tvOS 26.2
      - visionOS 26.2
      - watchOS 26.2
  Android SDK: Not Found
IDEs:
  Android Studio: 2026.1 AI-261.23567.138.2611.15646644
  Xcode:
    version: 26.3/17C529
    path: /usr/bin/xcodebuild
Languages:
  Java: Not Found
  Ruby:
    version: 2.6.10
    path: /usr/bin/ruby
npmPackages:
  "@react-native-community/cli":
    installed: 20.1.0
    wanted: 20.1.0
  react:
    installed: 19.2.3
    wanted: 19.2.3
  react-native:
    installed: 0.86.2
    wanted: 0.86.2
  react-native-macos: Not Found
npmGlobalPackages:
  "*react-native*": Not Found
Android:
  hermesEnabled: true
  newArchEnabled: true
iOS:
  hermesEnabled: true
  newArchEnabled: true

Stacktrace or Logs

There is no crash; the failure is behavioral. Below are excerpts from the reproducer's on-screen event log (Release build, physical device), which mirrors everything to the harness UI. The CORRUPT lines are emitted by the victim ScrollView's own scroll events reporting its native `contentInset`/`contentOffset`.

Poisoning cycle pattern (each cycle; the keyboard-hide of the poison modal arrives after the modal's teardown, while the next screen is already mounting):


14:25:14.750 keyboardWillShow
14:25:15.245 keyboardWillHide (phase victim)
14:10:38.135 keyboardWillHide (phase gap)
keyboardWillShow during gap - ROGUE      <- keyboard event with no input focused anywhere


Corrupt readings on the freshly mounted victim (RN 0.86.2 run; keyboard hidden, victim has no inputs and never enabled automaticallyAdjustKeyboardInsets; content 840pt, viewport 234pt, so the honest max offset is 606 and the phantom inset legalizes parking at ~823):


15:37:21.469 CORRUPT[inline]: insetB=217.3 insetT=0.0 y=810.0
15:37:21.485 CORRUPT[inline]: insetB=217.3 insetT=0.0 y=810.3
15:37:21.523 CORRUPT[inline]: insetB=217.3 insetT=0.0 y=809.3
15:37:21.548 CORRUPT[inline]: insetB=217.3 insetT=0.0 y=809.7
15:37:21.569 CORRUPT[inline]: insetB=217.3 insetT=0.0 y=810.0
15:37:21.598 CORRUPT[inline]: insetB=217.3 insetT=0.0 y=810.3
15:37:21.620 CORRUPT[inline]: insetB=217.3 insetT=0.0 y=810.3
Counters after 3 cycles on 0.86.2: rogue keyboard 3, corrupt readings 203, max insetB 217.3


Line formats and emission points are in the reproducer's `App.tsx` (`pushLog`, `onReadingImpl`).

MANDATORY Reproducer

https://github.com/PragathiJ/rn-aaki-recycle-repro

Screenshots and Videos

aaki-recycle-repro.mp4
Image

Updated 2026-08-04. Instrumented the native side per keyboard delivery on 0.86.2 (physical device, Release build, React core built from source), which closes the "we did not instrument the native side per delivery" gap noted above.

Verified:

  • prepareForRecycle leaves _automaticallyAdjustKeyboardInsets set. Logged on entry and on exit of the method.
  • The pooled view keeps receiving keyboard notifications and writes the inset onto itself while pooled. The next component to mount on that view inherits the value.
  • On mount, updateProps clears the ivar, so the mounted view takes no further writes.
  • Timing: the defect requires the next component to mount while a keyboard is up. Tearing the modal down ~120ms after it mounts reproduces; at ~220ms and beyond the keyboard's hide reaches the pooled view before handover and it self-cleans.
  • The blank region has no view beneath it, so it takes no touches. Once content has scrolled out of view, no gesture recovers it.
  • The changes in [iOS] Fix keyboard inset state across ScrollView recycling #57811, backported to 0.86.2, stop it in this reproducer on device. Details and caveats in that PR.

The mechanism wording and the production sequence above have been updated to match.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions