Skip to content

[iOS][Fabric] RTL horizontal ScrollView: contentOffset prop is not RTL-converted while scrollTo/metrics are, and scrollTo conversion uses stale contentSize #58534

Open

Description

@baravit

Description

On the New Architecture (Fabric), iOS implements RTL for a horizontal ScrollView by mirroring the view and converting scroll coordinates — but the conversion is asymmetric, and the imperative conversion depends on the native contentSize being current at call time. JS-driven paged lists that work on Paper break on Fabric under RTL.

In React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm (0.81.5; the same code is on main and 0.82-stable):

  • updateLayoutMetrics: applies CGAffineTransformMakeScale(-1, 1) to _containerView and _scrollView when layoutDirection == RightToLeft.
  • _scrollViewMetrics converts the reported offset: metrics.contentOffset.x = contentSize.width - containerSize.width - contentOffset.x (RTL branch).
  • scrollToOffset:animated: converts the requested offset: offset.x = self.contentSize.width - _scrollView.frame.size.width - offset.x (RTL branch) — main ~L1024.
  • updateProps: applies the contentOffset prop with no conversion: _scrollView.contentOffset = RCTCGPointFromPoint(newScrollViewProps.contentOffset) — main ~L408. The prepareForRecycle / state-restore paths set contentOffset unconverted too.

Two concrete consequences:

  1. Prop vs. imperative disagree. In RTL, contentOffset={{x: k * pageWidth}} positions the view in physical (mirrored) space, while scrollTo({x: k * pageWidth}) positions it in logical space (and onScroll reports logical). For a paged list the prop lands one page off / at the far end relative to an identical scrollTo.
  2. scrollTo is timing-dependent. The conversion uses self.contentSize.width and _scrollView.frame.size.width at call time. A scrollTo issued right after content changes (before the native contentSize commits — e.g. in a setTimeout(0) after a data update, a very common pattern) is converted with stale dimensions and lands on the wrong page. This never happened in LTR (no conversion) and did not happen on Paper, so it surfaces as intermittent, device-only page jumps.

Steps to reproduce

  1. RTL app: I18nManager.allowRTL(true); I18nManager.forceRTL(true) and cold launch (the direction is read at startup).
  2. newArchEnabled: true, iOS.
  3. Horizontal ScrollView with pagingEnabled, N full-width pages.
  4. Case A — set contentOffset={{x: k * width}} as a prop; compare the page shown with calling scrollTo({x: k * width, animated: false}) after mount: they show different pages.
  5. Case B — change the content (add/remove pages) and in the same tick / setTimeout(0) call scrollTo({x: k * width}): on a device it frequently lands on a different page than k (converted against the previous contentSize); reading contentOffset.x from the next onScroll confirms the mismatch.

Expected behavior

All offset inputs are converted consistently in RTL — the contentOffset prop (and recycle/state restore) using the same mapping as scrollToOffset: and _scrollViewMetrics — and the imperative conversion should not silently use a stale contentSize (or the behavior should be documented so libraries can wait for the commit).

React Native Version

0.81.5 (Expo SDK 54). Verified the same code paths on main and 0.82-stable.

Affected Platforms

Runtime - iOS (New Architecture / Fabric). Not reproducible on the old architecture; not affected in LTR.

Environment

macOS 15 (Darwin 24.2), Xcode 16.2, iPhone 12 simulator (iOS 18.2) and physical iPhone (iOS 26.3.1), Hermes, newArchEnabled: true.

Real-world impact

react-native-calendars (used widely for RTL calendars) implements its own RTL offset handling for its recyclerlistview-based horizontal pagers; on Fabric that handling — and a naive "just remove the inversion" fix — both fail because of the two points above, producing calendars that jump between days/weeks nonstop. A downstream workaround (keep the pager's container direction: 'ltr' and mirror in JS) will be linked here.

Reproducer

No standalone reproducer repository attached yet; the steps above are minimal and the relevant native lines are cited. Happy to add a reproducer-react-native repo if maintainers want one.

Activity

  1. react-native-bot commented on Sep 15, 2026

    @react-native-bot
    Collaborator

    Warning

    Missing reproducer: We could not detect a reproducible example in your issue report. Reproducers are mandatory and we can accept only one of those as a valid reproducer:


    You can read more about about it on our website: How to report a bug.

  2. baravit commented on Sep 15, 2026

    @baravit
    Author

    Downstream workaround, for reference: wix/react-native-calendars#2793 keeps the pager's scroll container direction: 'ltr' and mirrors page order in JS, which sidesteps both the unconverted contentOffset prop path and the stale-contentSize conversion described above. Verified on a physical iPhone (iOS 26.3.1) with RN 0.81.5 + Fabric in an RTL app.

  3. baravit commented on Sep 15, 2026

    @baravit
    Author

    Update: the downstream calendar library's paged timeline also breaks under Android New-Architecture RTL (verified on a Pixel 7 / API 36 emulator — the day pager desyncs, header jumps months from the selected day). I have not traced that to a specific Android native cause the way the iOS contentOffset/scrollTo asymmetry above is traced; Android has its own RTL horizontal-scroll handling (ReactHorizontalScrollView, offsets "from the right edge", New-Arch re-centering). So this issue stays scoped to the iOS asymmetry, but flagging that RTL + New Arch horizontal paging is fragile on both platforms. The downstream workaround (wix/react-native-calendars#2793) sidesteps native RTL scroll handling entirely by keeping the container LTR and mirroring in JS, and fixes both.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs: AttentionIssues where the author has responded to feedback.Needs: ReproThis issue could be improved with a clear list of steps to reproduce the issue.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions