Skip to content

[Due for payment 2026-08-10] Composer hidden behind keyboard when app opens fresh into a chat #97295

Closed

Description

@yuwenmemon

If you haven't already, check out our contributing guidelines for onboarding. To join our Slack channel, fill out this form.


Version Number: 9.4.44-6
Reproducible in staging?: Yes
Reproducible in production?: Yes
If this was caught during regression testing, add the test name, ID and link from BrowserStack: N/A
Email or phone of affected tester (no customers): yuwen@expensify.com
Logs: https://stackoverflow.com/c/expensify/questions/4856
Expensify/Expensify Issue URL:
Issue reported by: Yuwen
Slack conversation (hyperlinked to channel name):

Action Performed:

  1. Force-quit the New Expensify iOS app so it's not running in the background.
  2. Reopen the app from the home screen. The app loads directly into a chat (e.g. Concierge/Chronos) rather than the Inbox (LHN).
  3. Tap the message composer text input at the bottom of the screen.

Expected Result:

The keyboard opens and pushes the composer up so it remains visible above the keyboard, with the cursor visible for typing.

Actual Result:

The keyboard opens but the composer does not move up — it's hidden behind the keyboard, so there's no visible input to type into. Backing out to the Inbox (LHN), reopening the same chat, and tapping the composer again shows the composer correctly above the keyboard.

Workaround:

Navigate back to the Inbox (LHN), tap the chat again, then tap the composer — it then appears above the keyboard correctly.

Platforms:

Select the officially supported platforms where the issue was reproduced:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome / Safari

Screenshots/Videos

