Skip to content

[$250] Composer Does Not Auto-Focus After Starting a New Chat #88239

Closed

Description

@m-natarajan

If you haven’t already, check out our contributing guidelines for onboarding and email contributors@expensify.com to request to join our Slack channel!


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

Action Performed:

  1. Click FAB → Start Chat
  2. Enter a valid email address
  3. Select the user to initiate the chat
  4. Observe the composer

Expected Result:

The composer input should automatically gain focus

Actual Result:

Composer does not gain focus

Workaround:

Unknown

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

2026-04-17.00.00.44.mov
Recording.2725.mp4

View all open jobs on GitHub

Issue OwnerCurrent Issue Owner: @nkdengineer
Upwork Automation - Do Not Edit
  • Upwork Job URL: https://www.upwork.com/jobs/~022045243176158519497
  • Upwork Job ID: 2045243176158519497
  • Last Price Increase: 2026-04-24

Activity

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

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    The composer has two focus mechanisms, both of which fail during the "Start Chat → navigate to new report" transition:

    1. shouldAutoFocus evaluates to false at mount/render time (ComposerWithSuggestions.tsx:296): this variable depends on areAllModalsHidden(), which checks a non-reactive mutable array in Modal.ts. During the RHP-to-report transition, this can still report modals as present, making shouldAutoFocus false.

    2. The focus-restore useEffect (ComposerWithSuggestions.tsx:827) guards on willBlurTextInputOnTapOutside || shouldAutoFocus. On narrow/touch-capable web layouts, willBlurTextInputOnTapOutside is false (returns !getIsNarrowLayout()) and shouldFocusInputOnScreenFocus is false (returns !canUseTouchScreen()). With no draft for a new chat, shouldAutoFocus is also false — so the effect early-returns and never calls focus().

    Additionally, navigateToReport in Report/index.ts:1955-1973 performs a double navigation (via dismissModal({ afterTransition }) and a setTimeout fallback), which can cause re-renders that further disrupt focus timing.

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

    After the RHP modal dismisses and navigation to the new report completes, explicitly focus the composer using ReportActionComposeFocusManager. The most targeted fix would be in the navigateToReport flow — after the dismissModal transition completes and the report navigation fires, call focusComposerWithDelay to ensure the composer receives focus regardless of the shouldAutoFocus evaluation timing.

    Alternatively, the focus-restore useEffect in ComposerWithSuggestions.tsx could be adjusted to also trigger focus when isFocused transitions from false to true (i.e., when the report screen gains navigation focus after the RHP closes), without requiring willBlurTextInputOnTapOutside to be true.

    What alternative solutions did you explore? (Optional)

    • Making areAllModalsHidden() reactive (e.g., backed by Onyx or React state) — this would be a larger refactor with broader implications.
    • Removing the willBlurTextInputOnTapOutside guard from the focus-restore effect — this could cause unwanted focus behavior on mobile/narrow layouts in other scenarios.
    Investigation details

    Key files:

    Data flow:

    1. User selects a contact in NewChatPage (RHP modal)
    2. selectOption → KeyboardUtils.dismiss() → navigateToAndOpenReport() → navigateToReport()
    3. navigateToReport calls dismissModal({ afterTransition: navigate }) AND setTimeout(navigate)
    4. RHP blur fires → Modal.setModalVisibility(false) via Onyx (async)
    5. Composer mounts/updates — shouldAutoFocus is false because areAllModalsHidden() still returns false during transition
    6. Focus-restore useEffect skips because willBlurTextInputOnTapOutside || shouldAutoFocus is false on narrow/touch web
    7. Result: composer never gets focused

    Possibly related open issue: Expensify/App#86889 — Focus jumps to Concierge Anywhere (SidePanel) when switching chats — also involves composer focus behavior during chat transitions.


    Next Steps for Contributor+ team: Reply with @MelvinBot implement this to create a draft PR, @MelvinBot <your feedback> to refine this analysis, or explain why you are rejecting Melvin's proposal.

  3. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Apr 17, 2026
  4. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on Apr 17, 2026
  5. melvin-bot commented on Apr 17, 2026

    @melvin-bot

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

  6. changed the title [-]Composer Does Not Auto-Focus After Starting a New Chat[/-] [+][$250] Composer Does Not Auto-Focus After Starting a New Chat[/+] on Apr 17, 2026
  7. melvin-bot commented on Apr 17, 2026

    @melvin-bot
  8. JustDreameritis commented on Apr 17, 2026

    @JustDreameritis

    Proposal

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

    After starting a new chat via FAB → Start Chat → selecting a user, the composer input on the newly opened report does not auto-focus on web.

    What is the root cause of that problem?

    When navigating from NewChatPage.tsx to a report (both existing and newly created), Navigation.dismissModal is used. This leaves stale callbacks in the closeModals array in Modal.ts, causing areAllModalsHidden() to return false when the composer mounts. This makes shouldAutoFocus evaluate to false at ComposerWithSuggestions.tsx:296.

    The backup focus-restore useEffect (~line 827) also fails. On wide-screen web, willBlurTextInputOnTapOutside is true, so the willBlurTextInputOnTapOutside || shouldAutoFocus OR-condition passes — but the inner logic only calls focus when there's a draft to restore or shouldFocusComposerOnScreenFocus is true. For a fresh chat with no draft, neither condition holds, so focus is never applied.

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

    The fix is in ComposerWithSuggestions.tsx. The file already has access to modal state via useOnyx(ONYXKEYS.MODAL) (used for the existing modal variable around line 280). In the focus-restore useEffect (~line 827), add a focused-screen condition that triggers focus when the screen gains focus and no modal is actually visible:

    // Inside the existing focus-restore useEffect, add before the return:
    if (isFocused && !modal?.isVisible && !modal?.willAlertModalBecomeVisible && editFocused === false) {
        focusComposerWithDelay(textInputRef.current)();
    }

    To avoid stealing focus when merely returning to an already-open report (critic point #5), gate this with a ref that tracks whether this is the initial focus after mount or a navigation-triggered focus:

    const isInitialFocusRef = useRef(true);
    // In the useEffect:
    if (isFocused && isInitialFocusRef.current && !modal?.isVisible && !modal?.willAlertModalBecomeVisible && editFocused === false) {
        isInitialFocusRef.current = false;
        focusComposerWithDelay(textInputRef.current)();
    }

    This ensures focus fires once on initial mount/navigation, not on every re-focus. The modal object is already available in scope from useOnyx(ONYXKEYS.MODAL).

    Additionally, the stale closeModals state should be addressed: in Navigation.dismissModal, ensure the afterTransition callback is removed from closeModals once executed, by calling Modal.setCloseModal(null) at the end of the callback. This fixes shouldAutoFocus for all consumers, not just this useEffect.

    What alternative solutions did you explore? (Optional)

    Calling focusComposerWithDelay inside navigateToReport in Report/index.ts after dismissModal — rejected because it couples navigation to composer internals. Keeping the fix in the composer's own effect plus the Modal.ts cleanup is more maintainable and fixes the root stale-modal state for other dependents.

  9. yuvrajangadsingh commented on Apr 17, 2026

    @yuvrajangadsingh
    Contributor

    Proposal

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

    On wide-screen web, picking a user through FAB → Start Chat lands on the new report but the composer never auto-focuses, so you have to click it before typing.

    What is the root cause of that problem?

    navigateToReport in src/libs/actions/Report/index.ts:1955 fires two navigations back-to-back. The first runs inside Navigation.dismissModal({afterTransition}) (line 1957) and only dispatches Navigation.navigate(REPORT_WITH_ID) once the RHP dismiss transition finishes. The second sits in a setTimeout(…, 0) at line 1970 that runs almost immediately, while the RHP is still on top and modal.isVisible in Onyx is still true.

    That second nav is what poisons the focus flow in ComposerWithSuggestions.tsx. On wide-screen web (willBlurTextInputOnTapOutside is true, shouldFocusInputOnScreenFocus is true) the composer's focus-restore effect at line 827 gates on !isModalVisible && (!!prevIsModalVisible || !prevIsFocused). When the early setTimeout nav lands, the composer re-renders with the new reportID while modal.isVisible is still true (RHP not blurred yet), so the effect bails out. When the RHP finally blurs and modal.isVisible flips to false, the screen has already been focused for a while, so prevIsFocused is true and prevIsModalVisible in this render cycle is also false (because the "modal still up" render already happened and set prev to false too, since shouldAutoFocus plus the other deps changed in between without the modal.isVisible=true→false edge landing on a render where prevIsModalVisible was true). The guard never sees the true → false edge it needs, so focus(true) is never called. shouldAutoFocus (line 296) also can't save it, because it goes through !didHideComposerInput && isFocused && shouldShowComposeInput which were already true before the nav, so the autoFocus prop on the underlying Composer at line 967 doesn't retrigger on the render where the new reportID arrives.

    The closeModals array in src/libs/actions/Modal.ts is a red herring here. It's only populated by BaseModal at src/components/Modal/BaseModal.tsx:141 and PopoverWithoutOverlay, not by the RHP stack, so areAllModalsHidden() returns true in this flow anyway.

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

    Drop the racing second navigation in navigateToReport. The historical reason for the setTimeout (commit 0b4807dda63, "Fix composer focus when navigating when starting new chat") predates the current dismissModal({afterTransition}) API. Now that dismissModal has a proper afterTransition callback that fires once the RHP finishes dismissing, relying solely on it gives a clean modal.isVisible: true → false edge that the composer's focus-restore effect already handles.

    In src/libs/actions/Report/index.ts:1955, simplify navigateToReport to one nav path, the dismiss-then-navigate callback when we're dismissing a modal, and a plain navigate otherwise:

    function navigateToReport(reportID: string | undefined, shouldDismissModal = true) {
        if (!reportID) {
            return;
        }
        const route = ROUTES.REPORT_WITH_ID.getRoute(reportID);
        if (shouldDismissModal) {
            Navigation.dismissModal({afterTransition: () => Navigation.navigate(route)});
            return;
        }
        Navigation.navigate(route);
    }

    That removes the early setTimeout nav so the composer only sees the new reportID after the RHP is already dismissed, and the useEffect at ComposerWithSuggestions.tsx:827 then hits the !!prevIsModalVisible && !isModalVisible edge cleanly and calls focus(true). On narrow layouts, dismissModalWithReport at src/libs/Navigation/Navigation.ts:818 already does the plain navigate(..., {forceReplace: true}) path on its own, so this keeps existing mobile behavior unchanged. Callers of navigateToReport on mobile (navigateToAndOpenReport via selectOption in src/pages/NewChatPage.tsx:381, and the group-chat/workspace variants nearby) still get the same outward contract.

    What alternative solutions did you explore? (Optional)

    I considered fixing only the composer side by calling ReportActionComposeFocusManager.focus() after dismissModal's afterTransition in navigateToReport, but that couples the navigation helper to a composer singleton and still leaves the racing setTimeout nav behind to misfire in other surfaces. Loosening the guard in the focus-restore effect (e.g. keying focus off isFocused flipping to true alone) risks stealing focus when coming back from unrelated modals like the emoji picker or attachment preview, which is what that compound guard exists to prevent.

  10. abbasifaizan70 commented on Apr 17, 2026

    @abbasifaizan70
    Contributor

    🚨 Edited by proposal-police: This proposal was edited at 2026-04-17 22:22:50 UTC.

    Proposal

    What is the root cause of that problem?

    The bug is specific to the group-chat confirmation flow, not the normal 1:1 Start Chat flow. In NewChatConfirmPage, the group is created from the focused footer button (Start group), so that button can keep DOM focus while navigation is happening. That leaves the composer wrapper in a focused visual state, but the actual text input never becomes the active element with a live caret.

    navigateToAndCreateGroupChat(
    logins,
    newGroupDraft.reportName ?? '',
    personalData.login ?? '',
    optimisticReportID.current,
    introSelected,
    isSelfTourViewed,
    betas,
    personalData.accountID,
    newGroupDraft.avatarUri ?? '',
    avatarFile,
    );
    };
    return (
    <ScreenWrapper testID="NewChatConfirmPage">

    The group-chat creation path also clears NEW_GROUP_CHAT_DRAFT too early inside createGroupChat(). That cleanup runs during the transition instead of after the modal/navigation flow has settled, so NewChatConfirmPage can re-render and tear down the focused footer button while focus is still being handed off to the destination report composer.

    // eslint-disable-next-line @typescript-eslint/no-deprecated
    InteractionManager.runAfterInteractions(() => {
    clearGroupChat();
    });

    The shared navigateToReport() helper already has a special dismissModalWithReport() path that works well for modal-to-report navigation. The group flow was still using the generic dismiss/navigate sequence, so it was not explicitly opting into the more reliable report-opening path for composer-focus-sensitive navigation.

    /**
    * Navigates to a report, handling modal dismissal and delayed navigation for composer focus.
    *
    * @param reportID The ID of the report to navigate to
    * @param shouldDismissModal Whether to dismiss the modal before navigating
    */
    function navigateToReport(reportID: string | undefined, shouldDismissModal = true) {
    if (shouldDismissModal) {
    Navigation.dismissModal({
    afterTransition: () => {
    if (!reportID) {
    return;
    }
    Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID));
    },
    });
    } else if (reportID) {
    Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID));
    }

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

    The group confirmation screen should blur the currently focused footer button before navigation, then explicitly request the focus-safe report-opening path when creating the group:

    const createGroup = () => {
    if (!newGroupDraft) {
    return;
    }
    const logins: string[] = (newGroupDraft.participants ?? []).map((participant) => participant.login).filter((login): login is string => !!login);
    navigateToAndCreateGroupChat(
    logins,
    newGroupDraft.reportName ?? '',
    personalData.login ?? '',
    optimisticReportID.current,
    introSelected,
    isSelfTourViewed,
    betas,
    personalData.accountID,
    newGroupDraft.avatarUri ?? '',
    avatarFile,
    );
    };

    Call blurActiveElement() before creating the group so the focused Start group footer button does not keep DOM focus during navigation. Then pass the avatar data and shouldFocusComposer: true through the new options object so the group flow explicitly uses the composer-safe navigation path.

    The group-chat action should stop clearing NEW_GROUP_CHAT_DRAFT inside createGroupChat(). That cleanup should move to navigateToAndCreateGroupChat() and run after the transition delay, so the confirmation page does not re-render in the middle of focus handoff:

    // eslint-disable-next-line @typescript-eslint/no-deprecated
    InteractionManager.runAfterInteractions(() => {
    clearGroupChat();
    });

    Remove the early clearGroupChat() cleanup from createGroupChat(). That function should only create the optimistic report and fire the API request, without mutating the draft state during the transition.

    The navigation helper should support an explicit composer-focus path, and the group-chat flow should use it while delaying draft cleanup until after the transition:

    /**
    * Navigates to a report, handling modal dismissal and delayed navigation for composer focus.
    *
    * @param reportID The ID of the report to navigate to
    * @param shouldDismissModal Whether to dismiss the modal before navigating

    function navigateToAndCreateGroupChat(
    userLogins: string[],
    reportName: string,
    currentUserLogin: string,
    optimisticReportID: string,
    introSelected: OnyxEntry<IntroSelected>,
    isSelfTourViewed: boolean | undefined,
    betas: OnyxEntry<Beta[]>,
    currentUserAccountID: number,
    avatarUri?: string,
    avatarFile?: File | CustomRNImageManipulatorResult | undefined,
    ) {
    const participantAccountIDs = PersonalDetailsUtils.getAccountIDsByLogins(userLogins);
    // If we are creating a group chat then participantAccountIDs is expected to contain currentUserAccountID
    const newChat = buildOptimisticGroupChatReport(participantAccountIDs, reportName, avatarUri ?? '', currentUserAccountID, optimisticReportID, CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
    createGroupChat(newChat.reportID, userLogins, newChat, currentUserLogin, introSelected, isSelfTourViewed, betas, avatarFile);
    navigateToReport(newChat.reportID);
    }
    /**
    * This will find an existing chat, or create a new one if none exists, for the given accountID or set of accountIDs. It will then navigate to this chat.
    *
    * @param participantAccountIDs of user logins to start a chat report with.
    */
    function navigateToAndOpenReportWithAccountIDs(
    participantAccountIDs: number[],
    currentUserAccountID: number,

    Update navigateToReport() so composer-sensitive flows can use dismissModalWithReport({reportID}) directly when shouldFocusComposer is true. In navigateToAndCreateGroupChat(), replace the trailing optional arguments with a small options object, read avatarUri, avatarFile, and shouldFocusComposer from that object, navigate with navigateToReport(newChat.reportID, true, shouldFocusComposer), and delay clearGroupChat() with setTimeout(..., CONST.ANIMATED_TRANSITION) so cleanup happens after the transition instead of during it.

    Test Branch

    Link

    Code Difference

    Link

    Demo

    Screen.Recording.2026-04-18.at.3.15.33.AM.mov
  11. github-actions commented on Apr 17, 2026

    @github-actions
    Contributor

    ⚠️ @abbasifaizan70 Your proposal is a duplicate of an already existing proposal and has been automatically withdrawn to prevent spam. Please review the existing proposals before submitting a new one.

  12. abbasifaizan70 commented on Apr 17, 2026

    @abbasifaizan70
    Contributor

    @daledah My proposal is not a duplicate. I updated my solution approach and also added a test branch and a demo video.

  13. 23 remaining items

  14. added
    ReviewingHas a PR in review
    and removed
    Help WantedApply this label when an issue is open to proposals by contributors
    on Apr 27, 2026
  15. nkdengineer commented on Apr 28, 2026

    @nkdengineer
    Contributor

    @daledah The PR is ready for review.

  16. nkdengineer commented on May 20, 2026

    @nkdengineer
    Contributor

    @neil-marcellini This PR fixed the issue caused by the part change that removed the duplicate navigation, so I closed my PR. Please help to assign someone to handle the payment here.

  17. nkdengineer commented on Jun 9, 2026

    @nkdengineer
    Contributor

    @mallenexpensify, could you help with the payment based on my comment above? Thanks.

  18. mallenexpensify commented on Jun 15, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor: @nkdengineer due $250 via NewDot
    Contributor+: @daledah due $250 via NewDot

    Tests were done by C+ so 100% to both

  19. twisterdotcom commented on Jun 16, 2026

    @twisterdotcom
    Contributor

    $250 approved for @nkdengineer in Report ID: R009bfMTMRr7.

    Zapier Logs Run ID: 00040eee-17a6-a4de-bf1d-5a7ea2f4b9a4

    SO: https://stackoverflowteams.com/c/expensify/questions/7582

  20. quinthar commented on Sep 25, 2026

    @quinthar
    Contributor

    Approved $250 to @daledah

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

Metadata

Metadata

Labels

BugSomething is broken. Auto assigns a BugZero manager.ExternalAdded to denote the issue can be worked on by a contributorWeeklyKSv2

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions