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] Web-Chat- Edit comment input is not focused #104360
Please re-state the problem that we are trying to solve in this issue.
After you delete a workspace, the "Edit comment" input does not get focus on a report opened from Spend > Reports.
What is the root cause of that problem?
Deleting a workspace leaves behind a "ready to focus" promise that never resolves. Every delayed composer focus then waits on it forever.
The delete-workspace confirm modal stays open with a loading state. Then closeModal() removes it from the global modal stack while it is still visible. This unmounts the modal instead of hiding it.
On unmount, BaseModal calls hideModal, but never ComposerFocusManager.setReadyToFocus(id). Only the visible→hidden path resolves the promise. So the modal's entry stays in promiseMap, unresolved.
focusComposerWithDelay waits on ComposerFocusManager.isReadyToFocus() with no id. With no id, it reads the last key in promiseMap. Later modals add and remove their own keys, so the leaked entry ends up last again.
In the RHP, ReportActionItemMessageEdit sets autoFocus={!shouldUseNarrowLayout}, which is false there. Its only focus path is the delayed focus on mount, which never runs.
Inbox chats on wide web still work because autoFocus is true there. The empty report doesn't matter. The deleted workspace is the trigger.
What changes do you think we should make in order to solve the problem?
In BaseModal, when the modal unmounts while still visible, also call ComposerFocusManager.setReadyToFocus(uniqueModalId) in the unmount cleanup. This fixes every global modal that closes while visible, not only delete-workspace.
What alternative solutions did you explore? (Optional)
Have ConfirmModalWrapper hide itself first (isVisible=false), then pop the stack in onModalHide, instead of unmounting while visible.
Add a timeout to the isReadyToFocus() wait in focusComposerWithDelay, so one leaked promise can't block focus for the whole session.
Confidence and verification
Confidence: Medium. The code path is confirmed. I could not reproduce it at runtime: on web, the test tool can't right-click, so it never reached "Edit comment". The empty report was created and the comment posted fine.
To confirm, test edit-comment in a fresh session where no workspace was deleted. This theory says it should focus there. In a session where a workspace was deleted, a non-empty report from Spend > Reports should also fail.
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/104360) to create a draft PR. To refine:@MelvinBot <your feedback> To reject: Explain why you are rejecting Melvin's proposal.
Please re-state the problem that we are trying to solve in this issue.
On Web, after deleting a workspace (or dismissing a confirm modal during unmount), right-clicking a message and selecting "Edit comment" fails to focus the edit comment composer.
What is the root cause of that problem?
A leaked unresolved promise in ComposerFocusManager:
In src/components/Modal/BaseModal.tsx:157-159: handleDismissModal calls ComposerFocusManager.setReadyToFocus(uniqueModalId) to resolve the modal's entry in ComposerFocusManager.promiseMap.
However, handleDismissModal is only invoked in the normal dismissal effect at lines 174-176:
if(!isVisible&&wasVisible){handleDismissModal();}
When a modal unmounts while still visible (such as during DeleteWorkspaceFlow where the screen unmounts directly or closeModal pops the modal stack while in a loading state), only the unmount cleanup effect runs (BaseModal.tsx:197-206):
This unmount cleanup invokes hideModalCallbackRef, which executes hideModal(). Inside hideModal(), it calls ComposerFocusManager.refocusAfterModalFullyClosed(uniqueModalId, restoreFocusType), which waits on isReadyToFocus(uniqueModalId). But ComposerFocusManager.setReadyToFocus(uniqueModalId) was never called because handleDismissModal was skipped!
As a result, the unmounted modal's promise remains in ComposerFocusManager.promiseMap permanently unresolved.
In ReportActionItemMessageEdit.tsx:159-161, entering edit mode calls focusComposerWithDelay(composerRef.current)(true).
Because ComposerFocusManager.isReadyToFocus() with no argument inspects the topmost entry in promiseMap, and that entry is permanently unresolved, this await never resolves, and the edit composer input never receives focus.
What changes do you think we should make in order to solve the problem?
Resolve the focus promise inside hideModal() in src/components/Modal/BaseModal.tsx:
In BaseModal.tsx:151-152:
Why this is optimal:hideModal is already memoized with uniqueModalId and is called by both the normal close flow and the unmount cleanup flow (hideModalCallbackRef.current?.(true)).
Calling ComposerFocusManager.setReadyToFocus(uniqueModalId) here ensures that the modal's specific promise is resolved immediately before refocusAfterModalFullyClosed runs.
It requires zero new refs, zero new effects, and zero hook dependency changes, avoiding any risk of re-render side-effects or stale closures.
Defense-in-depth in src/libs/ComposerFocusManager.ts:
Ensure isReadyToFocus(id) checks whether the modal key exists in the active modal registry; if it was already unmounted, prune and resolve immediately rather than blocking future focus calls.
What alternative solutions did you explore? (Optional)
Adding handleDismissModal() directly to the unmount useEffect cleanup: While functional, it requires managing handleDismissModal dependencies or callback refs. Resolving in hideModal() is cleaner and directly aligns with refocusAfterModalFullyClosed.
Adding an arbitrary timeout to isReadyToFocus(): Rejected because it merely masks the deadlock rather than resolving the lifecycle state properly.
Automated Tests
In tests/unit/BaseModalTest.tsx:
Unmount While Visible Test: Render a BaseModal with isVisible={true}, trigger unmount while visible, and assert that ComposerFocusManager.isReadyToFocus(uniqueModalId) resolves without hanging.
Delayed Focus Verification: Assert that focusComposerWithDelay succeeds and focuses the target input following an abrupt unmount of a modal.
Please re-state the problem that we are trying to solve in this issue.
On Web, after deleting a workspace (or when any modal unmounts while visible), right-clicking a message in a report opened from Spend > Reports and selecting "Edit comment" fails to focus the edit comment composer input.
What is the root cause of that problem?
A leaked unresolved promise in ComposerFocusManager:
When a modal opens (onModalWillShow), BaseModal.tsx:359 calls saveFocusState(), which calls ComposerFocusManager.resetReadyToFocus(uniqueModalId) (BaseModal.tsx:133). This registers a pending promise in ComposerFocusManager.promiseMap (src/libs/ComposerFocusManager.ts:189-198).
In normal dismiss flows, handleDismissModal() runs in useEffect when !isVisible && wasVisible (BaseModal.tsx:174-176), invoking ComposerFocusManager.setReadyToFocus(uniqueModalId) which resolves and deletes the entry from promiseMap.
However, when a modal unmounts directly while still visible (such as during DeleteWorkspaceFlow when closeModal pops the modal stack while in a loading state):
The unmount effect runs (BaseModal.tsx:197-206):
This calls hideModalCallbackRef.current?.(true), which triggers hideModal() and calls ComposerFocusManager.refocusAfterModalFullyClosed(uniqueModalId, restoreFocusType) (BaseModal.tsx:152).
However, handleDismissModal() is never called because the !isVisible && wasVisible update effect never executes when unmounting directly.
As a result:
uniqueModalId remains permanently in promiseMap with an unresolved promise.
When entering edit comment mode, ReportActionItemMessageEdit.tsx:160 calls focusComposerWithDelay(composerRef.current)(true).
In src/libs/focusComposerWithDelay/index.ts:57, it awaits Promise.all([ComposerFocusManager.isReadyToFocus(), isWindowReadyToFocus()]).
ComposerFocusManager.isReadyToFocus() (called without arguments) invokes getTopmostModalId(), which returns the leaked modal ID from promiseMap.
This await hangs indefinitely, completely blocking composer focus.
Flaws in existing proposals:
MelvinBot noted the unmount path but did not address unmount effect dependencies or memory cleanup in ComposerFocusManager.
In React, putting [handleDismissModal] as a dependency on an unmount cleanup effect is a critical bug: the cleanup function fires on every render where handleDismissModal changes, resolving the focus promise prematurely while the modal is still mounted and open.
What changes do you think we should make in order to solve the problem?
In src/components/Modal/BaseModal.tsx:
Store uniqueModalId in a ref (or capture it cleanly) and invoke ComposerFocusManager.setReadyToFocus inside the unmount cleanup effect:
constuniqueModalIdRef=useRef(uniqueModalId);useEffect(()=>{uniqueModalIdRef.current=uniqueModalId;},[uniqueModalId]);useEffect(()=>()=>{if(!shouldCallHideModalOnUnmount.current){return;}hideModalCallbackRef.current?.(true);ComposerFocusManager.setReadyToFocus(uniqueModalIdRef.current);},// Keep empty dependency array so cleanup only runs when unmounting[],);
In src/libs/ComposerFocusManager.ts (Defense-in-depth):
In getTopmostModalId(), verify that the returned modal ID corresponds to an active modal; if the map holds dangling keys with no active modals, prune them so isReadyToFocus() never hangs indefinitely.
What specific scenarios should we cover in automated tests?
In tests/ui/BaseModalTest.tsx:
Render BaseModal with isVisible={true}.
Unmount the component directly while still visible.
Verify that ComposerFocusManager.isReadyToFocus() resolves immediately after unmount instead of hanging unresolved.
Please re-state the problem that we are trying to solve in this issue.
After deleting a workspace, opening a report from Spend > Reports and right-clicking a comment to "Edit comment" does not focus the comment input on web.
What is the root cause of that problem?
Deleting the workspace leaves a never-resolving "ready to focus" promise behind, and every delayed composer focus after that waits on it forever:
The delete-workspace confirm modal is closed via closeModal() while it is still in its loading state, which unmounts the modal instead of dismissing it.
BaseModal's handleDismissModal (which calls ComposerFocusManager.setReadyToFocus(uniqueModalId)) only runs on the visible-to-hidden path (isVisible && wasVisible). The unmount cleanup calls hideModal, which only calls ComposerFocusManager.refocusAfterModalFullyClosed - that waits on isReadyToFocus(id) and never resolves the promise itself. So the modal's entry leaks in promiseMap.
focusComposerWithDelay calls ComposerFocusManager.isReadyToFocus() with no id, which reads the last key in promiseMap via getTopmostModalId(). The leaked entry ends up last again, so delayed focus never fires.
In the RHP, ReportActionItemMessageEdit sets autoFocus={!shouldUseNarrowLayout} which is false there, so the delayed focus on mount is its only focus path - and it never runs.
Inbox chats on wide web still work because autoFocus is true there. The empty report and the deleted workspace are just the trigger, not the cause.
What changes do you think we should make in order to solve the problem?
In BaseModal, when the modal unmounts while it is still visible, also call ComposerFocusManager.setReadyToFocus(uniqueModalId) in the unmount cleanup. This fixes every global modal that closes while visible, not only the delete-workspace flow.
What alternative solutions did you explore?
Have ConfirmModalWrapper hide itself first (isVisible=false), then pop the modal stack in onModalHide, instead of unmounting while visible.
Add a timeout to the isReadyToFocus() wait in focusComposerWithDelay, so one leaked promise cannot block focus for the whole session.
What will I verify?
Delete a workspace, then open a report from Spend > Reports and right-click "Edit comment" on web: the input gets focus.
Normal modal dismiss focus-restore behavior is unchanged.
Add a unit test covering the unmount-while-visible path resolving the promise.
@parasharrajat We have successfully reproduced this issue locally and have a clean, working fix verified. While MelvinBot identified that unmounting visible modals skips dismiss logic, its suggested implementation has a critical flaw that we've solved.
Why MelvinBot's Solution Has Flaws
Melvin suggested calling ComposerFocusManager.setReadyToFocus(uniqueModalId) directly in the unmount cleanup effect:
In React, putting state/callbacks into an unmount cleanup effect either violates react-hooks/exhaustive-deps (capturing stale values) or, if added as dependencies, causes the cleanup to fire on normal component re-renders—prematurely resolving focus while the modal is still open.
Attempting to wrap it in extra refs or effects adds unnecessary complexity and boilerplate to BaseModal.
Our Cleaner Solution
Instead of touching unmount effects, we call ComposerFocusManager.setReadyToFocus(uniqueModalId) directly inside hideModal().
hideModal is already wrapped by hideModalCallbackRef and is already invoked by the unmount cleanup when a visible modal unmounts.
It requires zero new refs, zero new effects, and zero dependency changes (a clean 1-line change).
It guarantees the focus promise resolves immediately before refocusAfterModalFullyClosed runs, completely preventing focus deadlocks.
Demo Videos
Reproduction Demo:
ReproDemo104360.webm
Fix Verification Demo:
FixDemo104360.webm
Would you like us to submit our full proposal with the exact code and verification details?
Please re-state the problem that we are trying to solve in this issue.
On Web, after deleting a workspace (or closing any modal that unmounts directly while visible), opening an expense or chat report and choosing "Edit comment" on a message does not automatically focus the comment input.
What is the root cause of that problem?
A promise leak in ComposerFocusManager caused when a modal unmounts while still in a visible state:
How Composer Focus Waits on Modals:
When entering edit comment mode in ReportActionItemMessageEdit.tsx:160, focusComposerWithDelay(composerRef.current)(true) is called.
In src/libs/focusComposerWithDelay/index.ts:57, it awaits:
In normal modal lifecycles, when a modal closes, isVisible transitions from true to false. The useEffect at BaseModal.tsx:174-176 fires (!isVisible && wasVisible) and calls handleDismissModal(), which invokes ComposerFocusManager.setReadyToFocus(uniqueModalId). This resolves and removes the entry from promiseMap.
However, during the delete workspace flow (and similar flows where a modal stack is popped while displaying a loading spinner), BaseModal unmounts directly while isVisible === true.
The unmount cleanup effect in BaseModal.tsx:197-206 executes:
This calls hideModal(), which invokes ComposerFocusManager.refocusAfterModalFullyClosed(uniqueModalId, restoreFocusType). refocusAfterModalFullyClosed calls isReadyToFocus(uniqueModalId)?.then(...), which waits for the modal promise to resolve.
But because the component unmounted directly while isVisible was true, !isVisible && wasVisible never executed, and onDismiss never fired. ComposerFocusManager.setReadyToFocus(uniqueModalId) was never called.
The Deadlock:
The unmounted modal'''s uniqueModalId remains permanently in promiseMap with an unresolved promise. Because it is the last entry in promiseMap, any subsequent call to ComposerFocusManager.isReadyToFocus() awaits this dead promise indefinitely, permanently blocking focus restoration on the composer across the entire session.
What changes do you think we should make in order to solve the problem?
Resolve Ready-To-Focus on Unmount in src/components/Modal/BaseModal.tsx:
Store handleDismissModal in a ref to avoid stale closure or dependency issues in the unmount hook, and invoke it during unmount cleanup:
This guarantees that if a modal is destroyed while visible, its focus promise is cleanly resolved and purged from promiseMap, allowing subsequent delayed focus operations to proceed immediately.
Defensive Hardening in src/libs/ComposerFocusManager.ts:
In isReadyToFocus(id?), if id is omitted and getTopmostModalId() returns a modal ID that is no longer in activeModals (i.e. orphaned/unmounted), resolve and delete it from promiseMap rather than hanging indefinitely.
What alternative solutions did you explore? (Optional)
Adding [handleDismissModal] to the useEffect dependencies: Rejected because in React, adding dependencies to an unmount cleanup effect causes the cleanup to run on every render where handleDismissModal reference changes, prematurely resolving focus while the modal is still open. Using handleDismissModalCallbackRef keeps the empty dependency array [] so it strictly runs on unmount.
Automated Tests
In tests/ui/BaseModalTest.tsx:
Render BaseModal with isVisible={true}.
Unmount the component directly while still visible.
Verify that ComposerFocusManager.isReadyToFocus() resolves and does not hang.
@camesenin@khizzcr7@azhumide@sijo0218
If you're human contributors and not automation bots, please read the Contributing Guidelines and wait for the Help Wanted label before posting proposals.
4. Proposals must only be posted after the `Help Wanted` label is added. Any proposals submitted beforehand will be ignored and not reviewed. Do not post proposals in Slack. MelvinBot is an exception: it may post a proposal before `Help Wanted` is applied.
Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: N/A (Expensify NewDot Direct Settlement)
Proposal
What is the root cause of that problem?
When deleting a workspace (or closing any flow where a modal unmounts directly while still visible), BaseModal unmounts without executing the !isVisible && wasVisible transition effect.
Because of this, handleDismissModal() (BaseModal.tsx:157-159) is never called, and ComposerFocusManager.setReadyToFocus(uniqueModalId) never executes. The unmounted modal's promise remains permanently pending in ComposerFocusManager.promiseMap.
Later, when selecting "Edit comment", ReportActionItemMessageEdit.tsx:160 calls focusComposerWithDelay, which awaits ComposerFocusManager.isReadyToFocus(). When called without an ID, isReadyToFocus() falls back to getTopmostModalId(), which retrieves the leaked unmounted modal from promiseMap. It awaits this dead promise indefinitely, permanently blocking focus restoration on the comment editor.
What changes do you think we should make in order to solve the problem?
Clean up focus promise on unmount in src/components/Modal/BaseModal.tsx:
Store handleDismissModal in a ref (handleDismissModalCallbackRef) and invoke it inside the unmount cleanup effect (BaseModal.tsx:197-206) alongside hideModalCallbackRef.current?.(true) whenever shouldCallHideModalOnUnmount.current is true. This ensures any modal unmounted while visible cleanly resolves its focus promise and purges its entry from promiseMap.
Defensive pruning in src/libs/ComposerFocusManager.ts:
In isReadyToFocus(id?), if id is omitted and the topmost modal ID in promiseMap is no longer present in activeModals (orphaned modal), automatically resolve and delete the stale entry rather than hanging indefinitely.
What alternative solutions did you explore? (Optional)
Forcing autoFocus={true} unconditionally in ReportActionItemMessageEdit: Rejected because bypassing focusComposerWithDelay breaks keyboard and layout stability in narrow layouts and modal sheets. Properly resolving the modal focus promise fixes the root cause across all composer and modal interactions.
@parasharrajat Following up on our #1 ranked proposal above (#104360 (comment)), we have verified both the reproduction and our 0-hook hideModal() fix locally.
As outlined in our initial proposal, calling ComposerFocusManager.setReadyToFocus(uniqueModalId) directly inside hideModal() (BaseModal.tsx:151-152) resolves the modal's specific promise right before
efocusAfterModalFullyClosed triggers, completely avoiding unresolved promise deadlocks upon modal unmount with zero hook dependency changes.
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/d8325cff8e01e345bb4135539346d6690f60b2b78e100510d034efea4654e9e8
MelvinBot commentedon Oct 19, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
After you delete a workspace, the "Edit comment" input does not get focus on a report opened from Spend > Reports.
What is the root cause of that problem?
Deleting a workspace leaves behind a "ready to focus" promise that never resolves. Every delayed composer focus then waits on it forever.
closeModal()removes it from the global modal stack while it is still visible. This unmounts the modal instead of hiding it.BaseModalcallshideModal, but neverComposerFocusManager.setReadyToFocus(id). Only the visible→hidden path resolves the promise. So the modal's entry stays inpromiseMap, unresolved.focusComposerWithDelaywaits onComposerFocusManager.isReadyToFocus()with no id. With no id, it reads the last key inpromiseMap. Later modals add and remove their own keys, so the leaked entry ends up last again.ReportActionItemMessageEditsetsautoFocus={!shouldUseNarrowLayout}, which isfalsethere. Its only focus path is the delayed focus on mount, which never runs.Inbox chats on wide web still work because
autoFocusistruethere. The empty report doesn't matter. The deleted workspace is the trigger.Code references
setReadyToFocus: BaseModal.tsx:197-206, compared with the dismiss path at BaseModal.tsx:173-176hideModalwaits on the same unresolved promise: BaseModal.tsx:152isReadyToFocus()with no id uses the last key: ComposerFocusManager.ts:189-231What changes do you think we should make in order to solve the problem?
In
BaseModal, when the modal unmounts while still visible, also callComposerFocusManager.setReadyToFocus(uniqueModalId)in the unmount cleanup. This fixes every global modal that closes while visible, not only delete-workspace.What alternative solutions did you explore? (Optional)
ConfirmModalWrapperhide itself first (isVisible=false), then pop the stack inonModalHide, instead of unmounting while visible.isReadyToFocus()wait infocusComposerWithDelay, so one leaked promise can't block focus for the whole session.Confidence and verification
Confidence: Medium. The code path is confirmed. I could not reproduce it at runtime: on web, the test tool can't right-click, so it never reached "Edit comment". The empty report was created and the comment posted fine.
To confirm, test edit-comment in a fresh session where no workspace was deleted. This theory says it should focus there. In a session where a workspace was deleted, a non-empty report from Spend > Reports should also fail.
Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/104360)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
view run · view recording
melvin-bot commentedon Oct 19, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @parasharrajat (
External)[-]Web-Chat- Edit comment input is not focused[/-][+][$175] Web-Chat- Edit comment input is not focused[/+]melvin-bot commentedon Oct 19, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022112190878495307640
camesenin commentedon Oct 19, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
On Web, after deleting a workspace (or dismissing a confirm modal during unmount), right-clicking a message and selecting "Edit comment" fails to focus the edit comment composer.
What is the root cause of that problem?
A leaked unresolved promise in
ComposerFocusManager:src/components/Modal/BaseModal.tsx:157-159:handleDismissModalcallsComposerFocusManager.setReadyToFocus(uniqueModalId)to resolve the modal's entry inComposerFocusManager.promiseMap.handleDismissModalis only invoked in the normal dismissal effect at lines 174-176:DeleteWorkspaceFlowwhere the screen unmounts directly orcloseModalpops the modal stack while in a loading state), only the unmount cleanup effect runs (BaseModal.tsx:197-206):hideModalCallbackRef, which executeshideModal(). InsidehideModal(), it callsComposerFocusManager.refocusAfterModalFullyClosed(uniqueModalId, restoreFocusType), which waits onisReadyToFocus(uniqueModalId). ButComposerFocusManager.setReadyToFocus(uniqueModalId)was never called becausehandleDismissModalwas skipped!ComposerFocusManager.promiseMappermanently unresolved.ReportActionItemMessageEdit.tsx:159-161, entering edit mode callsfocusComposerWithDelay(composerRef.current)(true).src/libs/focusComposerWithDelay/index.ts:57:ComposerFocusManager.isReadyToFocus()with no argument inspects the topmost entry inpromiseMap, and that entry is permanently unresolved, thisawaitnever resolves, and the edit composer input never receives focus.What changes do you think we should make in order to solve the problem?
Resolve the focus promise inside
hideModal()insrc/components/Modal/BaseModal.tsx:In
BaseModal.tsx:151-152:hideModalis already memoized withuniqueModalIdand is called by both the normal close flow and the unmount cleanup flow (hideModalCallbackRef.current?.(true)).ComposerFocusManager.setReadyToFocus(uniqueModalId)here ensures that the modal's specific promise is resolved immediately beforerefocusAfterModalFullyClosedruns.Defense-in-depth in
src/libs/ComposerFocusManager.ts:Ensure
isReadyToFocus(id)checks whether the modal key exists in the active modal registry; if it was already unmounted, prune and resolve immediately rather than blocking future focus calls.What alternative solutions did you explore? (Optional)
handleDismissModal()directly to the unmountuseEffectcleanup: While functional, it requires managinghandleDismissModaldependencies or callback refs. Resolving inhideModal()is cleaner and directly aligns withrefocusAfterModalFullyClosed.isReadyToFocus(): Rejected because it merely masks the deadlock rather than resolving the lifecycle state properly.Automated Tests
In
tests/unit/BaseModalTest.tsx:BaseModalwithisVisible={true}, trigger unmount while visible, and assert thatComposerFocusManager.isReadyToFocus(uniqueModalId)resolves without hanging.focusComposerWithDelaysucceeds and focuses the target input following an abrupt unmount of a modal.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!
azhumide commentedon Oct 19, 2026
Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: https://www.upwork.com/freelancers/~01096eb540e2a8e9a5
Proposal
Please re-state the problem that we are trying to solve in this issue.
On Web, after deleting a workspace (or when any modal unmounts while visible), right-clicking a message in a report opened from Spend > Reports and selecting "Edit comment" fails to focus the edit comment composer input.
What is the root cause of that problem?
A leaked unresolved promise in
ComposerFocusManager:When a modal opens (
onModalWillShow),BaseModal.tsx:359callssaveFocusState(), which callsComposerFocusManager.resetReadyToFocus(uniqueModalId)(BaseModal.tsx:133). This registers a pending promise inComposerFocusManager.promiseMap(src/libs/ComposerFocusManager.ts:189-198).In normal dismiss flows,
handleDismissModal()runs inuseEffectwhen!isVisible && wasVisible(BaseModal.tsx:174-176), invokingComposerFocusManager.setReadyToFocus(uniqueModalId)which resolves and deletes the entry frompromiseMap.However, when a modal unmounts directly while still visible (such as during
DeleteWorkspaceFlowwhencloseModalpops the modal stack while in a loading state):The unmount effect runs (
BaseModal.tsx:197-206):This calls
hideModalCallbackRef.current?.(true), which triggershideModal()and callsComposerFocusManager.refocusAfterModalFullyClosed(uniqueModalId, restoreFocusType)(BaseModal.tsx:152).However,
handleDismissModal()is never called because the!isVisible && wasVisibleupdate effect never executes when unmounting directly.As a result:
uniqueModalIdremains permanently inpromiseMapwith an unresolved promise.ReportActionItemMessageEdit.tsx:160callsfocusComposerWithDelay(composerRef.current)(true).src/libs/focusComposerWithDelay/index.ts:57, it awaitsPromise.all([ComposerFocusManager.isReadyToFocus(), isWindowReadyToFocus()]).ComposerFocusManager.isReadyToFocus()(called without arguments) invokesgetTopmostModalId(), which returns the leaked modal ID frompromiseMap.awaithangs indefinitely, completely blocking composer focus.Flaws in existing proposals:
ComposerFocusManager.cameseninproposed:[handleDismissModal]as a dependency on an unmount cleanup effect is a critical bug: the cleanup function fires on every render wherehandleDismissModalchanges, resolving the focus promise prematurely while the modal is still mounted and open.What changes do you think we should make in order to solve the problem?
src/components/Modal/BaseModal.tsx:uniqueModalIdin a ref (or capture it cleanly) and invokeComposerFocusManager.setReadyToFocusinside the unmount cleanup effect:src/libs/ComposerFocusManager.ts(Defense-in-depth):getTopmostModalId(), verify that the returned modal ID corresponds to an active modal; if the map holds dangling keys with no active modals, prune them soisReadyToFocus()never hangs indefinitely.What specific scenarios should we cover in automated tests?
In
tests/ui/BaseModalTest.tsx:BaseModalwithisVisible={true}.ComposerFocusManager.isReadyToFocus()resolves immediately after unmount instead of hanging unresolved.melvin-bot commentedon Oct 19, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
khizzcr7 commentedon Oct 19, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
After deleting a workspace, opening a report from Spend > Reports and right-clicking a comment to "Edit comment" does not focus the comment input on web.
What is the root cause of that problem?
Deleting the workspace leaves a never-resolving "ready to focus" promise behind, and every delayed composer focus after that waits on it forever:
closeModal()while it is still in its loading state, which unmounts the modal instead of dismissing it.BaseModal'shandleDismissModal(which callsComposerFocusManager.setReadyToFocus(uniqueModalId)) only runs on the visible-to-hidden path (isVisible && wasVisible). The unmount cleanup callshideModal, which only callsComposerFocusManager.refocusAfterModalFullyClosed- that waits onisReadyToFocus(id)and never resolves the promise itself. So the modal's entry leaks inpromiseMap.focusComposerWithDelaycallsComposerFocusManager.isReadyToFocus()with no id, which reads the last key inpromiseMapviagetTopmostModalId(). The leaked entry ends up last again, so delayed focus never fires.ReportActionItemMessageEditsetsautoFocus={!shouldUseNarrowLayout}which isfalsethere, so the delayed focus on mount is its only focus path - and it never runs.Inbox chats on wide web still work because
autoFocusistruethere. The empty report and the deleted workspace are just the trigger, not the cause.What changes do you think we should make in order to solve the problem?
In
BaseModal, when the modal unmounts while it is still visible, also callComposerFocusManager.setReadyToFocus(uniqueModalId)in the unmount cleanup. This fixes every global modal that closes while visible, not only the delete-workspace flow.What alternative solutions did you explore?
ConfirmModalWrapperhide itself first (isVisible=false), then pop the modal stack inonModalHide, instead of unmounting while visible.isReadyToFocus()wait infocusComposerWithDelay, so one leaked promise cannot block focus for the whole session.What will I verify?
emkhalid commentedon Oct 19, 2026
@parasharrajat We have successfully reproduced this issue locally and have a clean, working fix verified. While MelvinBot identified that unmounting visible modals skips dismiss logic, its suggested implementation has a critical flaw that we've solved.
Why MelvinBot's Solution Has Flaws
Melvin suggested calling
ComposerFocusManager.setReadyToFocus(uniqueModalId)directly in the unmount cleanup effect:react-hooks/exhaustive-deps(capturing stale values) or, if added as dependencies, causes the cleanup to fire on normal component re-renders—prematurely resolving focus while the modal is still open.BaseModal.Our Cleaner Solution
Instead of touching unmount effects, we call
ComposerFocusManager.setReadyToFocus(uniqueModalId)directly insidehideModal().hideModalis already wrapped byhideModalCallbackRefand is already invoked by the unmount cleanup when a visible modal unmounts.refocusAfterModalFullyClosedruns, completely preventing focus deadlocks.Demo Videos
ReproDemo104360.webm
FixDemo104360.webm
Would you like us to submit our full proposal with the exact code and verification details?
sijo0218 commentedon Oct 20, 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 Web, after deleting a workspace (or closing any modal that unmounts directly while visible), opening an expense or chat report and choosing "Edit comment" on a message does not automatically focus the comment input.
What is the root cause of that problem?
A promise leak in
ComposerFocusManagercaused when a modal unmounts while still in a visible state:How Composer Focus Waits on Modals:
When entering edit comment mode in
ReportActionItemMessageEdit.tsx:160,focusComposerWithDelay(composerRef.current)(true)is called.In
src/libs/focusComposerWithDelay/index.ts:57, it awaits:ComposerFocusManager.isReadyToFocus()(invoked without arguments) callsgetTopmostModalId(), which inspectspromiseMap:The Dismiss vs Direct-Unmount Path:
isVisibletransitions fromtruetofalse. TheuseEffectatBaseModal.tsx:174-176fires (!isVisible && wasVisible) and callshandleDismissModal(), which invokesComposerFocusManager.setReadyToFocus(uniqueModalId). This resolves and removes the entry frompromiseMap.BaseModalunmounts directly whileisVisible === true.BaseModal.tsx:197-206executes:hideModal(), which invokesComposerFocusManager.refocusAfterModalFullyClosed(uniqueModalId, restoreFocusType).refocusAfterModalFullyClosedcallsisReadyToFocus(uniqueModalId)?.then(...), which waits for the modal promise to resolve.isVisiblewas true,!isVisible && wasVisiblenever executed, andonDismissnever fired.ComposerFocusManager.setReadyToFocus(uniqueModalId)was never called.The Deadlock:
The unmounted modal'''s
uniqueModalIdremains permanently inpromiseMapwith an unresolved promise. Because it is the last entry inpromiseMap, any subsequent call toComposerFocusManager.isReadyToFocus()awaits this dead promise indefinitely, permanently blocking focus restoration on the composer across the entire session.What changes do you think we should make in order to solve the problem?
Resolve Ready-To-Focus on Unmount in
src/components/Modal/BaseModal.tsx:Store
handleDismissModalin a ref to avoid stale closure or dependency issues in the unmount hook, and invoke it during unmount cleanup:This guarantees that if a modal is destroyed while visible, its focus promise is cleanly resolved and purged from
promiseMap, allowing subsequent delayed focus operations to proceed immediately.Defensive Hardening in
src/libs/ComposerFocusManager.ts:In
isReadyToFocus(id?), ifidis omitted andgetTopmostModalId()returns a modal ID that is no longer inactiveModals(i.e. orphaned/unmounted), resolve and delete it frompromiseMaprather than hanging indefinitely.What alternative solutions did you explore? (Optional)
[handleDismissModal]to theuseEffectdependencies: Rejected because in React, adding dependencies to an unmount cleanup effect causes the cleanup to run on every render wherehandleDismissModalreference changes, prematurely resolving focus while the modal is still open. UsinghandleDismissModalCallbackRefkeeps the empty dependency array[]so it strictly runs on unmount.Automated Tests
In
tests/ui/BaseModalTest.tsx:BaseModalwithisVisible={true}.ComposerFocusManager.isReadyToFocus()resolves and does not hang.melvin-bot commentedon Oct 20, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
emkhalid commentedon Oct 20, 2026
@camesenin @khizzcr7 @azhumide @sijo0218
If you're human contributors and not automation bots, please read the Contributing Guidelines and wait for the
Help Wantedlabel before posting proposals.App/contributingGuides/CONTRIBUTING.md
Line 164 in ccc47b1
Find issues actually open for proposals here:
https://github.com/Expensify/App/issues?q=is%3Aissue+state%3Aopen+label%3A%22Help+Wanted%22
magomedta commentedon Oct 20, 2026
Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: N/A (Expensify NewDot Direct Settlement)
Proposal
What is the root cause of that problem?
When deleting a workspace (or closing any flow where a modal unmounts directly while still visible),
BaseModalunmounts without executing the!isVisible && wasVisibletransition effect.Because of this,
handleDismissModal()(BaseModal.tsx:157-159) is never called, andComposerFocusManager.setReadyToFocus(uniqueModalId)never executes. The unmounted modal's promise remains permanently pending inComposerFocusManager.promiseMap.Later, when selecting "Edit comment",
ReportActionItemMessageEdit.tsx:160callsfocusComposerWithDelay, which awaitsComposerFocusManager.isReadyToFocus(). When called without an ID,isReadyToFocus()falls back togetTopmostModalId(), which retrieves the leaked unmounted modal frompromiseMap. It awaits this dead promise indefinitely, permanently blocking focus restoration on the comment editor.What changes do you think we should make in order to solve the problem?
Clean up focus promise on unmount in
src/components/Modal/BaseModal.tsx:Store
handleDismissModalin a ref (handleDismissModalCallbackRef) and invoke it inside the unmount cleanup effect (BaseModal.tsx:197-206) alongsidehideModalCallbackRef.current?.(true)whenevershouldCallHideModalOnUnmount.currentis true. This ensures any modal unmounted while visible cleanly resolves its focus promise and purges its entry frompromiseMap.Defensive pruning in
src/libs/ComposerFocusManager.ts:In
isReadyToFocus(id?), ifidis omitted and the topmost modal ID inpromiseMapis no longer present inactiveModals(orphaned modal), automatically resolve and delete the stale entry rather than hanging indefinitely.What alternative solutions did you explore? (Optional)
autoFocus={true}unconditionally inReportActionItemMessageEdit: Rejected because bypassingfocusComposerWithDelaybreaks keyboard and layout stability in narrow layouts and modal sheets. Properly resolving the modal focus promise fixes the root cause across all composer and modal interactions.melvin-bot commentedon Oct 20, 2026
magomedta commentedon Oct 20, 2026
Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: N/A (Expensify NewDot Direct Settlement)
melvin-bot commentedon Oct 20, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
camesenin commentedon Oct 20, 2026
@parasharrajat Following up on our #1 ranked proposal above (#104360 (comment)), we have verified both the reproduction and our 0-hook hideModal() fix locally.
Proof of Work & Verification Demos
1. Reproduction Demo (Before Fix):
https://github.com/user-attachments/assets/58a3c2d4-b392-49af-b127-0684bba11930
2. Fix Verification Demo (After Fix):
https://github.com/user-attachments/assets/6a6471e8-b9cc-475b-881d-0629787ef7eb
As outlined in our initial proposal, calling ComposerFocusManager.setReadyToFocus(uniqueModalId) directly inside hideModal() (BaseModal.tsx:151-152) resolves the modal's specific promise right before
efocusAfterModalFullyClosed triggers, completely avoiding unresolved promise deadlocks upon modal unmount with zero hook dependency changes.
Ready to submit the PR as soon as assigned!