Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/components/Search/SearchAutocompleteList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,15 @@
prevQueryRef.current = autocompleteQueryValue;

if (queryChanged) {
// When query changes, 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);
if (autocompleteQueryValue === '') {
// When query is cleared, reset the initial focus guard so the initial focus
// effect can re-fire and correctly focus the first focusable item (skipping section headers).
hasSetInitialFocusRef.current = false;
} else {
Comment on lines +255 to +259

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep list scrolled to top when query is cleared

When autocompleteQueryValue becomes empty, this branch only flips hasSetInitialFocusRef and no longer calls updateAndScrollToFocusedIndex(..., true), so the list can stay at a previously scrolled position while focus is moved to an item near the top by the initial-focus effect (updateAndScrollToFocusedIndex(flatIndex, false) later in the same component). In the common case where a user scrolls down search results and then clears the query, the highlighted item is off-screen, which makes keyboard focus appear lost again.

Useful? React with 👍 / 👎.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flipping hasSetInitialFocusRef will trigger the existing initial focus effect which will call updateAndScrollToFocusedIndex. So, this seems to be a non-issue.

// 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);
}
}
}, [autocompleteQueryValue, isInitialRender]);

Expand Down Expand Up @@ -355,7 +361,7 @@
}, [autocompleteQueryWithoutFilters, debounceHandleSearch]);

/* Sections generation */
const sections: Array<Section<AutocompleteListItem>> = [];

Check warning on line 364 in src/components/Search/SearchAutocompleteList.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

The 'sections' array makes the dependencies of useEffect Hook (at line 446) change on every render. To fix this, wrap the initialization of 'sections' in its own useMemo() Hook

Check warning on line 364 in src/components/Search/SearchAutocompleteList.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

The 'sections' array makes the dependencies of useEffect Hook (at line 446) change on every render. To fix this, wrap the initialization of 'sections' in its own useMemo() Hook
let sectionIndex = 0;

if (searchQueryItem) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
const innerTextInputRef = useRef<BaseTextInputRef | null>(null);
const isTextInputFocusedRef = useRef<boolean>(false);
const hasKeyBeenPressed = useRef(false);
const suppressNextFocusScrollRef = useRef(false);
const activeElementRole = useActiveElementRole();
const {isKeyboardShown} = useKeyboardState();
const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings();
Expand Down Expand Up @@ -125,6 +126,10 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
disabledIndexes,
isActive: isScreenFocused && itemsCount > 0,
onFocusedIndexChange: (index: number) => {
if (suppressNextFocusScrollRef.current) {
suppressNextFocusScrollRef.current = false;
return;
}
if (!shouldScrollToFocusedIndex) {
return;
}
Expand Down Expand Up @@ -183,6 +188,9 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
};

const updateAndScrollToFocusedIndex = (index: number, shouldScroll = true) => {
if (!shouldScroll) {
suppressNextFocusScrollRef.current = true;
}
Comment on lines +191 to +193

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset suppressed-scroll state on no-op focus updates

Setting suppressNextFocusScrollRef.current = true before setFocusedIndex(index) assumes onFocusedIndexChange will always run next, but useArrowKeyFocusManager skips that callback when the index is unchanged. In that case the suppress flag leaks into the next real focus change and cancels scrolling unexpectedly (e.g., after a no-scroll update to the same index, the next ArrowDown changes focus but does not scroll).

Useful? React with 👍 / 👎.

setFocusedIndex(index);
if (shouldScroll) {
scrollToIndex(index);
Expand Down
202 changes: 202 additions & 0 deletions tests/unit/SearchAutocompleteListTest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import type * as NativeNavigation from '@react-navigation/native';
import {act, render, screen, waitFor} from '@testing-library/react-native';
import React, {useMemo} from 'react';
import Onyx from 'react-native-onyx';
import {LocaleContextProvider} from '@components/LocaleContextProvider';
import OnyxListItemProvider from '@components/OnyxListItemProvider';
import {OptionsListActionsContext, OptionsListStateContext} from '@components/OptionListContextProvider';
import SearchRouter from '@components/Search/SearchRouter/SearchRouter';
import type {PrivateIsArchivedMap} from '@hooks/usePrivateIsArchivedMap';
import {createOptionList} from '@libs/OptionsListUtils';
import ComposeProviders from '@src/components/ComposeProviders';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PersonalDetails, Report} from '@src/types/onyx';
import createCollection from '../utils/collections/createCollection';
import createPersonalDetails from '../utils/collections/personalDetails';
import {createRandomReport} from '../utils/collections/reports';
import * as TestHelper from '../utils/TestHelper';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates';

jest.mock('lodash/debounce', () =>
jest.fn((fn: Record<string, jest.Mock>) => {
// eslint-disable-next-line no-param-reassign
fn.cancel = jest.fn();
return fn;
}),
);

jest.mock('@src/libs/Log');

jest.mock('@src/libs/API', () => ({
write: jest.fn(),
makeRequestWithSideEffects: jest.fn(),
read: jest.fn(),
}));

// The jest-expo preset resolves to the .native.tsx file which defers rendering via onLayout (which never fires in tests).
// Mock the deferred wrapper to directly render SearchAutocompleteList.
jest.mock('@components/Search/DeferredSearchAutocompleteList', () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const module = jest.requireActual<{default: React.ComponentType}>('@components/Search/SearchAutocompleteList');
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
__esModule: true,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
default: module.default,
};
});

