From 327c9af1da22b23089e36055e8c1fd60651d3ed3 Mon Sep 17 00:00:00 2001 From: PollyGlot Date: Fri, 7 Aug 2026 10:35:39 +0200 Subject: [PATCH 1/2] fix(mobile): land the first thread open above the composer on Android On the non-automatic-inset path (Android, pre-glass iOS) the composer overlay only exists as the keyboard integration's animated bottom padding, which LegendList's scroll math cannot see. The imperative reportContentInset issued on the empty->filled remount raced the list's initial scroll-to-end, so the very first (uncached) open of a long thread could rest one composer-height low, hiding the last lines behind the text field. Declare the estimated overlay height through contentInsetEndStaticAdjustment instead, so the initial scroll math includes it from construction, and drop the racy imperative report. Co-Authored-By: Claude Fable 5 --- .../src/features/threads/ThreadFeed.tsx | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index db7fecf64ff..b3dffc052ea 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -12,7 +12,6 @@ import { useCallback, useContext, useEffect, - useLayoutEffect, useMemo, useRef, useState, @@ -1536,20 +1535,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ], ); - // The empty↔filled key below remounts the list, which resets its imperative - // content-inset override — and useKeyboardChatComposerInset (mounted above - // the remount boundary) deduplicates by height, so it never re-reports the - // composer inset to the fresh instance. Without this, the remounted list's - // initial scroll-to-end computes with a zero end inset and rests one - // composer-height short of the end. Layout effect: it must land before the - // list's first positioning tick or the one-shot initial scroll misses it. const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`; - useLayoutEffect(() => { - const bottom = props.contentInsetEndAdjustment.value; - if (bottom > 0) { - props.listRef.current?.reportContentInset({ bottom }); - } - }, [listMountKey, props.contentInsetEndAdjustment, props.listRef]); const anchoredEndSpace = useMemo( () => @@ -1846,7 +1832,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // under-reports the composer inset by this amount (see // ThreadDetailScreen); this tells LegendList's scroll math about the // extra so programmatic end scrolls land at the true resting offset. - contentInsetEndStaticAdjustment={usesNativeAutomaticInsets ? insets.bottom : 0} + // Without automatic insets (Android, pre-glass iOS) the composer + // overlay only exists as the keyboard integration's animated bottom + // padding, which the scroll math cannot see either — declare the + // estimated overlay height here so the initial scroll-at-end lands + // above the composer on the very first (uncached) thread open, + // instead of racing an imperative reportContentInset after mount. + contentInsetEndStaticAdjustment={ + usesNativeAutomaticInsets ? insets.bottom : bottomContentInset + } // The keyboard integration's offset math (end pinning, max scroll) // must add the same UIKit-added extra, or its keyboard-open end // targets land one safe-area short of the true resting offset. From 743c05c889d466fd51bf306d23fbf6325457ada4 Mon Sep 17 00:00:00 2001 From: PollyGlot Date: Fri, 7 Aug 2026 11:40:54 +0200 Subject: [PATCH 2/2] fix(mobile): address review findings on the Android end-inset floor Restore the measured reportContentInset re-report on the empty->filled remount (it carries pending approval / user-input card heights the estimate misses) and drop the static-adjustment change that would have double-counted once the keyboard integration reports its own inset. The initial-scroll floor now rides LegendList's declarative contentInset prop instead, Android-only: it is consumed by JS scroll math alone there (Android's ScrollView has no native contentInset) and the first reported override replaces it rather than adding to it. Co-Authored-By: Claude Fable 5 --- .../src/features/threads/ThreadFeed.tsx | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b3dffc052ea..30e74a0e60f 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -12,6 +12,7 @@ import { useCallback, useContext, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -1535,7 +1536,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ], ); + // The empty↔filled key below remounts the list, which resets its imperative + // content-inset override — and useKeyboardChatComposerInset (mounted above + // the remount boundary) deduplicates by height, so it never re-reports the + // composer inset to the fresh instance. Re-report the measured overlay height + // (composer plus any pending approval / user-input card) so the remounted + // list's scroll math gets the true value; on Android the declarative + // contentInset floor below covers the window before this effect lands. const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`; + useLayoutEffect(() => { + const bottom = props.contentInsetEndAdjustment.value; + if (bottom > 0) { + props.listRef.current?.reportContentInset({ bottom }); + } + }, [listMountKey, props.contentInsetEndAdjustment, props.listRef]); const anchoredEndSpace = useMemo( () => @@ -1832,15 +1846,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // under-reports the composer inset by this amount (see // ThreadDetailScreen); this tells LegendList's scroll math about the // extra so programmatic end scrolls land at the true resting offset. - // Without automatic insets (Android, pre-glass iOS) the composer - // overlay only exists as the keyboard integration's animated bottom - // padding, which the scroll math cannot see either — declare the - // estimated overlay height here so the initial scroll-at-end lands - // above the composer on the very first (uncached) thread open, - // instead of racing an imperative reportContentInset after mount. - contentInsetEndStaticAdjustment={ - usesNativeAutomaticInsets ? insets.bottom : bottomContentInset - } + contentInsetEndStaticAdjustment={usesNativeAutomaticInsets ? insets.bottom : 0} + // Android: the composer overlay only exists as the keyboard + // integration's animated bottom padding, which the list's scroll + // math cannot see until the inset reports above land — and those + // arrive via runOnJS, racing the remounted list's one-shot initial + // scroll-at-end. Seed the estimated overlay height as a declarative + // contentInset floor: LegendList consumes it in JS math only + // (Android's ScrollView has no native contentInset prop) and the + // first reported override REPLACES it instead of adding to it. + // Not on iOS: there the prop would reach UIKit and inset natively + // on top of the animated padding. + {...(Platform.OS === "android" && !usesNativeAutomaticInsets + ? { contentInset: { bottom: bottomContentInset } } + : {})} // The keyboard integration's offset math (end pinning, max scroll) // must add the same UIKit-added extra, or its keyboard-open end // targets land one safe-area short of the true resting offset.