Image
Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Jul 28, 2026
  2. MelvinBot commented on Jul 28, 2026

    @MelvinBot
    Contributor

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On iOS, when the app cold-starts directly into a chat (force-quit → reopen restores the last-visited report, bypassing the LHN), tapping the composer opens the keyboard but the composer is not pushed above it — so it's hidden behind the keyboard. Going back to the LHN and reopening the chat fixes it.

    What is the root cause of that problem?

    The composer's keyboard avoidance is gated on the report being the "topmost" one:

    src/pages/inbox/ReportScreen.tsx:84 / :121

    const isTopMostReportId = currentReportIDValue === reportIDFromRoute;
    ...
    shouldEnableKeyboardAvoidingView={isTopMostReportId || isInNarrowPaneModal}
    

    currentReportIDValue comes from the useCurrentReportID context, which initializes to '' (useCurrentReportID.tsx:56) and is only ever populated by updateCurrentReportID, which is called exclusively from the NavigationContainer's onStateChange handler (NavigationRoot.tsx:278, wired at :304).

    React Navigation does not fire onStateChange for the initial state. On a cold start the report is mounted from initialState (NavigationRoot.tsx:141), so updateCurrentReportID never runs, currentReportID stays '', and isTopMostReportId is false. Since a normal report isn't a narrow-pane modal (isInNarrowPaneModal is false), shouldEnableKeyboardAvoidingView is false → the KeyboardAvoidingView returns an empty animated style and the composer is never lifted. Navigating back through the LHN triggers a real state change, onStateChange fires, and the flag flips to true — which is why the workaround works.

    onReadyWithSentry (NavigationRoot.tsx:286) runs once the container is ready but does not seed currentReportID, so nothing closes this gap on cold start.

    This is an App-side bug, not a react-native-keyboard-controller issue — enabled={false} correctly produces no offset.

    What changes do you think we should make in order to solve the problem?

    Seed currentReportID from the restored navigation state once the container is ready. In onReadyWithSentry (NavigationRoot.tsx:286), call updateCurrentReportID(navigationRef.getRootState()). This populates the topmost reportID for cold-start deep links (the case onStateChange misses), making isTopMostReportId correct on the very first report and fixing every other useCurrentReportID consumer that also sees '' on cold start.

    Alternative (more localized): make the gate tolerant of an un-initialized value in ReportScreen.tsx:84, e.g. treat empty currentReportIDValue as topmost, or derive the topmost report directly from Navigation.getTopmostReportId(navigationRef.getRootState()). The NavigationRoot fix is preferred since it addresses the root cause for all consumers.

    Confidence & verification

    High confidence — deterministic client-side state/timing bug, fully traced in code (all line references verified against SHA 7af4726). No production logs needed. A quick runtime confirmation: log currentReportIDValue / isTopMostReportId in ReportScreen on first render after a cold-start deep link — expect '' / false on the first report and true after an LHN round-trip.

    No open/recent duplicate issues found for this specific cold-start keyboard-avoidance behavior.


    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/97295) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.

  3. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Jul 28, 2026
  4. melvin-bot commented on Jul 28, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @bernhardoj (External)

  5. melvin-bot commented on Jul 28, 2026

    @melvin-bot
  6. bernhardoj commented on Jul 29, 2026

    @bernhardoj
    Contributor

    @MelvinBot can you propose an alternative solution that initializes the currentReportID state with the initialState, so that we won't have an extra re-render, if that's possible? Provide a code snippet if necessary. Let me know if you have any doubt with that approach and prefer your own solution.

  7. MelvinBot commented on Jul 29, 2026

    @MelvinBot
    Contributor

    Good instinct — and yes, seeding it up front is better than my onReady suggestion, because the onReady approach flips isTopMostReportId from false→true after the first render (and inside a startTransition), leaving a brief window where the composer's KAV is still disabled. A fast tap in that window can still reproduce the bug. Getting the value right on the first render fixes it more robustly and avoids the extra re-render.

    But I do have a doubt with literally seeding the useState from initialState, and it's an ordering problem:

    • CurrentReportIDContextProvider is mounted in App.tsx:114 — above Expensify.tsx → NavigationRoot.
    • initialState is computed inside NavigationRoot (NavigationRoot.tsx:141) from lastVisitedPath (an Onyx value read in Expensify.tsx:67) and initialUrl — and NavigationRoot is only mounted after the initial URL resolves.

    So when the provider's useState('') initializer runs (useCurrentReportID.tsx:56), initialState doesn't exist yet and lastVisitedPath isn't guaranteed hydrated. Because the provider is an ancestor of where initialState is computed, you can't feed that value into its initializer without either plumbing it upward from a descendant (awkward inversion) or duplicating the whole initialState derivation in the provider with a synchronous Onyx read that isn't reliably populated that early. That's the part I wouldn't recommend.

    Preferred alternative — seed the first render from the live nav state (no extra re-render)

    Navigation.getTopmostReportId() already defaults to reading navigationRef.getState() (Navigation.ts:192), and by the time ReportScreen renders, the NavigationContainer has already mounted with initialState. So ReportScreen can fall back to the live nav state whenever the context still holds its '' sentinel — the first render is then already correct, no state flip, no extra re-render:

    // src/pages/inbox/ReportScreen.tsx  (around line 82-84)
    const {currentReportID: currentReportIDValue} = useCurrentReportIDState();
    // On a cold-start deep link the provider still holds its initial '' because
    // onStateChange doesn't fire for the initial navigation state. Fall back to the
    // live navigation state so the first render already knows the topmost report.
    const topmostReportID = currentReportIDValue || Navigation.getTopmostReportId();
    const isTopMostReportId = topmostReportID === reportIDFromRoute;

    Why I prefer this over seeding the state:

    • Correct on the first render → no extra re-render and no window where the KAV is disabled (closes the fast-tap gap the onReady fix leaves open).
    • Localized to ReportScreen.tsx:82-84; no changes to the provider tree or navigation bootstrap.
    • No regression risk — if getTopmostReportId() returns undefined (ref not ready), isTopMostReportId is false, exactly today's behavior.

    If you specifically want the context state itself seeded (so other useCurrentReportID consumers also benefit on cold start), the only clean spot is a lazy initializer that computes the topmost report from the restored path — but that needs initialState/lastVisitedPath available at provider mount, which they aren't, so I'd steer away from it and go with the ReportScreen fallback above.


    Next Steps: Reply with @MelvinBot implement this to create a draft PR with the ReportScreen fallback, or @MelvinBot <your feedback> to refine.

  8. bernhardoj commented on Jul 29, 2026

    @bernhardoj
    Contributor

    @MelvinBot why we don't use Navigation.getTopmostReportId() from the first time it was implemented? Can you check the history first?

  9. MelvinBot commented on Jul 29, 2026

    @MelvinBot
    Contributor

    Good question — and the history actually answers it directly: the code originally did call Navigation.getTopmostReportId() directly here, and it was deliberately switched away from it for reactivity.

    Timeline:

    • b7aa133 (2023-07-17, #23024) — isTopMostReportId / shouldEnableKeyboardAvoidingView gating was first introduced (replacing the old withNavigationFocus isFocused gate). The first version used the direct call:
      const isTopMostReportId = Navigation.getTopmostReportId() === getReportID(this.props.route);
    • 0665ea0 (2023-08-31, #26283) — swapped the direct call for the reactive context value:
      - const isTopMostReportId = Navigation.getTopmostReportId() === getReportID(route);
      + const isTopMostReportId = currentReportID === getReportID(route);
      That PR was titled "Fix the composer being hidden by the keyboard by subscribing to top most report changes in ReportScreen" and fixed #24555. Its rationale: "When we go to child report, the top most report changes which disables the keyboard avoiding view but we never enabled it back."

    So the reason is reactivity, and it's documented (not inferred). Navigation.getTopmostReportId() is a plain function call evaluated once at render — it does not re-render ReportScreen when the topmost report changes. So the composer's KAV would get stuck disabled after navigating between reports. The useCurrentReportID context is just a reactive cache of getTopmostReportId(state) (see useCurrentReportID.tsx:87, where it does setCurrentReportID(Navigation.getTopmostReportId(state)) from onStateChange) — updating it re-renders every consumer, which is exactly what was needed.

    Why this matters for the fix here: it means the "alternative" I floated earlier — reading Navigation.getTopmostReportId(navigationRef.getRootState()) directly in ReportScreen — would reintroduce #24555. We should keep the reactive context value and instead fix the initialization gap: seed currentReportID on cold start by calling updateCurrentReportID(navigationRef.getRootState()) in onReadyWithSentry (NavigationRoot.tsx:286). That keeps reactivity intact and closes the one case onStateChange never covers (the restored initial state).

    Additional history
    • Navigation.getTopmostReportId itself predates all of this — added in 58fbc77 (2023-03-31), so it was available when isTopMostReportId was first written; the direct call was the original approach.
    • The currentReportID context started as the withCurrentReportId HOC (#16581, 2023-04), became a hook (#20663), dropped the HOC in ReportScreen (#54702), and was refactored again in Jan 2026. Throughout, it has always been a reactive wrapper around getTopmostReportId(state).
    • No inline code comment records this "why" today — the rationale lives in #26283 / #24555.
  10. bernhardoj commented on Jul 29, 2026

    @bernhardoj
    Contributor

    Let's go with Melvin's main solution

    🎀 👀 🎀 C+ reviewed

  11. melvin-bot commented on Jul 29, 2026

    @melvin-bot

    Triggered auto assignment to @chuckdries, see https://stackoverflow.com/c/expensify/questions/7972 for more details.

  12. chuckdries commented on Jul 29, 2026

    @chuckdries
    Contributor

    Sounds reasonable to me! But you need to post the @MelvinBot implement this instruction. If I give any instructions to melvin, my review won't count and you'll have to find another reviewer.

  13. 14 remaining items

  14. bernhardoj commented on Aug 5, 2026

    @bernhardoj
    Contributor

    BugZero Checklist:

    • [Contributor] The offending PR and associated issue have been commented on, pointing out the bug it caused and why, so the author and reviewers can learn from the mistake.

      Link to the comment on the PR: N/A
      Link to the comment on the Issue: N/A

    Not really a regression. This issue most likely started to happen after switching from non-reactive topmost report ID to the reactive version. The root cause where the reactive version's initial value is not initialized has been happening since it's implemented.

    • [Contributor] If the regression was CRITICAL (e.g. interrupts a core flow) A discussion in #expensify-open-source has been started about whether any other steps should be taken (e.g. updating the PR review checklist) in order to catch this type of bug sooner.

      Link to discussion: N/A

    • [Contributor] If it was decided to create a regression test for the bug, please propose the regression test steps using the template below to ensure the same bug will not reach production again.

    • [BugZero Assignee] Create a GH issue for creating/updating the regression test once above steps have been agreed upon.

      Link to issue: https://github.com/Expensify/Expensify/issues/670801

    Regression Test Proposal

    Precondition:

    • Use a private-domain email account

    Test:

    iOS/Android only

    1. Open any report
    2. Close and reopen the app. It should open the report from step 1
    3. Press the composer
    4. Verify the composer is pushed above the keyboard

    Do we agree 👍 or 👎

    Zapier Logs Run ID: 00040eee-0401-a8c6-9bf8-6b1fb0526044
  15. added
    Awaiting PaymentAuto-added when associated PR is deployed to production
    and removed on Aug 9, 2026
  16. melvin-bot commented on Aug 9, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  17. melvin-bot commented on Aug 9, 2026

    @melvin-bot

    Payment Summary

    Resolving PRs:

    BugZero Checklist (@mallenexpensify)

    • I have confirmed assignees, roles, and Upwork contracts look correct
    • I have paid out Upwork contracts / manual NewDot requests
  18. mallenexpensify commented on Aug 12, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor+: @bernhardoj due $250 via NewDot

  19. flaviadefaria commented on Aug 21, 2026

    @flaviadefaria
    Contributor

    Approved: @bernhardoj due $250 via NewDot

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

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions