From 208fafb044e9ef7cab00fdee31530e5ee97cb5e5 Mon Sep 17 00:00:00 2001
From: "mkhutornyi (via MelvinBot)" <infra+melvinbot@expensify.com>
Date: Tue, 9 Jun 2026 09:25:14 +0000
Subject: [PATCH 1/3] Highlight first matched chat in search switcher so Enter
 opens it

The two-section chat switcher always renders section headers during an
active search. That shifted the first result down one flat index and
left the highlight targeting a disabled header, so pressing Enter ran a
text search instead of opening the focused chat. Derive the highlight
reference text from the real first result and focus the header-aware
flat index of that result.

Co-authored-by: mkhutornyi <mkhutornyi@users.noreply.github.com>
---
 .../Search/SearchAutocompleteList.tsx         | 22 ++++++------
 .../Search/SearchRouter/SearchRouter.tsx      |  2 --
 tests/unit/SearchAutocompleteListTest.tsx     | 34 +++++++++++++++++++
 3 files changed, 45 insertions(+), 13 deletions(-)

diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx
index 67c1acc4b16f..1b10785a139d 100644
--- a/src/components/Search/SearchAutocompleteList.tsx
+++ b/src/components/Search/SearchAutocompleteList.tsx
@@ -67,9 +67,6 @@ type SearchAutocompleteListProps = {
     /** Whether to subscribe to KeyboardShortcut arrow keys events */
     shouldSubscribeToArrowKeyEvents?: boolean;
 
-    /** Callback to highlight (e.g. scroll to) the first matched item in the list. */
-    onHighlightFirstItem?: () => void;
-
     /** Ref for the external text input */
     textInputRef?: RefObject<AnimatedTextInputRef | null>;
 
@@ -144,7 +141,6 @@ function SearchAutocompleteList({
     getAdditionalSections,
     onListItemPress,
     shouldSubscribeToArrowKeyEvents = true,
-    onHighlightFirstItem,
     textInputRef,
     autocompleteSubstitutions,
     ref,
@@ -272,8 +268,8 @@ function SearchAutocompleteList({
                 // effect can re-fire and correctly focus the first focusable item (skipping section headers).
                 hasSetInitialFocusRef.current = false;
             } else {
-                // When query changes to a non-empty value, focus on the search query item (index 0) and scroll to top
-                // onHighlightFirstItem will switch focus to the first result when there's a good match
+                // When query changes to a non-empty value, focus on the search query item (index 0) and scroll to top.
+                // The highlight effect below switches focus to the first result when there's a good match.
                 innerListRef.current?.updateAndScrollToFocusedIndex(0, true);
             }
         }
@@ -569,7 +565,7 @@ function SearchAutocompleteList({
         reports,
     ]);
 
-    const sectionItemText = sections?.at(1)?.data?.[0]?.text ?? '';
+    const sectionItemText = styledRecentReports.at(0)?.text ?? '';
     const normalizedReferenceText = sectionItemText.toLowerCase();
     const trimmedAutocompleteQueryValue = autocompleteQueryValue.trim();
     const isLoading = !isRecentSearchesDataLoaded;
@@ -616,12 +612,16 @@ function SearchAutocompleteList({
     }, [isLoadingOptions, firstRecentReportFlatIndex, shouldUseNarrowLayout]);
 
     useEffect(() => {
-        const targetText = autocompleteQueryValue;
+        if (firstRecentReportFlatIndex === -1) {
+            return;
+        }
 
-        if (shouldHighlight(normalizedReferenceText, targetText)) {
-            onHighlightFirstItem?.();
+        // Focus the first matched result row. Its flat index already accounts for the section headers
+        // ("Recent chats" / "Search results") so the keyboard highlight lands on the row, not a header.
+        if (shouldHighlight(normalizedReferenceText, autocompleteQueryValue)) {
+            innerListRef.current?.updateAndScrollToFocusedIndex(firstRecentReportFlatIndex, true);
         }
-    }, [autocompleteQueryValue, onHighlightFirstItem, normalizedReferenceText]);
+    }, [autocompleteQueryValue, normalizedReferenceText, firstRecentReportFlatIndex]);
 
     if (isLoading) {
         return (
diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx
index 973871e5be97..94c6450b5e62 100644
--- a/src/components/Search/SearchRouter/SearchRouter.tsx
+++ b/src/components/Search/SearchRouter/SearchRouter.tsx
@@ -372,7 +372,6 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
     useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => {
         onRouterClose();
     });
-    const updateAndScrollToFocusedIndex = useCallback(() => listRef.current?.updateAndScrollToFocusedIndex(searchQueryItems?.length ?? 1, true), [searchQueryItems?.length]);
 
     const modalWidth = shouldUseNarrowLayout ? styles.w100 : {width: variables.searchRouterPopoverWidth};
 
@@ -421,7 +420,6 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
                 searchQueryItems={searchQueryItems}
                 getAdditionalSections={getAdditionalSections}
                 onListItemPress={onListItemPress}
-                onHighlightFirstItem={updateAndScrollToFocusedIndex}
                 ref={listRef}
                 textInputRef={textInputRef}
                 autocompleteSubstitutions={autocompleteSubstitutions}
diff --git a/tests/unit/SearchAutocompleteListTest.tsx b/tests/unit/SearchAutocompleteListTest.tsx
index 16b77aa90ce3..bf4c9aad386b 100644
--- a/tests/unit/SearchAutocompleteListTest.tsx
+++ b/tests/unit/SearchAutocompleteListTest.tsx
@@ -9,9 +9,11 @@ import SearchRouter from '@components/Search/SearchRouter/SearchRouter';
 import type {PrivateIsArchivedMap} from '@hooks/usePrivateIsArchivedMap';
 import type * as OptionsListUtilsModule from '@libs/OptionsListUtils';
 import {createOptionList} from '@libs/OptionsListUtils';
+import Navigation from '@navigation/Navigation';
 import ComposeProviders from '@src/components/ComposeProviders';
 import CONST from '@src/CONST';
 import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
 import type {PersonalDetails, Report} from '@src/types/onyx';
 import createCollection from '../utils/collections/createCollection';
 import createPersonalDetails from '../utils/collections/personalDetails';
@@ -419,5 +421,37 @@ describe('SearchAutocompleteList', () => {
             // NewServer should appear after the local results (in the server section)
             expect(relevantOrder.indexOf('Charlie Report')).toBeLessThan(relevantOrder.indexOf('NewServer Report'));
         });
+
+        it('should focus the first matched chat so submitting opens it instead of running a search', async () => {
+            const recentSearches: Record<string, {query: string; timestamp: string}> = {};
+            recentSearches['2024-01-01T00:00:00'] = {query: 'type:expense', timestamp: '2024-01-01T00:00:00'};
+
+            await waitForBatchedUpdates();
+            await Onyx.multiSet({
+                ...mockedReports,
+                [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
+                [ONYXKEYS.BETAS]: mockedBetas,
+                [ONYXKEYS.RECENT_SEARCHES]: recentSearches,
+            });
+
+            render(<SearchRouterWrapper />);
+            await flushAllUpdates();
+
+            await waitFor(() => {
+                expect(screen.getByText('Recent chats')).toBeTruthy();
+            });
+
+            // Type a query that matches the first recent chat ("Alice Report") as a whole word.
+            // The header-aware highlight effect should move focus onto that result row, not a section header.
+            const textInput = screen.getByTestId('search-autocomplete-text-input');
+            fireEvent.changeText(textInput, 'Alice');
+            await flushAllUpdates();
+
+            // Submitting (Enter) should open the focused chat rather than run a text search.
+            fireEvent(textInput, 'submitEditing');
+            await flushAllUpdates();
+
+            expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('101'));
+        });
     });
 });

