Skip to content

Transcript: first keyboard-open after a session switch leaves a safe-area-sized gap above the composer (KCSV mount scroll-tracker race) #22

@yyq1025

Description

@yyq1025

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

  1. Open a session, open the keyboard, close it.
  2. Switch to another session via the drawer.
  3. Open the keyboard in the second session → ~34px blank gap above the composer.
  4. 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.jscomputeIOSContentOffset) 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

  • File the upstream react-native-keyboard-controller issue, ideally with a patch-package probe printing KCSV's internal offset.value at keyboard onStart (expected: stale by insets.bottom on the first open of a non-first mount, refreshed after one keyboard cycle).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions