From 249a6674da2a89a6fbb8e4200119b0edfefa7f47 Mon Sep 17 00:00:00 2001 From: FitseTLT Date: Thu, 9 Apr 2026 17:34:53 +0300 Subject: [PATCH 1/4] implemented passing constant id to ModalContext --- src/components/ImportSpreadsheetConfirmModal.tsx | 6 +++++- src/components/Modal/Global/ModalContext.tsx | 13 +++++++++---- src/hooks/useConfirmModal.ts | 7 +++++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/components/ImportSpreadsheetConfirmModal.tsx b/src/components/ImportSpreadsheetConfirmModal.tsx index bedb094e29364..e2b3043cd4fb7 100644 --- a/src/components/ImportSpreadsheetConfirmModal.tsx +++ b/src/components/ImportSpreadsheetConfirmModal.tsx @@ -34,6 +34,7 @@ function ImportSpreadsheetConfirmModal({isVisible, closeImportPageAndModal, onMo return; } showConfirmModal({ + id: 'import-spreadsheet-confirm-modal', title: titleText, prompt: promptText, confirmText: translate('common.buttonConfirm'), @@ -43,7 +44,10 @@ function ImportSpreadsheetConfirmModal({isVisible, closeImportPageAndModal, onMo }).then(() => { closeImportPageAndModal(); }); - }, [isVisible, titleText, promptText, closeImportPageAndModal, onModalHide, shouldHandleNavigationBack, showConfirmModal, translate, spreadsheet?.importFinalModal]); + + // We don't need the callbacks as dependencies. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isVisible, titleText, promptText, spreadsheet?.importFinalModal, shouldHandleNavigationBack]); return null; } diff --git a/src/components/Modal/Global/ModalContext.tsx b/src/components/Modal/Global/ModalContext.tsx index 28f3d10feb384..53aaa177a2f18 100644 --- a/src/components/Modal/Global/ModalContext.tsx +++ b/src/components/Modal/Global/ModalContext.tsx @@ -46,7 +46,7 @@ function ModalProvider({children}: {children: React.ReactNode}) { // This is a promise that will resolve when the modal is closed let closeModalPromise: CloseModalPromiseWithResolvers | null = id ? modalPromisesStack.current?.[id] : null; - const newModalId = id ?? String(modalIDRef.current++); + const modalID = id ?? String(modalIDRef.current++); if (!closeModalPromise) { // Create a new promise with resolvers to be resolved when the modal is closed @@ -56,12 +56,17 @@ function ModalProvider({children}: {children: React.ReactNode}) { // New modal => update modals stack setModalStack((prevState) => ({ ...prevState, - modals: [...prevState.modals, {component: component as React.FunctionComponent, props, isCloseable, id: newModalId}], + modals: [...prevState.modals, {component: component as React.FunctionComponent, props, isCloseable, id: modalID}], })); + modalPromisesStack.current[modalID] = closeModalPromise; + } else { + // If it is an existing modal, update props in place instead of stacking a new modal + setModalStack((prevState) => { + const modalsExcludingCurrentModal = prevState.modals.filter((modal) => modal.id !== id); + return {...prevState, modals: [...modalsExcludingCurrentModal, {component: component as React.FunctionComponent, props, isCloseable, id: modalID}]}; + }); } - modalPromisesStack.current[newModalId] = closeModalPromise; - return closeModalPromise.promise; }; diff --git a/src/hooks/useConfirmModal.ts b/src/hooks/useConfirmModal.ts index bb2b7815e6067..7fa66961cfb82 100644 --- a/src/hooks/useConfirmModal.ts +++ b/src/hooks/useConfirmModal.ts @@ -2,14 +2,17 @@ import ConfirmModalWrapper from '@components/Modal/Global/ConfirmModalWrapper'; import type {ModalProps} from '@components/Modal/Global/ModalContext'; import {useModal} from '@components/Modal/Global/ModalContext'; -type ConfirmModalOptions = Omit, keyof ModalProps>; +type ConfirmModalOptions = Omit, keyof ModalProps> & { + id?: string; +}; const useConfirmModal = () => { const context = useModal(); - const showConfirmModal = (options: ConfirmModalOptions) => { + const showConfirmModal = ({id, ...options}: ConfirmModalOptions) => { return context.showModal({ component: ConfirmModalWrapper, + id, props: { shouldHandleNavigationBack: true, ...options, From 975ba23dd7275a88365adc6f87a5a5eb0c991dd2 Mon Sep 17 00:00:00 2001 From: FitseTLT Date: Thu, 9 Apr 2026 23:41:40 +0300 Subject: [PATCH 2/4] kept modal stack order when updating modal --- src/components/Modal/Global/ModalContext.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/Modal/Global/ModalContext.tsx b/src/components/Modal/Global/ModalContext.tsx index 53aaa177a2f18..520c15f6f6bd6 100644 --- a/src/components/Modal/Global/ModalContext.tsx +++ b/src/components/Modal/Global/ModalContext.tsx @@ -62,8 +62,13 @@ function ModalProvider({children}: {children: React.ReactNode}) { } else { // If it is an existing modal, update props in place instead of stacking a new modal setModalStack((prevState) => { - const modalsExcludingCurrentModal = prevState.modals.filter((modal) => modal.id !== id); - return {...prevState, modals: [...modalsExcludingCurrentModal, {component: component as React.FunctionComponent, props, isCloseable, id: modalID}]}; + const modals = prevState.modals.map((modal) => { + if (modal.id === id) { + return {component: component as React.FunctionComponent, props, isCloseable, id: modalID}; + } + return modal; + }); + return {...prevState, modals}; }); } From 79b4244be9fe0f3c2e5c1fcd5d5311748f3ecef8 Mon Sep 17 00:00:00 2001 From: FitseTLT Date: Sat, 11 Apr 2026 18:04:57 +0300 Subject: [PATCH 3/4] update comment --- .../ImportSpreadsheetConfirmModal.tsx | 3 +- src/pages/inbox/report/ReportActionCompose/w | 63 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/w diff --git a/src/components/ImportSpreadsheetConfirmModal.tsx b/src/components/ImportSpreadsheetConfirmModal.tsx index e2b3043cd4fb7..f4ee4a10203d4 100644 --- a/src/components/ImportSpreadsheetConfirmModal.tsx +++ b/src/components/ImportSpreadsheetConfirmModal.tsx @@ -45,7 +45,8 @@ function ImportSpreadsheetConfirmModal({isVisible, closeImportPageAndModal, onMo closeImportPageAndModal(); }); - // We don't need the callbacks as dependencies. + // We don't need the callbacks as dependencies as they are unstable + // references that cause an infinite re-render loop. // eslint-disable-next-line react-hooks/exhaustive-deps }, [isVisible, titleText, promptText, spreadsheet?.importFinalModal, shouldHandleNavigationBack]); diff --git a/src/pages/inbox/report/ReportActionCompose/w b/src/pages/inbox/report/ReportActionCompose/w new file mode 100644 index 0000000000000..fcf6b8bc49d2c --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/w @@ -0,0 +1,63 @@ +## Proposal +### Please re-state the problem that we are trying to solve in this issue. +The first mention (User A) is removed after adding the second mention. +When a user adds an @mention for User A, moves the cursor to the beginning of the message (before that mention), and then adds an @mention for User B, the first mention (User A) is removed. Only the second mention (User B) remains in the composer. Only the second mention remains. +### What is the root cause of that problem? +When a second mention is inserted at the beginning of text that already starts with a mention, the replacement range is computed too broadly. +The bug is in `insertSelectedMention` inside `SuggestionMention.tsx`. When a suggestion is selected, the function rebuilds the composer string by slicing `value` into three parts: everything before the `@` sign, the new mention code, and everything after the original typed token. The "after" slice is computed using `getOriginalMentionText`, which scans forward from `atSignIndex` until it hits a `MENTION_BREAKER` character. +`getOriginalMentionText()` uses `MENTION_BREAKER`, and `MENTION_BREAKER` does not break on `@`. So for a value like `@b@userA `, the original mention text becomes `@b@userA` instead of only `@b`. +https://github.com/Expensify/App/blob/00ddfe502e9421db04431d68a68f62bdbdec5ede/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx#L195-L210 +https://github.com/Expensify/App/blob/b51e63c92c19fc67ee3df597a148e12355580572/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx#L208-L210 +The `MENTION_BREAKER` regex matches whitespace, newlines, and certain punctuation — but crucially **`@` is not a breaker**, because `@` is valid inside email-style mentions like `user@domain.com`. +https://github.com/Expensify/App/blob/b51e63c92c19fc67ee3df597a148e12355580572/src/CONST/index.ts#L4278-L4280 +https://github.com/Expensify/App/blob/00ddfe502e9421db04431d68a68f62bdbdec5ede/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx#L241-L243 +Then `insertSelectedMention()` uses: +When the user moves the cursor to position 0 and types `@UserB`, the value becomes `@UserB@UserA@example.com` with no space between the two tokens (since the existing mention begins immediately where the cursor was placed). `getOriginalMentionText` scans from position 0 and finds no mention breaker until the space after the existing mention, so it returns the entire combined string `"@UserB@UserA@example.com"` as the token to replace. `Math.max` then picks this longer length over the typed prefix length, causing `commentAfterMention` to slice past the existing User A mention entirely — deleting it. +https://github.com/Expensify/App/blob/b51e63c92c19fc67ee3df597a148e12355580572/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx#L241-L243 +### What changes do you think we should make in order to solve the problem? +`Math.max(mentionToReplace.length, suggestionValues.mentionPrefix.length + suggestionValues.prefixType.length)` picks the longer span (`@b@userA`), so the existing mention (User A) is consumed and removed. +In `insertSelectedMention`, after computing `mentionToReplace` from `getOriginalMentionText`, check whether the portion of that string that lies beyond what the user actually typed starts with a new `@` or `#` token. If it does, and that portion is not part of the selected mention code itself, clip `mentionToReplace` back to only the typed length. This ensures the adjacent mention is treated as a separate entity and preserved in `commentAfterMention`. +### What changes do you think we should make in order to solve the problem? +https://github.com/Expensify/App/blob/00ddfe502e9421db04431d68a68f62bdbdec5ede/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx#L241-L243 https://github.com/Expensify/App/blob/b51e63c92c19fc67ee3df597a148e12355580572/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx#L224-L243 +```typescript +const typedLength = suggestionValues.mentionPrefix.length + suggestionValues.prefixType.length; const originalMention = getOriginalMentionText(value, suggestionValues.atSignIndex, StringUtils.countWhiteSpaces(suggestionValues.mentionPrefix)); +if (mentionToReplace.length > typedLength) { +const textAfterCursor = mentionToReplace.slice(typedLength); // Limit replacement to the currently typed mention token when another mention starts immediately after it. +if (textAfterCursor.startsWith('@') || textAfterCursor.startsWith('#')) { const typedMentionToken = `${suggestionValues.prefixType}${suggestionValues.mentionPrefix}`; +if (!mentionCode.toLowerCase().includes(textAfterCursor.toLowerCase())) { let mentionToReplace = originalMention; +mentionToReplace = mentionToReplace.slice(0, typedLength); if (typedMentionToken.length > 0 && mentionToReplace.startsWith(typedMentionToken)) { +} const nextMentionOffset = mentionToReplace.slice(typedMentionToken.length).search(/[@#]/); +} if (nextMentionOffset !== -1) { +} mentionToReplace = mentionToReplace.slice(0, typedMentionToken.length + nextMentionOffset); +} +const commentAfterMention = value.slice( } +suggestionValues.atSignIndex + Math.max(mentionToReplace.length, typedLength), +); let trailingDot = ''; +``` if (suggestionValues.prefixType === '@' && suggestionValues.mentionPrefix.endsWith('.')) { +trailingDot = mentionToReplace.match(CONST.REGEX.TRAILING_DOTS)?.[0] ?? ''; +### Demo mentionToReplace = mentionToReplace.slice(0, mentionToReplace.length - trailingDot.length); +https://github.com/user-attachments/assets/96160da5-f629-41d1-aba0-65e9e3ebc1df } +const commentAfterMention = value.slice( +suggestionValues.atSignIndex + Math.max(mentionToReplace.length, suggestionValues.mentionPrefix.length + suggestionValues.prefixType.length), +); +``` +This keeps replacement scoped to the active mention being typed, so inserting User B at the start does not delete adjacent existing mention tokens (User A). +https://github.com/Expensify/App/blob/b51e63c92c19fc67ee3df597a148e12355580572/tests/unit/SuggestionMentionTest.tsx#L164-L246 +```typescript +it('keeps existing mention when inserting a new mention at the beginning of the message', async () => { +mockPersonalDetails = {}; +mockPersonalDetails[2] = { +accountID: 2, +login: 'bob@example.com', +firstName: 'Bob', +lastName: 'Tester', +}; +const updateComment = jest.fn(); +const {setSelection} = renderSuggestionMention('@b@usera@example.com ', updateComment, {start: 2, end: 2}); +await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); +const {onSelect} = getLastMentionSuggestionsProps(); +act(() => onSelect(0)); +expect(updateComment).toHaveBeenCalledWith('@bob@example.com @usera@example.com ', true); +expect(setSelection).toHaveBeenCalledWith({start: 17, end: 17}); +}); +``` From ce4b1a5c544c1bcf94bc30c3b1bbddbcb6997ae1 Mon Sep 17 00:00:00 2001 From: FitseTLT Date: Sat, 11 Apr 2026 18:06:00 +0300 Subject: [PATCH 4/4] revert unnecessary update --- src/pages/inbox/report/ReportActionCompose/w | 63 -------------------- 1 file changed, 63 deletions(-) delete mode 100644 src/pages/inbox/report/ReportActionCompose/w diff --git a/src/pages/inbox/report/ReportActionCompose/w b/src/pages/inbox/report/ReportActionCompose/w deleted file mode 100644 index fcf6b8bc49d2c..0000000000000 --- a/src/pages/inbox/report/ReportActionCompose/w +++ /dev/null @@ -1,63 +0,0 @@ -## Proposal -### Please re-state the problem that we are trying to solve in this issue. -The first mention (User A) is removed after adding the second mention. -When a user adds an @mention for User A, moves the cursor to the beginning of the message (before that mention), and then adds an @mention for User B, the first mention (User A) is removed. Only the second mention (User B) remains in the composer. Only the second mention remains. -### What is the root cause of that problem? -When a second mention is inserted at the beginning of text that already starts with a mention, the replacement range is computed too broadly. -The bug is in `insertSelectedMention` inside `SuggestionMention.tsx`. When a suggestion is selected, the function rebuilds the composer string by slicing `value` into three parts: everything before the `@` sign, the new mention code, and everything after the original typed token. The "after" slice is computed using `getOriginalMentionText`, which scans forward from `atSignIndex` until it hits a `MENTION_BREAKER` character. -`getOriginalMentionText()` uses `MENTION_BREAKER`, and `MENTION_BREAKER` does not break on `@`. So for a value like `@b@userA `, the original mention text becomes `@b@userA` instead of only `@b`. -https://github.com/Expensify/App/blob/00ddfe502e9421db04431d68a68f62bdbdec5ede/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx#L195-L210 -https://github.com/Expensify/App/blob/b51e63c92c19fc67ee3df597a148e12355580572/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx#L208-L210 -The `MENTION_BREAKER` regex matches whitespace, newlines, and certain punctuation — but crucially **`@` is not a breaker**, because `@` is valid inside email-style mentions like `user@domain.com`. -https://github.com/Expensify/App/blob/b51e63c92c19fc67ee3df597a148e12355580572/src/CONST/index.ts#L4278-L4280 -https://github.com/Expensify/App/blob/00ddfe502e9421db04431d68a68f62bdbdec5ede/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx#L241-L243 -Then `insertSelectedMention()` uses: -When the user moves the cursor to position 0 and types `@UserB`, the value becomes `@UserB@UserA@example.com` with no space between the two tokens (since the existing mention begins immediately where the cursor was placed). `getOriginalMentionText` scans from position 0 and finds no mention breaker until the space after the existing mention, so it returns the entire combined string `"@UserB@UserA@example.com"` as the token to replace. `Math.max` then picks this longer length over the typed prefix length, causing `commentAfterMention` to slice past the existing User A mention entirely — deleting it. -https://github.com/Expensify/App/blob/b51e63c92c19fc67ee3df597a148e12355580572/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx#L241-L243 -### What changes do you think we should make in order to solve the problem? -`Math.max(mentionToReplace.length, suggestionValues.mentionPrefix.length + suggestionValues.prefixType.length)` picks the longer span (`@b@userA`), so the existing mention (User A) is consumed and removed. -In `insertSelectedMention`, after computing `mentionToReplace` from `getOriginalMentionText`, check whether the portion of that string that lies beyond what the user actually typed starts with a new `@` or `#` token. If it does, and that portion is not part of the selected mention code itself, clip `mentionToReplace` back to only the typed length. This ensures the adjacent mention is treated as a separate entity and preserved in `commentAfterMention`. -### What changes do you think we should make in order to solve the problem? -https://github.com/Expensify/App/blob/00ddfe502e9421db04431d68a68f62bdbdec5ede/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx#L241-L243 https://github.com/Expensify/App/blob/b51e63c92c19fc67ee3df597a148e12355580572/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx#L224-L243 -```typescript -const typedLength = suggestionValues.mentionPrefix.length + suggestionValues.prefixType.length; const originalMention = getOriginalMentionText(value, suggestionValues.atSignIndex, StringUtils.countWhiteSpaces(suggestionValues.mentionPrefix)); -if (mentionToReplace.length > typedLength) { -const textAfterCursor = mentionToReplace.slice(typedLength); // Limit replacement to the currently typed mention token when another mention starts immediately after it. -if (textAfterCursor.startsWith('@') || textAfterCursor.startsWith('#')) { const typedMentionToken = `${suggestionValues.prefixType}${suggestionValues.mentionPrefix}`; -if (!mentionCode.toLowerCase().includes(textAfterCursor.toLowerCase())) { let mentionToReplace = originalMention; -mentionToReplace = mentionToReplace.slice(0, typedLength); if (typedMentionToken.length > 0 && mentionToReplace.startsWith(typedMentionToken)) { -} const nextMentionOffset = mentionToReplace.slice(typedMentionToken.length).search(/[@#]/); -} if (nextMentionOffset !== -1) { -} mentionToReplace = mentionToReplace.slice(0, typedMentionToken.length + nextMentionOffset); -} -const commentAfterMention = value.slice( } -suggestionValues.atSignIndex + Math.max(mentionToReplace.length, typedLength), -); let trailingDot = ''; -``` if (suggestionValues.prefixType === '@' && suggestionValues.mentionPrefix.endsWith('.')) { -trailingDot = mentionToReplace.match(CONST.REGEX.TRAILING_DOTS)?.[0] ?? ''; -### Demo mentionToReplace = mentionToReplace.slice(0, mentionToReplace.length - trailingDot.length); -https://github.com/user-attachments/assets/96160da5-f629-41d1-aba0-65e9e3ebc1df } -const commentAfterMention = value.slice( -suggestionValues.atSignIndex + Math.max(mentionToReplace.length, suggestionValues.mentionPrefix.length + suggestionValues.prefixType.length), -); -``` -This keeps replacement scoped to the active mention being typed, so inserting User B at the start does not delete adjacent existing mention tokens (User A). -https://github.com/Expensify/App/blob/b51e63c92c19fc67ee3df597a148e12355580572/tests/unit/SuggestionMentionTest.tsx#L164-L246 -```typescript -it('keeps existing mention when inserting a new mention at the beginning of the message', async () => { -mockPersonalDetails = {}; -mockPersonalDetails[2] = { -accountID: 2, -login: 'bob@example.com', -firstName: 'Bob', -lastName: 'Tester', -}; -const updateComment = jest.fn(); -const {setSelection} = renderSuggestionMention('@b@usera@example.com ', updateComment, {start: 2, end: 2}); -await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled()); -const {onSelect} = getLastMentionSuggestionsProps(); -act(() => onSelect(0)); -expect(updateComment).toHaveBeenCalledWith('@bob@example.com @usera@example.com ', true); -expect(setSelection).toHaveBeenCalledWith({start: 17, end: 17}); -}); -```