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
[$175] Android-Chat-Message reappear in composer when sent to chat with unapproved expense #104358
User B creates and submits an expense in the workspace chat
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)
Open the Expensify App and log in as User A
User A goes to Inbox and opens the workspace chat (the chat will be automatically scrolled to the expense that needs approval)
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.
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.
Opening the chat from the inbox adds reportActionID (the expense) to the route. That's why the chat lands on the expense.
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).
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.
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?
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.
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.
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.
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
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:
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.
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 ''.
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.
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.
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:
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).
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.
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!
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:
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.
Log in as Account B on Web.
Navigate to Workspace W chat.
Submit an expense (e.g., $50).
Send 5–10 long chat messages or attachments to push the expense well above the visible viewport.
Log in as Account A on the Android App.
Open the Workspace W chat from the Inbox.
Verify the chat automatically navigates and focuses on the unapproved expense action.
In the message composer, type "Test message approval" and tap Send.
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:
Open any chat report.
Type a message in the composer without sending.
Navigate back to Inbox and return to the chat: verify the draft is restored.
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.
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:
Debounced Draft Clear on Send:
In src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx:985-992:
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.
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 ''.
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.
Android SilentCommentUpdater Restores Stale Draft:
In src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/index.android.tsx:
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?
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:
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.
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:
Debounced Draft Clear: In src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx:990:
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.
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.
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?
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.
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:
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.
Activity
applause-bot commentedon Oct 19, 2026
While Applause doesn't embed attachments for this bug, here are the links for quick view:
https://platform.applause.com/services/links/v1/external/71ec8717d9f66353f4e11b0d7e644b3bb73d4b1d42cee789e95c22b5903f9401
https://platform.applause.com/services/links/v1/external/6259f09d28af5342048e54c7d0bc591f8a1acde129801b645bffcfe8b2b0c143
MelvinBot commentedon Oct 19, 2026
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.
reportActionID(the expense) to the route. That's why the chat lands on the expense.LinkedActionNotFoundGuardreturns a different tree depending on that param: barechildrenwhen it's empty, orchildrenwrapped in a keyed<LinkedActionNotFoundGate>when it's set. It wraps the header, the action list and the footer/composer (ReportScreen.tsx:129-164).useReportActionsNewActionLiveTail.ts:106-124). When that load finishes, it clears the param withsetParams({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.ComposerProvidertakes its starting text fromREPORT_DRAFT_COMMENTonce, when it mounts (ComposerProvider.tsx:64-68). Sending doesn't clear the draft right away.onClearcallsupdateComment('', 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?
LinkedActionNotFoundGuardthe same shape whether or not a linked action is set. Render the gate as a sibling that returnsnull, and renderchildrenin 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.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)
'', andSilentCommentUpdatershould then clear the composer. It skips that sync only whenisCommentPendingSavedis true. My best guess is that the Android text input firesonChangeTextwhen 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.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
melvin-bot commentedon Oct 19, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @nyomanjyotisa (
External)[-]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[/+]melvin-bot commentedon Oct 19, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022112186732253821478
j3ffffff commentedon Oct 19, 2026
Proposal
What is the root cause of that problem?
When User A opens the workspace chat with the unapproved expense, the route carries a
reportActionIDparam pointing to the expense (makinglinkedReportActionIDnon-empty). When User A types a message and sends it, two things race:Debounced draft clearing —
onClearinComposerWithSuggestions.tsx(~line 990) callsupdateComment('', true), where thetrueflag debounces the Onyx write by 1 second (CONST.TIMING.DRAFT_SAVE_DEBOUNCE_TIME). TheREPORT_DRAFT_COMMENTOnyx key still holds the sent text for that full second.Live-tail jump clears the route param —
useReportActionsNewActionLiveTail.tssubscribes to new actions. When User A's own message arrives,scrollToBottomForCurrentUserActionevaluatesshouldJumpToLiveTail = !!linkedReportActionID→true, so it callsopenReport(). When that loading cycle completes,clearLinkedActionParam()fires and callsnavigation.setParams({reportActionID: ''}). This changesreportActionIDFromRoutefrom the expense action ID to''.Remount of the composer subtree —
LinkedActionNotFoundGuardgates its children behind<LinkedActionNotFoundGate key={reportActionIDFromRoute}>only whenreportActionIDFromRouteis truthy. When it becomes'', the guard returns children directly — a different React tree shape. React unmountsLinkedActionNotFoundGateand remounts the entire children subtree, including the composer.Android-only restoration —
SilentCommentUpdater.android.tsxrunsupdateComment(comment)once on component mount (empty[]deps). On remount it fires again — and at that momentcommentfrom Onyx is still the sent message text (because the 1-second debounce hasn't cleared it yet). The non-AndroidSilentCommentUpdaterguards 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 havelinkedReportActionID = ''soshouldJumpToLiveTailis 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
isCommentPendingSavedguard toSilentCommentUpdater.android.tsxthat already exists in the non-Android version. Specifically, passisCommentPendingSaved(the ref already threaded throughSilentCommentUpdaterProps) to the Android component and skip theupdateCommentcall ifisCommentPendingSaved.currentistrueat mount time: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
onClearcallback inComposerWithSuggestions.tsxfromupdateComment('', true)toupdateComment('', 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)
clearLinkedActionParam()from causing a full remount by makingLinkedActionNotFoundGuardkeepLinkedActionNotFoundGatemounted even whenreportActionIDFromRoutebecomes''(treating''as a "cleared but still mounted" state). This would work but is a larger structural change.shouldJumpToLiveTailpath when the user has the newest actions already loaded (hasNewestReportAction = true), but that would change behavior for other valid scenarios.janiya2 commentedon Oct 19, 2026
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
LinkedActionNotFoundGuardcombined with debounced draft saving and Android's asynchronousTextInputevent cycle:Root-Level Unmount/Remount Triggered by Route Param Clearing:
reportActionIDis passed in route params to focus on that expense.ReportScreen.tsx:129-164,<LinkedActionNotFoundGuard>wraps the entire report view (ReportDragAndDropProvider,ReportHeader,ReportActionsList, andReportFooter/ComposerProvider).LinkedActionNotFoundGuard.tsx:33-44:useReportActionsNewActionLiveTail.ts:167-188jumps to newest messages and clears the param viaNavigation.setParams({reportActionID: ''}).reportActionIDFromRoutebecomes falsy,LinkedActionNotFoundGuardswitches React branches from<LinkedActionNotFoundGate>{children}</LinkedActionNotFoundGate>to bare{children}.ReportHeader,ReportActionsList,ComposerProvider, andTextInput) and mounting a new tree from scratch.Debounced Draft Persistence in Onyx:
ComposerWithSuggestions.tsx:985-993calls:shouldDebounceSaveComment = truesends the clear throughdebouncedSaveComment(''), which waits1000msbefore committing''to OnyxREPORT_DRAFT_COMMENT.reportActionIDclearing, the newly mountedComposerProvider(ComposerProvider.tsx:64-68) reads its initial text fromdraftCommentin Onyx, which still holds the sent message!Android Native TextInput Event Cycle:
ReactEditTextmounts asynchronously with the initial draft text and emits a nativeonChangeTextevent back to the JS thread during attachment/layout.updateCommentwith the stale text, re-scheduling a debounced save and permanently restoring the sent message inside the composer.Historically,
commit 25d3bb4cremoved<FullPageNotFoundView>fromLinkedActionNotFoundGatebecause deleted linked actions now auto-recover viauseAutoNavigateForDeletedLinkedAction. Therefore,LinkedActionNotFoundGatehas no UI representation whatsoever (it returns barechildren), and wrappingchildreninside it only serves to destabilize the component tree wheneverreportActionIDtransitions between defined and undefined.What changes do you think we should make in order to solve the problem?
1. Decouple
childrenfromLinkedActionNotFoundGateinsrc/pages/inbox/LinkedActionNotFoundGuard.tsx:Render
LinkedActionNotFoundGateas a headless sibling rather than wrappingchildren. This keepschildrenat the exact same depth in the React tree whetherreportActionIDFromRouteis defined or undefined, completely eliminating the root unmount/remount: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:What alternative solutions did you explore? (Optional)
ComposerWithSuggestions: While this prevents stale drafts from being reloaded, it leaves the unnecessary unmount/remount of the entireReportScreenactive. The unmount causes header flickering, keyboard focus drops, and re-renders the entire message list on the first message sent. Stabilizing the tree inLinkedActionNotFoundGuardsolves the root architectural cause.reportActionIDas a prop intoLinkedActionNotFoundGatewithout keys: Retainingkey={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:
Offline / Draft Persistence Test:
Regression Tests:
/r/:reportID/:reportActionID): verify the action is highlighted and focused correctly.melvin-bot commentedon Oct 19, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
sijo0218 commentedon Oct 19, 2026
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:
In
src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx:985-992:When a message is sent,
onClearinvokesupdateComment('', true), wheretrue(shouldDebounce) delays the Onyx draft write by 1 second (CONST.TIMING.DRAFT_SAVE_DEBOUNCE_TIME). During this 1-second window,ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENTstill holds the sent message text.Live-Tail Jump Clears Route Param:
When the user's sent message arrives,
useReportActionsNewActionLiveTail.tsdetectsshouldJumpToLiveTail = !!linkedReportActionID(true). Once the report actions load,clearLinkedActionParam()callsnavigation.setParams({reportActionID: ''}), changingreportActionIDFromRoutefrom the expense action ID to an empty string''.Subtree Remount via Key Change:
In
LinkedActionNotFoundGuard, changingreportActionIDFromRoutefrom a valid ID to''alters the React tree shape (removing<LinkedActionNotFoundGate key={reportActionIDFromRoute}>), causing React to unmount and remount the composer subtree.Android SilentCommentUpdater Restores Stale Draft:
In
src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/index.android.tsx:On remount, the empty-dependency
useEffectfires and passes the OnyxcommenttoupdateComment. Because the 1-second debounce has not yet cleared Onyx,commentis 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?
isCommentPendingSavedin Android Updater:In
src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/index.android.tsx:Accept
isCommentPendingSavedfromSilentCommentUpdaterPropsand skip restoring the comment if a save/clear is pending:In
src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx:990:Change
updateComment('', true)toupdateComment('', 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
LinkedActionNotFoundGuardfrom remounting children when clearingreportActionIDFromRoute. However, fixing the draft clear timing and synchronizing theisCommentPendingSavedcheck between Android and non-Android resolves the root cause cleanly without altering the navigation guard lifecycle.melvin-bot commentedon Oct 19, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
camesenin commentedon Oct 19, 2026
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
reportActionIDlink), 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:
src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx:990:onClearpassestruefor debouncing (CONST.TIMING.DRAFT_SAVE_DEBOUNCE_TIME = 1000ms). During this 1-second debounce window,REPORT_DRAFT_COMMENTin Onyx still holds the sent message text.reportActionID), the live-tail navigation firesclearLinkedActionParam(), updating the route params (reportActionID: '').LinkedActionNotFoundGuardgates children conditionally behind<LinkedActionNotFoundGate key={reportActionIDFromRoute}>. Transitioning from a non-emptyreportActionIDto''changes the React tree structure, causing React to unmountLinkedActionNotFoundGateand remount the entire composer subtree.src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/index.android.tsx:22-25runs its mount effect unconditionally:SilentCommentUpdater/index.tsx, the Android implementation lacks the!isCommentPendingSaved.currentcheck 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?
Immediate Draft Clearing on Send (
ComposerWithSuggestions.tsx):In
onClear, clear the Onyx draft immediately by disabling debounce on clear: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.
Parity Guard in Android SilentCommentUpdater (
SilentCommentUpdater/index.android.tsx):Bring the Android implementation to parity with
index.tsxby receivingisCommentPendingSavedfrom props and guarding the mount effect:Tree Stability in LinkedActionNotFoundGuard (
LinkedActionNotFoundGuard.tsx):Ensure
LinkedActionNotFoundGatedoes not unmount children whenreportActionIDclears 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 ononClearleaves 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:onClearimmediately writes an empty string to OnyxREPORT_DRAFT_COMMENTwithout awaiting the 1000ms debounce timer.SilentCommentUpdater.android.tsxdoes not triggerupdateCommenton remount whenisCommentPendingSaved.currentistrue.Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: https://www.upwork.com/freelancers/~0170d6b02e9440e604
melvin-bot commentedon Oct 19, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!