Skip to content

[$175] Android-Chat-Message reappear in composer when sent to chat with unapproved expense #104358

Open

Description

@applause-bot

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


Version Number: v9.5.13-3
Reproducible in staging?: Yes
Reproducible in production?: Yes
If this was caught during regression testing, add the test name, ID and link from BrowserStack: https://test-management.browserstack.com/projects/2219752/test-runs/79209904/folder/13176723/41237037/1871928616
Email or phone of affected tester (no customers): [email protected] and [email protected]
Issue reported by: Applause Internal Team
Bug source: Exploratory - Significant User Experience Deterioration
Device used: Google Pixel 7a / Android 17
App Component: Chat Report View

Action Performed:

Preconditions:

  • User A is the owner and approver of Workspace A.
  • User B is a member of Workspace A.
  1. Open the Expensify Web App and log in as User B
  2. User B goes to Inbox and opens the workspace chat
  3. User B creates and submits an expense in the workspace chat
  4. User B sends a long message (or several messages, or an attachment) in the same chat (to move the submitted expense to the top of the chat)
  5. Open the Expensify App and log in as User A
  6. User A goes to Inbox and opens the workspace chat (the chat will be automatically scrolled to the expense that needs approval)
  7. User A types a message in the composer and sends it

Expected Result:

The message should not reappear in the composer after being sent to the workspace chat.

Actual Result:

The already sent message reappears in the message composer after the chat is scrolled down, and the green "Approve" button appears at the top of the screen.

Note: This happens only the first time after opening the chat. To reproduce the issue a second time, the user needs to go back to the Inbox and reopen the chat.

Workaround:

Unknown

Platforms:

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

Screenshots/Videos

To be added by Applause Leads

Bug7289460_1792298474910.Expensify_26w42_Message_Reappear.mp4

View all open jobs on GitHub

Applause Internal Information
Exported by: Chandana Kiran
Bug ID: 7289460

Upwork Automation - Do Not Edit

