Summary
On a non-first ChatPanel mount (e.g. after switching sessions via the drawer), the first time the keyboard opens the transcript over-scrolls and leaves a blank gap of exactly insets.bottom (~34px on the test device) above the composer. It self-heals on the 2nd keyboard open. Present in TestFlight production. This is not a regression from a single recent dependency bump — see Investigation.
Repro
- Open a session, open the keyboard, close it.
- Switch to another session via the drawer.
- Open the keyboard in the second session → ~34px blank gap above the composer.
- Close and reopen the keyboard → gap gone.
The very first ChatPanel mount after launch does not show it; only mounts after a switch do.
Root cause
ChatPanel (packages/app/src/components/transcript/chat-panel.tsx) renders a KeyboardAwareLegendList (@legendapp/list/keyboard), whose inner scroll component is react-native-keyboard-controller's KeyboardChatScrollView (KCSV). KCSV computes its keyboard lift in a worklet (useChatKeyboard/index.ios.js → computeIOSContentOffset) from its own internal scroll SharedValue (useScrollState's offset), which is updated only by native scroll events.
On a fresh mount, initialScrollAtEnd settles the list at the bottom before the asynchronous contentInset commit lands, so the last scroll event KCSV saw carried a position offset by the safe area; at rest no further scroll event fires to refresh it. The first keyboard-open lift then reads that stale value and lands insets.bottom past the true content end. A keyboard open/close cycle emits fresh scroll events, so the 2nd open is correct.
The library formula is correct — only that one input (scroll.value) is transiently stale — so there is no clean prop/config workaround and no clean upstream one-liner.
Evidence (on-device probe)
over = scroll − (contentLength − scrollLength) (positive ⇒ blank space above composer):
Non-first mount (buggy):
willShow#1 over=-319 (start = correct closed end)
didShow#1 over=+34 (= insets.bottom; lift overshoots)
rest+open#1 over=+34
willShow#2 over=-319
didShow#2 over=0 (self-healed)
First-ever mount: over=0 throughout. The overshoot equals insets.bottom exactly (safeB=34 == over=34) and is independent of transcript length (a 191-block / 23k-px transcript overshoots the same 34 as a short one) — i.e. it is a mount warm-up race, not a short-content lift issue.
Investigation — version bisect
- react-native-keyboard-controller
1.21.9 → 1.21.11: exonerated. Release notes touch KeyboardAwareScrollView animation / KCSV unmount crash / RN 0.87 compile / Android — not the KCSV scroll-lift math.
@legendapp/list 3.0.2 ↔ 3.0.4: exonerated. Reproduces at both 3.0.2 and 3.0.4 (verified by pinning).
- The latent KCSV race has existed since we adopted
KeyboardAwareLegendList (@legendapp/list 3.0.0). It was most likely surfaced by recent mount-timing changes — the drawer-settle gate deferring the ChatPanel mount past the drawer-close animation, and the chunked renderer growing content asynchronously (shiki) after mount — both of which shift the initialScrollAtEnd vs async contentInset commit timing.
Related upstream
Candidate fixes (deferred — currently accepting the bug)
- A. One-shot
scrollToEnd({ animated }) on the first keyboardDidShow when at-end. Works (the keyboard has grown the content so the end genuinely moved → scrollToEnd re-dispatches a native scroll, fixing both the visible position and the stale tracker). Cost: a visible ~34px settle, once per mount. Rejected for now on UX grounds.
- B. File an upstream rnkc issue (with the internal
offset.value proof) for a proper fix in KCSV's mount warm-up.
Currently accepted as-is (pre-existing, self-heals on the 2nd open).
Environment
- react-native-keyboard-controller 1.21.11
- @legendapp/list 3.0.4
- react-native 0.85.3, react-native-reanimated 4.3.1, New Architecture (Fabric), iOS
TODO
Summary
On a non-first
ChatPanelmount (e.g. after switching sessions via the drawer), the first time the keyboard opens the transcript over-scrolls and leaves a blank gap of exactlyinsets.bottom(~34px on the test device) above the composer. It self-heals on the 2nd keyboard open. Present in TestFlight production. This is not a regression from a single recent dependency bump — see Investigation.Repro
The very first
ChatPanelmount after launch does not show it; only mounts after a switch do.Root cause
ChatPanel(packages/app/src/components/transcript/chat-panel.tsx) renders aKeyboardAwareLegendList(@legendapp/list/keyboard), whose inner scroll component is react-native-keyboard-controller'sKeyboardChatScrollView(KCSV). KCSV computes its keyboard lift in a worklet (useChatKeyboard/index.ios.js→computeIOSContentOffset) from its own internal scrollSharedValue(useScrollState'soffset), which is updated only by native scroll events.On a fresh mount,
initialScrollAtEndsettles the list at the bottom before the asynchronouscontentInsetcommit lands, so the last scroll event KCSV saw carried a position offset by the safe area; at rest no further scroll event fires to refresh it. The first keyboard-open lift then reads that stale value and landsinsets.bottompast the true content end. A keyboard open/close cycle emits fresh scroll events, so the 2nd open is correct.The library formula is correct — only that one input (
scroll.value) is transiently stale — so there is no clean prop/config workaround and no clean upstream one-liner.Evidence (on-device probe)
over = scroll − (contentLength − scrollLength)(positive ⇒ blank space above composer):Non-first mount (buggy):
First-ever mount:
over=0throughout. The overshoot equalsinsets.bottomexactly (safeB=34 == over=34) and is independent of transcript length (a 191-block / 23k-px transcript overshoots the same 34 as a short one) — i.e. it is a mount warm-up race, not a short-content lift issue.Investigation — version bisect
1.21.9 → 1.21.11: exonerated. Release notes touchKeyboardAwareScrollViewanimation / KCSV unmount crash / RN 0.87 compile / Android — not the KCSV scroll-lift math.@legendapp/list 3.0.2 ↔ 3.0.4: exonerated. Reproduces at both 3.0.2 and 3.0.4 (verified by pinning).KeyboardAwareLegendList(@legendapp/list3.0.0). It was most likely surfaced by recent mount-timing changes — the drawer-settle gate deferring theChatPanelmount past the drawer-close animation, and the chunked renderer growing content asynchronously (shiki) after mount — both of which shift theinitialScrollAtEndvs asynccontentInsetcommit timing.Related upstream
contentInsetcommit; symptom "last message hidden behind composer"). Tested via patch-package: does NOT fix our overshoot — it only swallows the mount{0,0}contentOffset clobber, leaving the keyboardonStartlift path (where our overshoot is computed) untouched. fix: don't emit contentOffset {0,0} on the first animatedProps evaluation in ScrollViewWithBottomPadding kirillzyusko/react-native-keyboard-controller#1496maintainVisibleContentPosition+ keyboard-open jump inside a navigator (closest existing report). KeyboardChatScrollView content jumps on keyboard open when used with maintainVisibleContentPosition inside TabNavigator kirillzyusko/react-native-keyboard-controller#1397Candidate fixes (deferred — currently accepting the bug)
scrollToEnd({ animated })on the firstkeyboardDidShowwhen at-end. Works (the keyboard has grown the content so the end genuinely moved →scrollToEndre-dispatches a native scroll, fixing both the visible position and the stale tracker). Cost: a visible ~34px settle, once per mount. Rejected for now on UX grounds.offset.valueproof) for a proper fix in KCSV's mount warm-up.Currently accepted as-is (pre-existing, self-heals on the 2nd open).
Environment
TODO
offset.valueat keyboardonStart(expected: stale byinsets.bottomon the first open of a non-first mount, refreshed after one keyboard cycle).