From 050dce79ac2544cd6cfbf570601d724006a8dd43 Mon Sep 17 00:00:00 2001
From: "mkhutornyi (via MelvinBot)" <infra+melvinbot@expensify.com>
Date: Tue, 9 Jun 2026 10:11:11 +0000
Subject: [PATCH 2/3] Fix: keep optimistic invite accounts in "Recent chats"
 instead of flashing in "Search results"

An optimistic (invite) account is generated locally and is never a server search
result, but it was classified as a server row whenever it was not present in the
frozen local rank snapshot. This caused a non-existing user to briefly appear under
"Search results" before moving to "Recent chats". Always classify optimistic accounts
as local rows so they render directly in "Recent chats".

Co-authored-by: mkhutornyi <mkhutornyi@users.noreply.github.com>
---
 .../Search/SearchAutocompleteList.tsx         | 10 +++-
 tests/unit/SearchAutocompleteListTest.tsx     | 60 +++++++++++++++++++
 2 files changed, 67 insertions(+), 3 deletions(-)

diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx
index 1b10785a139d..67c270fcd4b3 100644
--- a/src/components/Search/SearchAutocompleteList.tsx
+++ b/src/components/Search/SearchAutocompleteList.tsx
@@ -499,15 +499,19 @@ function SearchAutocompleteList({
             const localRows: AutocompleteListItem[] = [];
             const serverRows: AutocompleteListItem[] = [];
             for (const item of nextStyledRecentReports) {
-                if (item.keyForList && frozenLocalRank.has(item.keyForList)) {
+                // Optimistic (invite) accounts are generated locally and are never server search results,
+                // so always keep them in the local section. Otherwise they briefly land in "Search results"
+                // before the frozen rank snapshot catches up, flashing across sections.
+                if (item.isOptimisticAccount || (item.keyForList && frozenLocalRank.has(item.keyForList))) {
                     localRows.push(item);
                 } else {
                     serverRows.push(item);
                 }
             }
             // Sort the local section by the rank captured at query-change time so it cannot
-            // reorder when the API returns.
-            localRows.sort((a, b) => (frozenLocalRank.get(a.keyForList ?? '') ?? 0) - (frozenLocalRank.get(b.keyForList ?? '') ?? 0));
+            // reorder when the API returns. Rows without a frozen rank (e.g. optimistic invite
+            // accounts) keep their original order at the end of the section.
+            localRows.sort((a, b) => (frozenLocalRank.get(a.keyForList ?? '') ?? Number.MAX_SAFE_INTEGER) - (frozenLocalRank.get(b.keyForList ?? '') ?? Number.MAX_SAFE_INTEGER));
 
             if (localRows.length > 0 || !isLoadingOptions) {
                 pushSection({title: translate('search.recentChats'), data: localRows, sectionIndex: sectionIndex++});
diff --git a/tests/unit/SearchAutocompleteListTest.tsx b/tests/unit/SearchAutocompleteListTest.tsx
index bf4c9aad386b..eee0283f69db 100644
--- a/tests/unit/SearchAutocompleteListTest.tsx
+++ b/tests/unit/SearchAutocompleteListTest.tsx
@@ -422,6 +422,66 @@ describe('SearchAutocompleteList', () => {
             expect(relevantOrder.indexOf('Charlie Report')).toBeLessThan(relevantOrder.indexOf('NewServer Report'));
         });
 
+        it('should keep an optimistic (invite) account in "Recent chats" instead of "Search results"', async () => {
+            const recentSearches: Record<string, {query: string; timestamp: string}> = {};
+            recentSearches['2024-01-01T00:00:00'] = {query: 'type:expense', timestamp: '2024-01-01T00:00:00'};
+
+            await waitForBatchedUpdates();
+            await Onyx.multiSet({
+                ...mockedReports,
+                [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
+                [ONYXKEYS.BETAS]: mockedBetas,
+                [ONYXKEYS.RECENT_SEARCHES]: recentSearches,
+            });
+
+            render(<SearchRouterWrapper />);
+            await flushAllUpdates();
+
+            await waitFor(() => {
+                expect(screen.getByText('Recent chats')).toBeTruthy();
+            });
+
+            // Type a query that matches the existing reports so their local rank is frozen and non-empty
+            // (the invite account is not part of that snapshot).
+            const textInput = screen.getByTestId('search-autocomplete-text-input');
+            fireEvent.changeText(textInput, 'test');
+            await flushAllUpdates();
+
+            // Simulate an optimistic (invite) result arriving after the frozen rank was captured. It is not part
+            // of the frozen snapshot, so without special handling it would be treated as a server result and
+            // shown under "Search results". Because it is locally generated it must stay in "Recent chats".
+            getSearchOptionsSpy.mockReturnValue({
+                options: {
+                    recentReports: [
+                        {reportID: '103', keyForList: '103', text: 'Charlie Report', alternateText: 'charlie alt', lastMessageText: 'hey'},
+                        {reportID: '101', keyForList: '101', text: 'Alice Report', alternateText: 'alice alt', lastMessageText: 'hello'},
+                        {reportID: '102', keyForList: '102', text: 'Bob Report', alternateText: 'bob alt', lastMessageText: 'hi'},
+                        {reportID: '201', keyForList: '201', text: 'InviteAccount Report', alternateText: 'invite alt', lastMessageText: 'new', isOptimisticAccount: true},
+                    ],
+                    personalDetails: [],
+                    currentUserOption: null,
+                    userToInvite: null,
+                },
+            });
+            mockUseFilteredOptions.mockReturnValue({
+                options: {...mockedOptions},
+                isLoading: false,
+                loadMore: jest.fn(),
+                hasMore: false,
+                isLoadingMore: false,
+            });
+            await act(async () => {
+                await Onyx.set(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, false);
+            });
+            await flushAllUpdates();
+
+            // The optimistic account is locally generated, so it must never be treated as a server result.
+            await waitFor(() => {
+                expect(screen.getByText('InviteAccount Report')).toBeTruthy();
+            });
+            expect(screen.queryByText('Search results')).toBeNull();
+        });
+
         it('should focus the first matched chat so submitting opens it instead of running a search', async () => {
             const recentSearches: Record<string, {query: string; timestamp: string}> = {};
             recentSearches['2024-01-01T00:00:00'] = {query: 'type:expense', timestamp: '2024-01-01T00:00:00'};

From aaddd3f711ffcad62cb71d5be8d4b1684b70860a Mon Sep 17 00:00:00 2001
From: "mkhutornyi (via MelvinBot)" <infra+melvinbot@expensify.com>
Date: Tue, 9 Jun 2026 10:25:46 +0000
Subject: [PATCH 3/3] Revert "Highlight first matched chat in search switcher
 so Enter opens it"

This reverts commit 208fafb044e9ef7cab00fdee31530e5ee97cb5e5.

Co-authored-by: mkhutornyi <mkhutornyi@users.noreply.github.com>
---
 .../Search/SearchAutocompleteList.tsx         | 22 ++++++------
 .../Search/SearchRouter/SearchRouter.tsx      |  2 ++
 tests/unit/SearchAutocompleteListTest.tsx     | 34 -------------------
 3 files changed, 13 insertions(+), 45 deletions(-)

diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx
index 67c270fcd4b3..7a263fb9207c 100644
--- a/src/components/Search/SearchAutocompleteList.tsx
+++ b/src/components/Search/SearchAutocompleteList.tsx
@@ -67,6 +67,9 @@ type SearchAutocompleteListProps = {
     /** Whether to subscribe to KeyboardShortcut arrow keys events */
     shouldSubscribeToArrowKeyEvents?: boolean;
 
+    /** Callback to highlight (e.g. scroll to) the first matched item in the list. */
+    onHighlightFirstItem?: () => void;
+
     /** Ref for the external text input */
     textInputRef?: RefObject<AnimatedTextInputRef | null>;
 
@@ -141,6 +144,7 @@ function SearchAutocompleteList({
     getAdditionalSections,
     onListItemPress,
     shouldSubscribeToArrowKeyEvents = true,
+    onHighlightFirstItem,
     textInputRef,
     autocompleteSubstitutions,
     ref,
@@ -268,8 +272,8 @@ function SearchAutocompleteList({
                 // effect can re-fire and correctly focus the first focusable item (skipping section headers).
                 hasSetInitialFocusRef.current = false;
             } else {
-                // When query changes to a non-empty value, focus on the search query item (index 0) and scroll to top.
-                // The highlight effect below switches focus to the first result when there's a good match.
+                // When query changes to a non-empty value, focus on the search query item (index 0) and scroll to top
+                // onHighlightFirstItem will switch focus to the first result when there's a good match
                 innerListRef.current?.updateAndScrollToFocusedIndex(0, true);
             }
         }
@@ -569,7 +573,7 @@ function SearchAutocompleteList({
         reports,
     ]);
 
-    const sectionItemText = styledRecentReports.at(0)?.text ?? '';
+    const sectionItemText = sections?.at(1)?.data?.[0]?.text ?? '';
     const normalizedReferenceText = sectionItemText.toLowerCase();
     const trimmedAutocompleteQueryValue = autocompleteQueryValue.trim();
     const isLoading = !isRecentSearchesDataLoaded;
@@ -616,16 +620,12 @@ function SearchAutocompleteList({
     }, [isLoadingOptions, firstRecentReportFlatIndex, shouldUseNarrowLayout]);
 
     useEffect(() => {
-        if (firstRecentReportFlatIndex === -1) {
-            return;
-        }
+        const targetText = autocompleteQueryValue;
 
-        // Focus the first matched result row. Its flat index already accounts for the section headers
-        // ("Recent chats" / "Search results") so the keyboard highlight lands on the row, not a header.
-        if (shouldHighlight(normalizedReferenceText, autocompleteQueryValue)) {
-            innerListRef.current?.updateAndScrollToFocusedIndex(firstRecentReportFlatIndex, true);
+        if (shouldHighlight(normalizedReferenceText, targetText)) {
+            onHighlightFirstItem?.();
         }
-    }, [autocompleteQueryValue, normalizedReferenceText, firstRecentReportFlatIndex]);
+    }, [autocompleteQueryValue, onHighlightFirstItem, normalizedReferenceText]);
 
     if (isLoading) {
         return (
diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx
index 94c6450b5e62..973871e5be97 100644
--- a/src/components/Search/SearchRouter/SearchRouter.tsx
+++ b/src/components/Search/SearchRouter/SearchRouter.tsx
@@ -372,6 +372,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
     useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => {
         onRouterClose();
     });
+    const updateAndScrollToFocusedIndex = useCallback(() => listRef.current?.updateAndScrollToFocusedIndex(searchQueryItems?.length ?? 1, true), [searchQueryItems?.length]);
 
     const modalWidth = shouldUseNarrowLayout ? styles.w100 : {width: variables.searchRouterPopoverWidth};
 
@@ -420,6 +421,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
                 searchQueryItems={searchQueryItems}
                 getAdditionalSections={getAdditionalSections}
                 onListItemPress={onListItemPress}
+                onHighlightFirstItem={updateAndScrollToFocusedIndex}
                 ref={listRef}
                 textInputRef={textInputRef}
                 autocompleteSubstitutions={autocompleteSubstitutions}
diff --git a/tests/unit/SearchAutocompleteListTest.tsx b/tests/unit/SearchAutocompleteListTest.tsx
index eee0283f69db..d850466df63c 100644
--- a/tests/unit/SearchAutocompleteListTest.tsx
+++ b/tests/unit/SearchAutocompleteListTest.tsx
@@ -9,11 +9,9 @@ import SearchRouter from '@components/Search/SearchRouter/SearchRouter';
 import type {PrivateIsArchivedMap} from '@hooks/usePrivateIsArchivedMap';
 import type * as OptionsListUtilsModule from '@libs/OptionsListUtils';
 import {createOptionList} from '@libs/OptionsListUtils';
-import Navigation from '@navigation/Navigation';
 import ComposeProviders from '@src/components/ComposeProviders';
 import CONST from '@src/CONST';
 import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
 import type {PersonalDetails, Report} from '@src/types/onyx';
 import createCollection from '../utils/collections/createCollection';
 import createPersonalDetails from '../utils/collections/personalDetails';
@@ -481,37 +479,5 @@ describe('SearchAutocompleteList', () => {
             });
             expect(screen.queryByText('Search results')).toBeNull();
         });
-
-        it('should focus the first matched chat so submitting opens it instead of running a search', async () => {
-            const recentSearches: Record<string, {query: string; timestamp: string}> = {};
-            recentSearches['2024-01-01T00:00:00'] = {query: 'type:expense', timestamp: '2024-01-01T00:00:00'};
-
-            await waitForBatchedUpdates();
-            await Onyx.multiSet({
-                ...mockedReports,
-                [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
-                [ONYXKEYS.BETAS]: mockedBetas,
-                [ONYXKEYS.RECENT_SEARCHES]: recentSearches,
-            });
-
-            render(<SearchRouterWrapper />);
-            await flushAllUpdates();
-
-            await waitFor(() => {
-                expect(screen.getByText('Recent chats')).toBeTruthy();
-            });
-
-            // Type a query that matches the first recent chat ("Alice Report") as a whole word.
-            // The header-aware highlight effect should move focus onto that result row, not a section header.
-            const textInput = screen.getByTestId('search-autocomplete-text-input');
-            fireEvent.changeText(textInput, 'Alice');
-            await flushAllUpdates();
-
-            // Submitting (Enter) should open the focused chat rather than run a text search.
-            fireEvent(textInput, 'submitEditing');
-            await flushAllUpdates();
-
-            expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('101'));
-        });
     });
 });