Activity

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

    @MelvinBot
    Contributor

    Proposal

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

    On Android, an approver opens a workspace chat that auto-scrolls to an unapproved expense and sends a message. The chat then jumps to the bottom, and the sent text comes back in the composer. It only happens on the first send after you open the chat.

    What is the root cause of that problem?

    Sending the first message rebuilds the whole report screen, composer included. The new composer reads its starting text from the saved draft before the 1-second debounced clear has emptied it.

    1. Opening the chat from the inbox adds reportActionID (the expense) to the route. That's why the chat lands on the expense.
    2. LinkedActionNotFoundGuard returns a different tree depending on that param: bare children when it's empty, or children wrapped in a keyed <LinkedActionNotFoundGate> when it's set. It wraps the header, the action list and the footer/composer (ReportScreen.tsx:129-164).
    3. When you send a message while a linked action is set, the app jumps to the newest messages (useReportActionsNewActionLiveTail.ts:106-124). When that load finishes, it clears the param with setParams({reportActionID: ''}) (L167-L188). The guard switches branches, so React unmounts and remounts everything under it. That explains the jump to the bottom and the freshly rendered header and Approve button. It's first-time-only because the param is gone after that.
    4. The new ComposerProvider takes its starting text from REPORT_DRAFT_COMMENT once, when it mounts (ComposerProvider.tsx:64-68). Sending doesn't clear the draft right away. onClear calls updateComment('', true), which goes through the 1-second debounced save (ComposerWithSuggestions.tsx:985-992). If the remount happens inside that window, the composer starts with the sent text.

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

    1. Main fix: keep the tree under LinkedActionNotFoundGuard the same shape whether or not a linked action is set. Render the gate as a sibling that returns null, and render children in the same position every time. Clearing the linked action should then never remount the header, list or composer. This also keeps focus and other local state.
    2. Defensive: after a send, write the empty draft right away instead of through the debounce. That stops Onyx from holding sent text, even if the screen remounts for some other reason.

    What alternative solutions did you explore? (Optional)

    Only doing step 2. It fixes this symptom, but the screen would still remount and lose state on the first send.

    Open questions (not verified)
    • Why Android only. The remount path runs on all platforms. After the remount, the old composer's unmount flush should write '', and SilentCommentUpdater should then clear the composer. It skips that sync only when isCommentPendingSaved is true. My best guess is that the Android text input fires onChangeText when it mounts with the stale text. That would queue a new save, block the sync, and write the sent text back. I haven't confirmed this on a device.
    • Testing. I didn't reproduce this. The flow needs two accounts and the Android app.
    • Introducing PR. The two-branch guard has been there since the not-found guards were extracted. I didn't pin down which change made a send clear reportActionID, so I can't name a single offending PR.

    Possibly related: Android - Compose box - Draft message input reappears after clearing and navigating. It's also a stale draft from a debounced save, but in the edit composer, with a different trigger.


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


    view run

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

    @melvin-bot

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

  5. changed the title [-]Android-Chat-Message reappear in composer when sent to chat with unapproved expense[/-] [+][$175] Android-Chat-Message reappear in composer when sent to chat with unapproved expense[/+] on Oct 19, 2026
  6. melvin-bot commented on Oct 19, 2026

    @melvin-bot
  7. j3ffffff commented on Oct 19, 2026

    @j3ffffff

    Proposal

    What is the root cause of that problem?

    When User A opens the workspace chat with the unapproved expense, the route carries a reportActionID param pointing to the expense (making linkedReportActionID non-empty). When User A types a message and sends it, two things race:

    1. Debounced draft clearing — onClear in ComposerWithSuggestions.tsx (~line 990) calls updateComment('', true), where the true flag debounces the Onyx write by 1 second (CONST.TIMING.DRAFT_SAVE_DEBOUNCE_TIME). The REPORT_DRAFT_COMMENT Onyx key still holds the sent text for that full second.

    2. Live-tail jump clears the route param — useReportActionsNewActionLiveTail.ts subscribes to new actions. When User A's own message arrives, scrollToBottomForCurrentUserAction evaluates shouldJumpToLiveTail = !!linkedReportActionID → true, so it calls openReport(). When that loading cycle completes, clearLinkedActionParam() fires and calls navigation.setParams({reportActionID: ''}). This changes reportActionIDFromRoute from the expense action ID to ''.

    3. Remount of the composer subtree — LinkedActionNotFoundGuard gates its children behind <LinkedActionNotFoundGate key={reportActionIDFromRoute}> only when reportActionIDFromRoute is truthy. When it becomes '', the guard returns children directly — a different React tree shape. React unmounts LinkedActionNotFoundGate and remounts the entire children subtree, including the composer.

    4. Android-only restoration — SilentCommentUpdater.android.tsx runs updateComment(comment) once on component mount (empty [] deps). On remount it fires again — and at that moment comment from Onyx is still the sent message text (because the 1-second debounce hasn't cleared it yet). The non-Android SilentCommentUpdater guards against this with !isCommentPendingSaved.current; the Android version has no such guard.

    The "only first time per open" behavior confirms this: clearLinkedActionParam() only fires once per open-report cycle; after it clears the param, subsequent sends have linkedReportActionID = '' so shouldJumpToLiveTail is false and no live-tail jump (and therefore no remount) occurs.

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

    Add the same isCommentPendingSaved guard to SilentCommentUpdater.android.tsx that already exists in the non-Android version. Specifically, pass isCommentPendingSaved (the ref already threaded through SilentCommentUpdaterProps) to the Android component and skip the updateComment call if isCommentPendingSaved.current is true at mount time:

    // in SilentCommentUpdater/index.android.tsx
    useEffect(() => {
        if (isCommentPendingSaved.current) {
            return;
        }
        updateComment(comment);
    }, []);
    

    This mirrors the exact protection the non-Android version already has and prevents the Android version from restoring a draft whose Onyx clear is merely delayed by the debounce.

    A complementary (and arguably cleaner) secondary fix is to change the onClear callback in ComposerWithSuggestions.tsx from updateComment('', true) to updateComment('', false), making the Onyx draft clear happen immediately when the user sends — eliminating the debounce window entirely for the send path. This removes the race condition at its source.

    What alternative solutions did you explore? (Optional)

    • Prevent clearLinkedActionParam() from causing a full remount by making LinkedActionNotFoundGuard keep LinkedActionNotFoundGate mounted even when reportActionIDFromRoute becomes '' (treating '' as a "cleared but still mounted" state). This would work but is a larger structural change.
    • Suppress the shouldJumpToLiveTail path when the user has the newest actions already loaded (hasNewestReportAction = true), but that would change behavior for other valid scenarios.
  8. janiya2 commented on Oct 19, 2026

    @janiya2

    Contributor details
    Your Expensify account email: [email protected]
    Upwork Profile Link: https://www.upwork.com/freelancers/janithad

    Proposal

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

    On Android, when an approver opens a workspace chat from Inbox that has an unapproved expense, the chat auto-scrolls to the linked expense action. When the user types a message in the composer and sends it, the chat jumps to the bottom and the sent message incorrectly reappears in the composer.

    What is the root cause of that problem?

    This issue is caused by a root-level component hierarchy change in LinkedActionNotFoundGuard combined with debounced draft saving and Android's asynchronous TextInput event cycle:

    1. Root-Level Unmount/Remount Triggered by Route Param Clearing:

      • When the user opens the workspace chat with an unapproved expense, reportActionID is passed in route params to focus on that expense.
      • In ReportScreen.tsx:129-164, <LinkedActionNotFoundGuard> wraps the entire report view (ReportDragAndDropProvider, ReportHeader, ReportActionsList, and ReportFooter / ComposerProvider).
      • In LinkedActionNotFoundGuard.tsx:33-44:
        if (!reportActionIDFromRoute) {
            return children;
        }
        
        return (
            <LinkedActionNotFoundGate
                key={reportActionIDFromRoute}
                reportActionIDFromRoute={reportActionIDFromRoute}
            >
                {children}
            </LinkedActionNotFoundGate>
        );
      • When the user sends a message while scrolled to a linked action, useReportActionsNewActionLiveTail.ts:167-188 jumps to newest messages and clears the param via Navigation.setParams({reportActionID: ''}).
      • Because reportActionIDFromRoute becomes falsy, LinkedActionNotFoundGuard switches React branches from <LinkedActionNotFoundGate>{children}</LinkedActionNotFoundGate> to bare {children}.
      • React treats this as an element type change at the root of the tree, destroying (unmounting) the entire subtree (including ReportHeader, ReportActionsList, ComposerProvider, and TextInput) and mounting a new tree from scratch.
    2. Debounced Draft Persistence in Onyx:

      • When the message was submitted, ComposerWithSuggestions.tsx:985-993 calls:
        const onClear = useCallback(
            (textOnClear: string) => {
                resetScrollPosition();
                onClearProp(textOnClear);
                updateComment('', true);
            },
            [onClearProp, updateComment, resetScrollPosition],
        );
      • Passing shouldDebounceSaveComment = true sends the clear through debouncedSaveComment(''), which waits 1000ms before committing '' to Onyx REPORT_DRAFT_COMMENT.
      • When the screen unmounts and remounts immediately upon reportActionID clearing, the newly mounted ComposerProvider (ComposerProvider.tsx:64-68) reads its initial text from draftComment in Onyx, which still holds the sent message!
    3. Android Native TextInput Event Cycle:

      • On iOS/Web, controlled text input reconciliation does not re-emit synthetic change events for initial props.
      • On Android, ReactEditText mounts asynchronously with the initial draft text and emits a native onChangeText event back to the JS thread during attachment/layout.
      • This native event triggers updateComment with the stale text, re-scheduling a debounced save and permanently restoring the sent message inside the composer.

    Historically, commit 25d3bb4c removed <FullPageNotFoundView> from LinkedActionNotFoundGate because deleted linked actions now auto-recover via useAutoNavigateForDeletedLinkedAction. Therefore, LinkedActionNotFoundGate has no UI representation whatsoever (it returns bare children), and wrapping children inside it only serves to destabilize the component tree whenever reportActionID transitions between defined and undefined.


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

    1. Decouple children from LinkedActionNotFoundGate in src/pages/inbox/LinkedActionNotFoundGuard.tsx:

    Render LinkedActionNotFoundGate as a headless sibling rather than wrapping children. This keeps children at the exact same depth in the React tree whether reportActionIDFromRoute is defined or undefined, completely eliminating the root unmount/remount:

    diff --git a/src/pages/inbox/LinkedActionNotFoundGuard.tsx b/src/pages/inbox/LinkedActionNotFoundGuard.tsx
    --- a/src/pages/inbox/LinkedActionNotFoundGuard.tsx
    +++ b/src/pages/inbox/LinkedActionNotFoundGuard.tsx
    @@ -28,28 +28,24 @@ type LinkedActionNotFoundGuardProps = {
     function LinkedActionNotFoundGuard({children}: LinkedActionNotFoundGuardProps) {
         const route = useRoute();
         const routeParams = route.params as {reportActionID?: string} | undefined;
         const reportActionIDFromRoute = routeParams?.reportActionID;
     
    -    if (!reportActionIDFromRoute) {
    -        return children;
    -    }
    -
         return (
    -        <LinkedActionNotFoundGate
    -            key={reportActionIDFromRoute}
    -            reportActionIDFromRoute={reportActionIDFromRoute}
    -        >
    +        <>
    +            {!!reportActionIDFromRoute && (
    +                <LinkedActionNotFoundGate
    +                    key={reportActionIDFromRoute}
    +                    reportActionIDFromRoute={reportActionIDFromRoute}
    +                />
    +            )}
                 {children}
    -        </LinkedActionNotFoundGate>
    +        </>
         );
     }
     
     type LinkedActionNotFoundGateProps = {
         reportActionIDFromRoute: string;
    -    children: ReactNode;
     };
     
    -function LinkedActionNotFoundGate({reportActionIDFromRoute, children}: LinkedActionNotFoundGateProps) {
    +function LinkedActionNotFoundGate({reportActionIDFromRoute}: LinkedActionNotFoundGateProps) {
         const route = useRoute();
         const navigation = useNavigation();
    @@ -147,7 +143,7 @@ function LinkedActionNotFoundGate({reportActionIDFromRoute, children}: LinkedActionNotFoundGateProps) {
     
         useAutoNavigateForDeletedLinkedAction(isLinkedActionUnavailable, navigateToEndOfReport);
     
    -    return children;
    +    return null;
     }
     
     export default LinkedActionNotFoundGuard;

    2. Synchronously clear draft comments on send in src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx:

    When a message is submitted and cleared, there is no need to debounce saving '' for 1000ms. Cancel any pending keystroke debounces and immediately clear the draft in Onyx so that Onyx never holds stale message text:

    diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx
    --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx
    +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx
    @@ -987,9 +987,10 @@ function ComposerWithSuggestions(
             (textOnClear: string) => {
                 resetScrollPosition();
                 // Note: use the value when the clear happened, not the current value which might have changed already
                 onClearProp(textOnClear);
    -            updateComment('', true);
    +            debouncedSaveComment.cancel();
    +            updateComment('', false);
             },
    -        [onClearProp, updateComment, resetScrollPosition],
    +        [debouncedSaveComment, onClearProp, updateComment, resetScrollPosition],
         );

    What alternative solutions did you explore? (Optional)

    • Only applying the synchronous draft clear in ComposerWithSuggestions: While this prevents stale drafts from being reloaded, it leaves the unnecessary unmount/remount of the entire ReportScreen active. The unmount causes header flickering, keyboard focus drops, and re-renders the entire message list on the first message sent. Stabilizing the tree in LinkedActionNotFoundGuard solves the root architectural cause.
    • Passing reportActionID as a prop into LinkedActionNotFoundGate without keys: Retaining key={reportActionIDFromRoute} on the headless gate is superior because it cleanly resets gate-specific local states (wasEverVisible, hasSeenLoadingCycle) whenever a new linked action route is opened.

    Tests

    Manual Test Steps:

    Preconditions:

    • Account A: Owner/Approver of Workspace W.
    • Account B: Member of Workspace W.
    1. Log in as Account B on Web.
    2. Navigate to Workspace W chat.
    3. Submit an expense (e.g., $50).
    4. Send 5–10 long chat messages or attachments to push the expense well above the visible viewport.
    5. Log in as Account A on the Android App.
    6. Open the Workspace W chat from the Inbox.
    7. Verify the chat automatically navigates and focuses on the unapproved expense action.
    8. In the message composer, type "Test message approval" and tap Send.
    9. Verify:
      • The message sends and appears in the chat.
      • The message composer clears immediately and stays empty.
      • The text does not reappear in the composer.
      • The screen does not unmount or flicker the header/Approve button.

    Offline / Draft Persistence Test:

    1. Open any chat report.
    2. Type a message in the composer without sending.
    3. Navigate back to Inbox and return to the chat: verify the draft is restored.
    4. Tap Send: verify the draft is immediately cleared in Onyx and does not reappear upon navigating away and returning.

    Regression Tests:

    • Open a chat with a linked action that has been deleted: verify auto-navigation to the end of the report still operates smoothly without errors.
    • Deep link to an action via URL (/r/:reportID/:reportActionID): verify the action is highlighted and focused correctly.
  9. melvin-bot commented on Oct 19, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  10. sijo0218 commented on Oct 19, 2026

    @sijo0218

    Contributor details
    Your Expensify account email: [email protected]
    Upwork Profile Link: https://www.upwork.com/freelancers/~011091133bbb09cd1c

    Proposal

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

    On Android, when an approver opens a workspace chat that was automatically scrolled to an unapproved expense via a linked action and sends a message, the sent message unexpectedly reappears in the message composer immediately after the chat scrolls down and the green "Approve" button renders.

    What is the root cause of that problem?

    This is caused by a race condition between debounced draft clearing, route parameter clearing, and Android-specific component mount behavior:

    1. Debounced Draft Clear on Send:
      In src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx:985-992:
    const onClear = useCallback(
        (textOnClear: string) => {
            onClearProp(textOnClear);
            updateComment('', true);

    When a message is sent, onClear invokes updateComment('', true), where true (shouldDebounce) delays the Onyx draft write by 1 second (CONST.TIMING.DRAFT_SAVE_DEBOUNCE_TIME). During this 1-second window, ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT still holds the sent message text.

    1. Live-Tail Jump Clears Route Param:
      When the user's sent message arrives, useReportActionsNewActionLiveTail.ts detects shouldJumpToLiveTail = !!linkedReportActionID (true). Once the report actions load, clearLinkedActionParam() calls navigation.setParams({reportActionID: ''}), changing reportActionIDFromRoute from the expense action ID to an empty string ''.

    2. Subtree Remount via Key Change:
      In LinkedActionNotFoundGuard, changing reportActionIDFromRoute from a valid ID to '' alters the React tree shape (removing <LinkedActionNotFoundGate key={reportActionIDFromRoute}>), causing React to unmount and remount the composer subtree.

    3. Android SilentCommentUpdater Restores Stale Draft:
      In src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/index.android.tsx:

    function SilentCommentUpdater({updateComment, reportID}: SilentCommentUpdaterProps) {
        const [comment = ''] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`);
    
        useEffect(() => {
            updateComment(comment);
        }, []);

    On remount, the empty-dependency useEffect fires and passes the Onyx comment to updateComment. Because the 1-second debounce has not yet cleared Onyx, comment is still the sent message, immediately restoring the text into the composer. The non-Android implementation (index.tsx) guards against this with !isCommentPendingSaved.current, but the Android implementation was missing this guard entirely.

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

    1. Pass and Check isCommentPendingSaved in Android Updater:
      In src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/index.android.tsx:
      Accept isCommentPendingSaved from SilentCommentUpdaterProps and skip restoring the comment if a save/clear is pending:
    function SilentCommentUpdater({updateComment, reportID, isCommentPendingSaved}: SilentCommentUpdaterProps) {
        const [comment = ''] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`);
    
        useEffect(() => {
            if (isCommentPendingSaved.current) {
                return;
            }
            updateComment(comment);
        }, []);
    1. Immediate Draft Clear on Send in ComposerWithSuggestions:
      In src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx:990:
      Change updateComment('', true) to updateComment('', false). When a message is actively sent and cleared, there is no reason to debounce the empty string write to Onyx. Writing '' immediately eliminates the 1-second stale window at the source.

    What alternative solutions did you explore? (Optional)

    Preventing LinkedActionNotFoundGuard from remounting children when clearing reportActionIDFromRoute. However, fixing the draft clear timing and synchronizing the isCommentPendingSaved check between Android and non-Android resolves the root cause cleanly without altering the navigation guard lifecycle.

  11. melvin-bot commented on Oct 19, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  12. camesenin commented on Oct 19, 2026

    @camesenin

    Proposal

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

    On Android, when sending a chat message in a report containing an unapproved expense (or navigating with a reportActionID link), the message sent reappears in the composer after sending.

    What is the root cause of that problem?

    A race condition between debounced draft clearing and component remounting:

    1. Debounced Draft Clear: In src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx:990:
      const onClear = useCallback(
          (textOnClear: string) => {
              resetScrollPosition();
              onClearProp(textOnClear);
              updateComment('', true);
          },
          [onClearProp, updateComment, resetScrollPosition],
      );
      When the user sends a message, onClear passes true for debouncing (CONST.TIMING.DRAFT_SAVE_DEBOUNCE_TIME = 1000ms). During this 1-second debounce window, REPORT_DRAFT_COMMENT in Onyx still holds the sent message text.
    2. Linked Action Route Clearing & Subtree Remount: When sending in a chat opened with a linked action (reportActionID), the live-tail navigation fires clearLinkedActionParam(), updating the route params (reportActionID: ''). LinkedActionNotFoundGuard gates children conditionally behind <LinkedActionNotFoundGate key={reportActionIDFromRoute}>. Transitioning from a non-empty reportActionID to '' changes the React tree structure, causing React to unmount LinkedActionNotFoundGate and remount the entire composer subtree.
    3. Android-Specific Stale Restoration: On remount, src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/index.android.tsx:22-25 runs its mount effect unconditionally:
      useEffect(() => {
          updateComment(comment);
      }, []);
      Unlike SilentCommentUpdater/index.tsx, the Android implementation lacks the !isCommentPendingSaved.current check and doesn't check if the draft clear is pending. Because Onyx still holds the sent text due to the 1s debounce, the mounting effect immediately restores the sent message back into the composer.

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

    1. Immediate Draft Clearing on Send (ComposerWithSuggestions.tsx):
      In onClear, clear the Onyx draft immediately by disabling debounce on clear:

      updateComment('', false);

      When a message is sent, clearing the draft is an intentional final action and should not be debounced. This eliminates the race condition at its root across all platforms.

    2. Parity Guard in Android SilentCommentUpdater (SilentCommentUpdater/index.android.tsx):
      Bring the Android implementation to parity with index.tsx by receiving isCommentPendingSaved from props and guarding the mount effect:

      useEffect(() => {
          if (isCommentPendingSaved?.current) {
              return;
          }
          updateComment(comment);
      }, []);
    3. Tree Stability in LinkedActionNotFoundGuard (LinkedActionNotFoundGuard.tsx):
      Ensure LinkedActionNotFoundGate does not unmount children when reportActionID clears by keeping the container structure consistent.

    What alternative solutions did you explore? (Optional)

    Only applying the guard in SilentCommentUpdater.android.tsx. While this prevents the stale restore on Android, keeping a 1000ms debounce window on onClear leaves potential race conditions open if the user rapidly switches chats immediately after sending. Clearing immediately (false) on send is strictly cleaner and safer.

    Automated Tests

    In tests/unit/ComposerWithSuggestionsTest.ts:

    • Verify that calling onClear immediately writes an empty string to Onyx REPORT_DRAFT_COMMENT without awaiting the 1000ms debounce timer.
    • Verify SilentCommentUpdater.android.tsx does not trigger updateComment on remount when isCommentPendingSaved.current is true.

    Contributor details
    Your Expensify account email: [email protected]
    Upwork Profile Link: https://www.upwork.com/freelancers/~0170d6b02e9440e604

  13. melvin-bot commented on Oct 19, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

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

Metadata

Metadata

Assignees

Labels

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

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions