Skip to content

Commit d80a018

Browse files
chore(runway): cherry-pick fix(predict): cp-7.62.0 debounce search input in PredictSearchOverlay (#24825)
- fix(predict): cp-7.62.0 debounce search input in PredictSearchOverlay (#24820) ## **Description** The PredictSearchOverlay component was triggering API requests for every character typed in the search input. This caused excessive network requests and poor UX. **Solution:** Added a 200ms debounce to the search query using the existing `useDebouncedValue` hook, which is already used throughout the codebase for similar search functionality (Bridge, Card onboarding, Trending tokens, etc.). **Changes:** - Import and use `useDebouncedValue` hook in PredictSearchOverlay - Pass debounced query to `usePredictMarketData` instead of raw search query - Track debouncing state to show loading indicator during debounce period - Combined `isDebouncing` and `isFetching` states for seamless loading UX ## **Changelog** CHANGELOG entry: Fixed search input triggering excessive API calls in Predictions ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/PRED-486 ## **Manual testing steps** ```gherkin Feature: Debounced search in Predictions Scenario: user types in search field Given user has opened the Predictions feed And user has tapped the search button When user types "bitcoin" quickly Then only one API request is made after typing stops And skeleton loaders appear while debouncing And search results appear after debounce completes ``` ## **Screenshots/Recordings** ### **Before** API call triggered on every keystroke (network tab shows multiple requests) ### **After** Single API call after 200ms debounce delay https://github.com/user-attachments/assets/399cf46f-6979-4be5-8993-6d71cbf049ee ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Improves Predictions search UX and reduces API calls by debouncing input. > > - Import and use `useDebouncedValue` in `PredictSearchOverlay`; introduce `SEARCH_DEBOUNCE_MS = 200` > - Pass `debouncedSearchQuery` to `usePredictMarketData` instead of raw `searchQuery` > - Add `isDebouncing` and combine with `isFetching` as `isSearchLoading` to show skeletons during debounce > - Extend `PredictFeed.test.tsx` to mock `useDebouncedValue` and assert: debounced query usage, loaders during debounce, results after debounce, and `useDebouncedValue` called with `200`ms > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2d6d25c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> [9d02315](9d02315) Co-authored-by: Luis Taniça <matallui@gmail.com>
1 parent 9d5193b commit d80a018

2 files changed

Lines changed: 91 additions & 2 deletions

File tree

app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ import { usePredictMarketData } from '../../hooks/usePredictMarketData';
4343

4444
const mockUsePredictMarketData = usePredictMarketData as jest.Mock;
4545

46+
jest.mock('../../../../hooks/useDebouncedValue', () => ({
47+
useDebouncedValue: jest.fn(),
48+
}));
49+
50+
import { useDebouncedValue } from '../../../../hooks/useDebouncedValue';
51+
52+
const mockUseDebouncedValue = useDebouncedValue as jest.Mock;
53+
4654
jest.mock('../../hooks/useFeedScrollManager', () => ({
4755
useFeedScrollManager: jest.fn(),
4856
}));
@@ -232,6 +240,7 @@ describe('PredictFeed', () => {
232240
refetch: jest.fn(),
233241
fetchMore: jest.fn(),
234242
});
243+
mockUseDebouncedValue.mockImplementation((value: string) => value);
235244
});
236245