jest.mock('@src/libs/Navigation/Navigation', () => ({
dismissModalWithReport: jest.fn(),
getTopmostReportId: jest.fn(),
isNavigationReady: jest.fn(() => Promise.resolve()),
isDisplayedInModal: jest.fn(() => false),
navigate: jest.fn(),
}));

jest.mock('@src/hooks/useRootNavigationState', () => ({
// eslint-disable-next-line @typescript-eslint/naming-convention
__esModule: true,
default: () => ({contextualReportID: undefined, isSearchRouterScreen: false}),
}));

jest.mock('@hooks/useExportedToFilterOptions', () => ({
// eslint-disable-next-line @typescript-eslint/naming-convention
__esModule: true,
default: () => ({
exportedToFilterOptions: [],
combinedUniqueExportTemplates: [],
connectedIntegrationNames: new Set<string>(),
}),
}));

jest.mock('@react-navigation/native', () => {
const actualNav = jest.requireActual<typeof NativeNavigation>('@react-navigation/native');
return {
...actualNav,
useFocusEffect: jest.fn(),
useIsFocused: () => true,
useRoute: () => jest.fn(),
usePreventRemove: () => jest.fn(),
useNavigation: () => ({
navigate: jest.fn(),
addListener: () => jest.fn(),
}),
createNavigationContainerRef: () => ({
addListener: () => jest.fn(),
removeListener: () => jest.fn(),
isReady: () => jest.fn(),
getCurrentRoute: () => jest.fn(),
getState: () => jest.fn(),
}),
useNavigationState: () => ({
routes: [],
}),
};
});

jest.mock('@src/components/ConfirmedRoute.tsx');

const getMockedReports = (length = 10) =>
createCollection<Report>(
(item) => `${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`,
(index) => createRandomReport(index, undefined),
length,
);

const getMockedPersonalDetails = (length = 10) =>
createCollection<PersonalDetails>(
(item) => item.accountID,
(index) => createPersonalDetails(index),
length,
);

const MOCK_CURRENT_USER_ACCOUNT_ID = 1;

const mockedReports = getMockedReports(10);
const mockedBetas = Object.values(CONST.BETAS);
const mockedPersonalDetails = getMockedPersonalDetails(10);
const EMPTY_PRIVATE_IS_ARCHIVED_MAP: PrivateIsArchivedMap = {};
const mockedOptions = createOptionList(mockedPersonalDetails, MOCK_CURRENT_USER_ACCOUNT_ID, EMPTY_PRIVATE_IS_ARCHIVED_MAP, mockedReports);

const mockOnClose = jest.fn();

function SearchRouterWrapper() {
return (
<ComposeProviders components={[OnyxListItemProvider, LocaleContextProvider]}>
<OptionsListStateContext.Provider value={useMemo(() => ({options: mockedOptions, areOptionsInitialized: true}), [])}>
<OptionsListActionsContext.Provider value={useMemo(() => ({initializeOptions: () => {}, resetOptions: () => {}}), [])}>
<SearchRouter onRouterClose={mockOnClose} />
</OptionsListActionsContext.Provider>
</OptionsListStateContext.Provider>
</ComposeProviders>
);
}

/**
* Helper to flush all pending React state updates and Onyx callbacks.
* With fake timers we need multiple rounds of timer advancement + microtask flushing.
*/
async function flushAllUpdates() {
for (let i = 0; i < 10; i++) {
// eslint-disable-next-line no-await-in-loop
await act(async () => {
jest.advanceTimersByTime(100);
await waitForBatchedUpdates();
});
}
}

describe('SearchAutocompleteList', () => {
beforeAll(() => {
Onyx.init({
keys: ONYXKEYS,
evictableKeys: [ONYXKEYS.COLLECTION.REPORT],
});
});

beforeEach(() => {
global.fetch = TestHelper.getGlobalFetchMock();
wrapOnyxWithWaitForBatchedUpdates(Onyx);
});

afterEach(async () => {
await act(async () => {
await Onyx.clear();
});
jest.clearAllMocks();
});

it('should display Recent searches section when query is empty and recent searches exist', async () => {
const timestampOne = '2024-01-01T00:00:00';
const timestampTwo = '2024-01-02T00:00:00';
const recentSearches: Record<string, {query: string; timestamp: string}> = {};
recentSearches[timestampOne] = {query: 'type:expense status:approved', timestamp: timestampOne};
recentSearches[timestampTwo] = {query: 'type:chat', timestamp: timestampTwo};

await waitForBatchedUpdates();
await Onyx.multiSet({
...mockedReports,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
[ONYXKEYS.BETAS]: mockedBetas,
[ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
[ONYXKEYS.RECENT_SEARCHES]: recentSearches,
});

render(<SearchRouterWrapper />);

// Flush all pending updates (Onyx subscriptions, useEffect, re-renders)
await flushAllUpdates();

// Verify "Recent searches" section header is visible when query is empty and recent searches exist
await waitFor(() => {
expect(screen.getByText('Recent searches')).toBeTruthy();
});

// Verify the recent search items themselves are also displayed
expect(screen.getByText('type:expense status:approved')).toBeTruthy();
expect(screen.getByText('type:chat')).toBeTruthy();
});
});
Loading