Skip to content

fix(rtl): correct horizontal RTL on native (blank, wrong edge, flicker)#494

Open
Radwan-Albahrani wants to merge 1 commit into
LegendApp:mainfrom
Radwan-Albahrani:fix/horizontal-rtl-native
Open

fix(rtl): correct horizontal RTL on native (blank, wrong edge, flicker)#494
Radwan-Albahrani wants to merge 1 commit into
LegendApp:mainfrom
Radwan-Albahrani:fix/horizontal-rtl-native

Conversation

@Radwan-Albahrani

Copy link
Copy Markdown

Problem

In a native-RTL app (I18nManager.isRTL === true, e.g. Arabic/Hebrew) a horizontal LegendList on the new architecture is broken on both iOS and Android:

LTR apps, and RTL apps that only use the rtl prop on an otherwise-LTR app, are unaffected.

Root cause

The library emulates RTL entirely in JS (mirror item positions, convert scroll offsets) and assumes the native scroll view behaves like a physically-LTR scroller. On native new-arch that assumption doesn't hold, and it collides with what React Native already does for RTL:

  1. Double mirror. RN's doLeftAndRightSwapInRTL is on by default on iOS and Android. Inside an RTL container it rewrites an absolutely-positioned child's left inset to start, which Yoga resolves from the right edge — so native layout already mirrors the item positions. toPhysicalHorizontalItemPosition then mirrors them a second time, pushing every item off screen → blank list.
  2. Mode misclassification. iOS Fabric's scroll view flips contentOffset in both directions (maxOffset - x, self-inverse), so from JS the offset is deterministically "inverted". The classifier in toLogicalHorizontalOffset instead guessed per frame: a single overscroll-bounce frame (negative rawOffset) pinned "negative", then the next positive frame fell through to the distance heuristic and could reclassify "inverted" → "normal", mirroring the visible-range math mid-scroll → blank on swipe.
  3. Watchdog ping-pong. scrollToFallbackOffset dispatched the unconverted logical offset, so the initial-scroll watchdog/retry fought the converting doScrollTo dispatch — the visible flicker, and it could settle on the mirror index (Horizontal RTL: initialScrollIndex near the end settles on the mirror index #476).

Fix

  • toPhysicalHorizontalItemPosition — skip the JS mirror on native; RN's left→start swap already mirrors. Web keeps the mirror (Container forces direction: ltr there, so left stays physical).
  • toLogicalHorizontalOffset — pin the platform default mode (native "inverted", web "normal") instead of the per-frame heuristic. A negative rawOffset is treated as overscroll bounce (clamped), never a mode switch.
  • scrollToFallbackOffset — convert the logical offset with toNativeHorizontalOffset, matching the normal dispatch, so the watchdog/retry can't dispatch the mirror offset. (Same change as fix(rtl): horizontal initialScrollIndex near the end settles on the mirror index #478 for this function.)

Testing

  • bun test — 1458 pass, 0 fail (updated the RTL unit tests that encoded the old mirror/heuristic behavior; added a regression test that the mode stays pinned across frames).
  • biome check and tsc --project tsconfig.src.json clean.
  • Verified on real iOS and real Android devices (new architecture) in an Arabic app: horizontal carousels render at item 0 (visual right edge), scroll in the correct direction, no blank, no flicker.

Related

Fixes #477, #458, #476. Overlaps the scrollToFallbackOffset half of #478; supersedes the direction: ltr approach in #483 (forcing the scroll container LTR disables RN's native RTL machinery and then needs constant re-dispatch — this instead works with the native behavior).

Note: this changes the native RTL model from "mirror in JS" to "let RN mirror, convert offsets only", which is why a few RTL unit tests' expectations changed. Happy to adjust naming/structure to your preference.

@Radwan-Albahrani Radwan-Albahrani force-pushed the fix/horizontal-rtl-native branch 2 times, most recently from 2c871d8 to 4ab4e41 Compare July 9, 2026 10:29

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 20d72a27fd

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/utils/rtl.ts Outdated
Comment thread src/utils/rtl.ts Outdated
Horizontal lists were blank / rendered from the wrong edge / flickered in a
native-RTL app (I18nManager.isRTL) on iOS and Android new architecture.

- Don't JS-mirror item positions on native. RN's doLeftAndRightSwapInRTL (on by
  default) already rewrites 'left' to 'start' so Yoga mirrors positions inside an
  RTL container; mirroring again in JS double-mirrors items off screen (LegendApp#477, LegendApp#458).
  Web keeps the mirror since Container forces direction: ltr there.
- Pin the platform-default scroll mode instead of the per-frame distance heuristic.
  A single overscroll-bounce frame (negative rawOffset) pinned 'negative' and the
  next frame fell through to the heuristic and could reclassify 'inverted' -> 'normal',
  mirroring the visible-range math mid-scroll and blanking the list. Negative raw is
  bounce: clamp, never switch modes.
- Convert the offset in scrollToFallbackOffset so the initial-scroll watchdog/retry
  doesn't dispatch an unconverted (mirror) offset and ping-pong the converting
  dispatch (LegendApp#476) - the ping-pong is the visible flicker.

Verified on real iOS and Android devices (new architecture).
@Radwan-Albahrani Radwan-Albahrani force-pushed the fix/horizontal-rtl-native branch from 4ab4e41 to 62bab15 Compare July 9, 2026 10:36
@Radwan-Albahrani

Copy link
Copy Markdown
Author

Both good catches — fixed in the latest push.

1. Per-list rtl override on a native LTR tree. Right: RN's left→start swap only fires when the native tree is actually RTL, so a prop-forced rtl list on an app with I18nManager.isRTL === false still needs the JS mirror. The skip is now gated on the global flag:

if (Platform.OS !== "web" && I18nManager.isRTL) {
    return logicalPosition; // native tree is RTL → RN already mirrored
}
return Math.max(0, listSize - logicalPosition - itemSize); // web, or prop-forced RTL on an LTR native tree

2. Negative web scroll offsets. Right: the deterministic pin should be native-only. toLogicalHorizontalOffset now branches — native pins inverted (bounce clamped), and web keeps the original adaptive classification unchanged, including the negative path for RTL scroll roots. So the web ScrollView-root case you described is untouched.

Added regression tests for both (prop-forced native mirror; web negative offset), plus the pinned-mode-across-frames case. Full suite green (1460), biome + tsc clean.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Horizontal list renders blank in a native-RTL app (I18nManager.isRTL), Android

1 participant