237246
afterEach(() => {
@@ -601,4 +610,74 @@ describe('PredictFeed', () => {
601610
);
602611
});
603612
});
613+
614+
describe('search debounce behavior', () => {
615+
it('passes debounced search query to usePredictMarketData', () => {
616+
mockUseDebouncedValue.mockReturnValue('debounced-query');
617+
const { getByTestId, getByPlaceholderText } = render(<PredictFeed />);
618+
619+
fireEvent.press(getByTestId('predict-search-button'));
620+
const searchInput = getByPlaceholderText('Search prediction markets');
621+
fireEvent.changeText(searchInput, 'bitcoin');
622+
623+
const searchCalls = mockUsePredictMarketData.mock.calls.filter(
624+
(call: [{ q?: string }]) => call[0].q !== undefined,
625+
);
626+
expect(searchCalls[searchCalls.length - 1][0].q).toBe('debounced-query');
627+
});
628+
629+
it('displays skeleton loaders when debouncing search input', () => {
630+
mockUseDebouncedValue.mockReturnValue('');
631+
mockUsePredictMarketData.mockReturnValue({
632+
marketData: [],
633+
isFetching: false,
634+
isFetchingMore: false,
635+
error: null,
636+
hasMore: false,
637+
refetch: jest.fn(),
638+
fetchMore: jest.fn(),
639+
});
640+
const { getByTestId, getByPlaceholderText } = render(<PredictFeed />);
641+
642+
fireEvent.press(getByTestId('predict-search-button'));
643+
const searchInput = getByPlaceholderText('Search prediction markets');
644+
fireEvent.changeText(searchInput, 'bitcoin');
645+
646+
expect(getByTestId('search-skeleton-1')).toBeOnTheScreen();
647+
});
648+
649+
it('displays search results after debounce completes', () => {
650+
mockUseDebouncedValue.mockReturnValue('bitcoin');
651+
mockUsePredictMarketData.mockReturnValue({
652+
marketData: [
653+
{ id: '1', title: 'Bitcoin Market 1' },
654+
{ id: '2', title: 'Bitcoin Market 2' },
655+
],
656+
isFetching: false,
657+
isFetchingMore: false,
658+
error: null,
659+
hasMore: false,
660+
refetch: jest.fn(),
661+
fetchMore: jest.fn(),
662+
});
663+
const { getByTestId, getByPlaceholderText } = render(<PredictFeed />);
664+
665+
fireEvent.press(getByTestId('predict-search-button'));
666+
const searchInput = getByPlaceholderText('Search prediction markets');
667+
fireEvent.changeText(searchInput, 'bitcoin');
668+
669+
expect(getByTestId('predict-search-result-0')).toBeOnTheScreen();
670+
expect(getByTestId('predict-search-result-1')).toBeOnTheScreen();
671+
});
672+
673+
it('invokes useDebouncedValue with 200ms delay', () => {
674+
const { getByTestId, getByPlaceholderText } = render(<PredictFeed />);
675+
676+
fireEvent.press(getByTestId('predict-search-button'));
677+
const searchInput = getByPlaceholderText('Search prediction markets');
678+
fireEvent.changeText(searchInput, 'test');
679+
680+
expect(mockUseDebouncedValue).toHaveBeenCalledWith('test', 200);
681+
});
682+
});
604683
});

app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
getPredictMarketListSelector,
4949
} from '../../Predict.testIds';
5050
import { usePredictMarketData } from '../../hooks/usePredictMarketData';
51+
import { useDebouncedValue } from '../../../../hooks/useDebouncedValue';
5152
import { useFeedScrollManager } from '../../hooks/useFeedScrollManager';
5253
import {
5354
PredictCategory,
@@ -515,6 +516,8 @@ interface PredictSearchOverlayProps {
515516
onClose: () => void;
516517
}
517518

519+
const SEARCH_DEBOUNCE_MS = 200;
520+
518521
const PredictSearchOverlay: React.FC<PredictSearchOverlayProps> = ({
519522
isVisible,
520523
onClose,
@@ -523,13 +526,20 @@ const PredictSearchOverlay: React.FC<PredictSearchOverlayProps> = ({
523526
const { colors } = useTheme();
524527
const insets = useSafeAreaInsets();
525528
const [searchQuery, setSearchQuery] = useState('');
529+
const debouncedSearchQuery = useDebouncedValue(
530+
searchQuery,
531+
SEARCH_DEBOUNCE_MS,
532+
);
533+
const isDebouncing = searchQuery !== debouncedSearchQuery;
526534

527535
const { marketData, isFetching, error, refetch } = usePredictMarketData({
528536
category: 'trending',
529-
q: searchQuery,
537+
q: debouncedSearchQuery,
530538
pageSize: 20,
531539
});
532540

541+
const isSearchLoading = isDebouncing || isFetching;
542+
533543
const handleSearch = useCallback((text: string) => {
534544
setSearchQuery(text);
535545
}, []);
@@ -607,7 +617,7 @@ const PredictSearchOverlay: React.FC<PredictSearchOverlayProps> = ({
607617

608618
{searchQuery.length > 0 && (
609619
<Box twClassName="flex-1">
610-
{isFetching ? (
620+
{isSearchLoading ? (
611621
<Box twClassName="px-4 pt-4">
612622
<PredictMarketSkeleton testID="search-skeleton-1" />
613623
<PredictMarketSkeleton testID="search-skeleton-2" />

0 commit comments

Comments
 (0)