You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Due for payment 2026-08-10] Composer hidden behind keyboard when app opens fresh into a chat #97295
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:
Force-quit the New Expensify iOS app so it's not running in the background.
Reopen the app from the home screen. The app loads directly into a chat (e.g. Concierge/Chronos) rather than the Inbox (LHN).
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:
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:
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.
@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.
Good instinct — and yes, seeding it up front is better than my onReady suggestion, because the onReady approach flips isTopMostReportId from false→trueafter 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 — aboveExpensify.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.consttopmostReportID=currentReportIDValue||Navigation.getTopmostReportId();constisTopMostReportId=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.
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 withNavigationFocusisFocused gate). The first version used the direct call:
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.
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.
[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.
Activity
MelvinBot commentedon Jul 28, 2026
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/:121currentReportIDValuecomes from theuseCurrentReportIDcontext, which initializes to''(useCurrentReportID.tsx:56) and is only ever populated byupdateCurrentReportID, which is called exclusively from the NavigationContainer'sonStateChangehandler (NavigationRoot.tsx:278, wired at:304).React Navigation does not fire
onStateChangefor the initial state. On a cold start the report is mounted frominitialState(NavigationRoot.tsx:141), soupdateCurrentReportIDnever runs,currentReportIDstays'', andisTopMostReportIdisfalse. Since a normal report isn't a narrow-pane modal (isInNarrowPaneModalisfalse),shouldEnableKeyboardAvoidingViewisfalse→ theKeyboardAvoidingViewreturns an empty animated style and the composer is never lifted. Navigating back through the LHN triggers a real state change,onStateChangefires, and the flag flips totrue— which is why the workaround works.onReadyWithSentry(NavigationRoot.tsx:286) runs once the container is ready but does not seedcurrentReportID, so nothing closes this gap on cold start.This is an App-side bug, not a
react-native-keyboard-controllerissue —enabled={false}correctly produces no offset.What changes do you think we should make in order to solve the problem?
Seed
currentReportIDfrom the restored navigation state once the container is ready. InonReadyWithSentry(NavigationRoot.tsx:286), callupdateCurrentReportID(navigationRef.getRootState()). This populates the topmost reportID for cold-start deep links (the caseonStateChangemisses), makingisTopMostReportIdcorrect on the very first report and fixing every otheruseCurrentReportIDconsumer 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 emptycurrentReportIDValueas topmost, or derive the topmost report directly fromNavigation.getTopmostReportId(navigationRef.getRootState()). TheNavigationRootfix 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: logcurrentReportIDValue/isTopMostReportIdinReportScreenon first render after a cold-start deep link — expect''/falseon the first report andtrueafter 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.
melvin-bot commentedon Jul 28, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @bernhardoj (
External)melvin-bot commentedon Jul 28, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022082245567277821698
bernhardoj commentedon Jul 29, 2026
@MelvinBot can you propose an alternative solution that initializes the
currentReportIDstate with theinitialState, 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.MelvinBot commentedon Jul 29, 2026
Good instinct — and yes, seeding it up front is better than my
onReadysuggestion, because theonReadyapproach flipsisTopMostReportIdfromfalse→trueafter the first render (and inside astartTransition), 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
useStatefrominitialState, and it's an ordering problem:CurrentReportIDContextProvideris mounted inApp.tsx:114— aboveExpensify.tsx→NavigationRoot.initialStateis computed insideNavigationRoot(NavigationRoot.tsx:141) fromlastVisitedPath(an Onyx value read inExpensify.tsx:67) andinitialUrl— andNavigationRootis only mounted after the initial URL resolves.So when the provider's
useState('')initializer runs (useCurrentReportID.tsx:56),initialStatedoesn't exist yet andlastVisitedPathisn't guaranteed hydrated. Because the provider is an ancestor of whereinitialStateis computed, you can't feed that value into its initializer without either plumbing it upward from a descendant (awkward inversion) or duplicating the wholeinitialStatederivation 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 readingnavigationRef.getState()(Navigation.ts:192), and by the timeReportScreenrenders, theNavigationContainerhas already mounted withinitialState. SoReportScreencan 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:Why I prefer this over seeding the state:
onReadyfix leaves open).ReportScreen.tsx:82-84; no changes to the provider tree or navigation bootstrap.getTopmostReportId()returnsundefined(ref not ready),isTopMostReportIdisfalse, exactly today's behavior.If you specifically want the context state itself seeded (so other
useCurrentReportIDconsumers also benefit on cold start), the only clean spot is a lazy initializer that computes the topmost report from the restored path — but that needsinitialState/lastVisitedPathavailable at provider mount, which they aren't, so I'd steer away from it and go with theReportScreenfallback above.Next Steps: Reply with
@MelvinBot implement thisto create a draft PR with theReportScreenfallback, or@MelvinBot <your feedback>to refine.bernhardoj commentedon Jul 29, 2026
@MelvinBot why we don't use
Navigation.getTopmostReportId()from the first time it was implemented? Can you check the history first?MelvinBot commentedon Jul 29, 2026
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/shouldEnableKeyboardAvoidingViewgating was first introduced (replacing the oldwithNavigationFocusisFocusedgate). The first version used the direct call:0665ea0(2023-08-31, #26283) — swapped the direct call for the reactive context value: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-renderReportScreenwhen the topmost report changes. So the composer's KAV would get stuck disabled after navigating between reports. TheuseCurrentReportIDcontext is just a reactive cache ofgetTopmostReportId(state)(seeuseCurrentReportID.tsx:87, where it doessetCurrentReportID(Navigation.getTopmostReportId(state))fromonStateChange) — 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 inReportScreen— would reintroduce #24555. We should keep the reactive context value and instead fix the initialization gap: seedcurrentReportIDon cold start by callingupdateCurrentReportID(navigationRef.getRootState())inonReadyWithSentry(NavigationRoot.tsx:286). That keeps reactivity intact and closes the one caseonStateChangenever covers (the restored initial state).Additional history
Navigation.getTopmostReportIditself predates all of this — added in58fbc77(2023-03-31), so it was available whenisTopMostReportIdwas first written; the direct call was the original approach.currentReportIDcontext started as thewithCurrentReportIdHOC (#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 aroundgetTopmostReportId(state).bernhardoj commentedon Jul 29, 2026
Let's go with Melvin's main solution
🎀 👀 🎀 C+ reviewed
melvin-bot commentedon Jul 29, 2026
Triggered auto assignment to @chuckdries, see https://stackoverflow.com/c/expensify/questions/7972 for more details.
chuckdries commentedon Jul 29, 2026
Sounds reasonable to me! But you need to post the
@MelvinBot implement thisinstruction. If I give any instructions to melvin, my review won't count and you'll have to find another reviewer.14 remaining items
bernhardoj commentedon Aug 5, 2026
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:
Test:
iOS/Android only
Do we agree 👍 or 👎
Zapier Logs
Run ID: 00040eee-0401-a8c6-9bf8-6b1fb0526044melvin-bot commentedon Aug 9, 2026
Triggered auto assignment to @mallenexpensify (
Awaiting Payment)melvin-bot commentedon Aug 9, 2026
Payment Summary
Resolving PRs:
Fix composer hidden behind keyboard on cold start into a chat #97357
Reviewer: @bernhardoj owed $250 via NewDot
BugZero Checklist (@mallenexpensify)
mallenexpensify commentedon Aug 12, 2026
Payment Summary
Contributor+: @bernhardoj due $250 via NewDot
flaviadefaria commentedon Aug 21, 2026
Approved: @bernhardoj due $250 via NewDot