fix(mobile): land the first thread open above the composer on Android#5585

merged4
main
GitHub fork: t3code/android-thread-initial-scroll
openedAug 7, 2026byPPollyGlot
View on GitHubTwo-way sync enabledPR last imported 20d ago

Summary

On Android (and pre-glass iOS), the very first open of a thread whose messages are not yet in the client cache can rest with its last lines hidden behind the composer text field. Once the thread has been opened before (messages cached), the same open lands correctly, which is why this mostly bites right after a cold app launch.

Root cause: on the non-automatic-inset path the composer overlay only exists as the keyboard integration's animated bottom padding, which LegendList's scroll math cannot see. The screen compensated with an imperative reportContentInset on the empty→filled remount, but that call races the list's one-shot initial scroll-to-end; when it loses, the initial position is computed with a zero end inset and rests exactly one composer-height low.

Fix: declare the estimated composer overlay height through the existing contentInsetEndStaticAdjustment prop (the same channel iOS already uses for UIKit's safe-area overcount), so the scroll math knows about the overlay from construction. The racy imperative report becomes redundant and is removed. Net: 9 insertions, 15 deletions, one file.

Before / After

First open of an uncached long thread, right after a cold launch (left: last lines and timestamp rest behind the composer; right: they land above it):

BeforeAfter
beforeafter

Test plan

  • Reproduced on a Pixel emulator (API 35): cold launch → first open of an uncached long thread rests one composer-height low; reopening the same thread (cached) lands correctly.
  • With the fix: first open lands above the composer, including cold launch and a deliberately slowed server (HTTP snapshot timeout → socket fallback path).
  • No double inset: resting gap between the last message and the composer is unchanged (the static adjustment only feeds scroll math on native; the visual spacer exists only on web).
  • Keyboard behavior intact: focusing the composer keeps the thread end anchored above the expanded composer; closing it restores the resting position.
  • tsc --noEmit, vp lint, vp fmt clean.

Related: #5582 fixes the separate issue that Android renders no backdrop gradient, so text scrolling behind the composer stayed fully legible. The two are independent.

🤖 Generated with Claude Code


Note

Low Risk Scoped to Android thread feed scroll/inset behavior with unit tests; iOS path explicitly unchanged to avoid native double-inset.

Overview Fixes Android thread feeds where the first open (especially uncached / cold launch) could scroll to the end with the last messages hidden behind the composer, because LegendList’s end-inset math ran before the keyboard integration reported overlay height.

Adds deriveThreadFeedInitialContentInset in layout.ts and wires it in ThreadFeed: on Android (when not using iOS-style automatic insets), passes a declarative contentInset.bottom equal to the estimated composer / bottom overlay height so initial scrollAtEnd uses that floor immediately. The prop is omitted on iOS so UIKit is not double-inset. Comments clarify that the existing remount reportContentInset still syncs measured height after empty→filled transitions, while the declarative inset covers the race on Android.

Reviewed by Cursor Bugbot for commit 8fe2ade17853061ad1327cd86af0f106b67e2a0e. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Fix first thread opening above the composer on Android

On Android, the first thread in a feed was rendering behind the composer overlay because LegendList had no bottom content inset to account for it. Adds deriveThreadFeedInitialContentInset in layout.ts to compute a bottom inset equal to the estimated composer height on Android (when native automatic insets are not used), and passes it as contentInset to LegendList in ThreadFeed.tsx. On iOS with native automatic insets, the prop is omitted.

Macroscope summarized 8fe2ade.

Synced from GitHub

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 10d7bcef-f0d5-4d07-b7eb-02c966710f19

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

apps/mobile/src/features/threads/ThreadFeed.tsxline 1893Outdated · 9948873Open thread
……@@ -1898,7 +1884,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1890+ // estimated overlay height here so the initial scroll-at-end lands
1891+ // above the composer on the very first (uncached) thread open,
1892+ // instead of racing an imperative reportContentInset after mount.
1893+ contentInsetEndStaticAdjustment={
MmacroscopeappBoton line 1893Aug 7, 2026

🟠 High threads/ThreadFeed.tsx:1893

Removing the useLayoutEffect that called reportContentInset breaks the only synchronous handoff of the measured composer overlay height to a freshly remounted list. When an empty thread becomes filled and listMountKey remounts KeyboardAwareLegendList, useKeyboardChatComposerInset never re-reports its already-measured height (it deduplicates by height), so the new list runs initialScrollAtEnd without the composer inset. The replacement contentInsetEndStaticAdjustment={usesNativeAutomaticInsets ? insets.bottom : bottomContentInset} only accounts for the safe-area bottom or the estimated chrome height — neither includes the height of an activePendingApproval or activePendingUserInput card that useKeyboardChatComposerInset actually measures. On a cold open with a pending-action card, the initial end-scroll math undercounts the overlay by that card height, leaving the final messages positioned behind the card. Consider restoring the useLayoutEffect that reports contentInsetEndAdjustment.value on listMountKey changes so the remounted list receives the true measured overlay height before its first positioning tick.

✅ Resolved in 9ed20ff1f13309da84c58f75e915935fd539d637

Fixed in 9ed20ff1f. The measured reportContentInset re-report on the empty→filled remount is restored, so pending approval / user-input card heights measured by useKeyboardChatComposerInset reach the remounted list again. The initial-scroll floor no longer replaces it: it now rides LegendList's declarative contentInset prop (Android-only), which the first reported override replaces rather than adds to.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/mobile/src/features/threads/ThreadFeed.tsxline 1895Outdated · 9948873Open thread
……@@ -1898,7 +1884,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1892+ // instead of racing an imperative reportContentInset after mount.
1893+ contentInsetEndStaticAdjustment={
1894+ usesNativeAutomaticInsets ? insets.bottom : bottomContentInset
1895+ }
Ccursor[bot]on line 1895Aug 7, 2026

Double-counted Android end inset

Medium Severity

On non-automatic inset paths, LegendList's scroll math incorrectly doubles the composer height for the end inset. This happens because the bottomContentInset is now explicitly set as contentInsetEndStaticAdjustment while also being dynamically reported via reportContentInset from contentInsetEndAdjustment. This causes end pinning and maintain-scroll targets to be misaligned.

Reviewed by Cursor Bugbot for commit 9948873defde0f94c7fb28ba2ac3601e25fce286. Configure here.

Fixed in 9ed20ff1f. contentInsetEndStaticAdjustment is back to usesNativeAutomaticInsets ? insets.bottom : 0, so nothing is added on top of the reported override anymore. The boot floor moved to LegendList's contentInset prop (Android-only): in getContentInsetEnd the reported override spreads over the base inset and replaces it, so end pinning and maintain-scroll targets use exactly one inset. Re-verified on emulator: first uncached open lands above the composer (including a delayed-connection open), resting gap unchanged, keyboard anchoring intact.

Synced from GitHub

Approvability

Verdict: Approved 88e38bd

Self-contained Android-specific bug fix for thread scroll positioning with clear logic, unit tests, and platform guards. The author has recent history with this code area, and the open review comment has been substantively addressed.

You can customize Macroscope's approvability policy. Learn more.

Ccursor[bot]reviewed · 1 inline threadAug 7, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9ed20ff1f13309da84c58f75e915935fd539d637. Configure here.

apps/mobile/src/features/threads/ThreadFeed.tsxline 1914Outdated · 9ed20ffOpen thread
……@@ -1899,6 +1899,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1911+ // on top of the animated padding.
1912+ {...(Platform.OS === "android" && !usesNativeAutomaticInsets
1913+ ? { contentInset: { bottom: bottomContentInset } }
1914+ : {})}
Ccursor[bot]on line 1914Aug 7, 2026

Pre-glass iOS loses inset floor

Medium Severity

contentInsetEndStaticAdjustment no longer seeds bottomContentInset when automatic insets are off, and the new declarative contentInset seed is Android-only. Pre-glass iOS (usesAutomaticContentInsets false) is left on the remount reportContentInset path alone, which this PR describes as racing the one-shot initial scroll-to-end—so the first open of an uncached thread can still rest one composer-height low there.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 9ed20ff1f13309da84c58f75e915935fd539d637. Configure here.

Pre-glass iOS does not need the floor: this blind spot is Android-specific. In react-native-keyboard-controller's ScrollViewWithBottomPadding, the animated bottom padding is written as the native iOS contentInset prop (animatedProps.contentInset = effective), so UIKit accounts for it during attach and in the scrollable range, and LegendList reads it back as nativeContentInset from scroll events (its base end inset when no contentInset prop is set). Only Android uses the synthetic contentInsetBottom path — the library's own onContentInsetChange doc says it exists because on Android the synthetic inset is not reflected in onScroll events. That is also why the floor must not be passed on iOS: effective adds the contentInset prop on top of the dynamic padding there, which would double the native inset. Pre-glass iOS behavior is byte-for-byte the same as main (remount re-report only), and the original bug never reproduced on iOS.

Added 4 commits

  • fix(mobile): land the first thread open above the composer on Androidd36fa31
  • fix(mobile): address review findings on the Android end-inset floor4812e67
  • test(mobile): cover Android initial thread insetab93401
  • Merge branch 'main' into t3code/android-thread-initial-scroll8fe2ade