From 5d715ae1d0266937cd877e6ed5c457975615452f Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 29 Apr 2024 16:52:24 +0100 Subject: [PATCH] Improve search screen perf (#3752) * Extract SearchHistory to a component * Extract AutocompleteResults to a component * Extract SearchInputBox to a component * Add a bunch of memoization * Optimize switching by rendering both * Remove subdomain matching This is only ever useful if you type it exactly correct. Search now does a better job anyway. * Give recent search decent hitslops --- src/view/screens/Search/Search.tsx | 487 +++++++++++++++++------------ src/view/shell/desktop/Search.tsx | 30 +- 2 files changed, 295 insertions(+), 222 deletions(-) diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index 1524c244..2335549a 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -49,11 +49,7 @@ import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' import {List} from '#/view/com/util/List' import {Text} from '#/view/com/util/text/Text' import {CenteredView, ScrollView} from '#/view/com/util/Views' -import { - MATCH_HANDLE, - SearchLinkCard, - SearchProfileCard, -} from '#/view/shell/desktop/Search' +import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search' import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {atoms as a} from '#/alf' @@ -156,7 +152,7 @@ function useSuggestedFollows(): [ return [items, onEndReached] } -function SearchScreenSuggestedFollows() { +let SearchScreenSuggestedFollows = (_props: {}): React.ReactNode => { const pal = usePalette('default') const [suggestions, onEndReached] = useSuggestedFollows() @@ -180,6 +176,7 @@ function SearchScreenSuggestedFollows() { ) } +SearchScreenSuggestedFollows = React.memo(SearchScreenSuggestedFollows) type SearchResultSlice = | { @@ -192,7 +189,7 @@ type SearchResultSlice = key: string } -function SearchScreenPostResults({ +let SearchScreenPostResults = ({ query, sort, active, @@ -200,7 +197,7 @@ function SearchScreenPostResults({ query: string sort?: 'top' | 'latest' active: boolean -}) { +}): React.ReactNode => { const {_} = useLingui() const {currentAccount} = useSession() const [isPTR, setIsPTR] = React.useState(false) @@ -298,14 +295,15 @@ function SearchScreenPostResults({ ) } +SearchScreenPostResults = React.memo(SearchScreenPostResults) -function SearchScreenUserResults({ +let SearchScreenUserResults = ({ query, active, }: { query: string active: boolean -}) { +}): React.ReactNode => { const {_} = useLingui() const {data: results, isFetched} = useActorSearch({ @@ -334,8 +332,9 @@ function SearchScreenUserResults({ ) } +SearchScreenUserResults = React.memo(SearchScreenUserResults) -export function SearchScreenInner({query}: {query?: string}) { +let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() @@ -467,18 +466,17 @@ export function SearchScreenInner({query}: {query?: string}) { ) } +SearchScreenInner = React.memo(SearchScreenInner) export function SearchScreen( props: NativeStackScreenProps, ) { const navigation = useNavigation() - const theme = useTheme() const textInput = React.useRef(null) const {_} = useLingui() const pal = usePalette('default') const {track} = useAnalytics() const setDrawerOpen = useSetDrawerOpen() - const moderationOpts = useModerationOpts() const setMinimalShellMode = useSetMinimalShellMode() const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries() @@ -584,21 +582,27 @@ export function SearchScreen( navigateToItem(searchText) }, [navigateToItem, searchText]) - const handleHistoryItemClick = (item: string) => { - setSearchText(item) - navigateToItem(item) - } + const onAutocompleteResultPress = React.useCallback(() => { + if (isWeb) { + setShowAutocomplete(false) + } else { + textInput.current?.blur() + } + }, []) + + const handleHistoryItemClick = React.useCallback( + (item: string) => { + setSearchText(item) + navigateToItem(item) + }, + [navigateToItem], + ) const onSoftReset = React.useCallback(() => { scrollToTopWeb() onPressCancelSearch() }, [onPressCancelSearch]) - const queryMaybeHandle = React.useMemo(() => { - const match = MATCH_HANDLE.exec(queryParam) - return match && match[1] - }, [queryParam]) - useFocusEffect( React.useCallback(() => { setMinimalShellMode(false) @@ -606,15 +610,19 @@ export function SearchScreen( }, [onSoftReset, setMinimalShellMode]), ) - const handleRemoveHistoryItem = (itemToRemove: string) => { - const updatedHistory = searchHistory.filter(item => item !== itemToRemove) - setSearchHistory(updatedHistory) - AsyncStorage.setItem('searchHistory', JSON.stringify(updatedHistory)).catch( - e => { + const handleRemoveHistoryItem = React.useCallback( + (itemToRemove: string) => { + const updatedHistory = searchHistory.filter(item => item !== itemToRemove) + setSearchHistory(updatedHistory) + AsyncStorage.setItem( + 'searchHistory', + JSON.stringify(updatedHistory), + ).catch(e => { logger.error('Failed to update search history', {message: e}) - }, - ) - } + }) + }, + [searchHistory], + ) return ( @@ -642,81 +650,15 @@ export function SearchScreen( /> )} - - { - textInput.current?.focus() - }}> - - { - if (isWeb) { - // Prevent a jump on iPad by ensuring that - // the initial focused render has no result list. - requestAnimationFrame(() => { - setShowAutocomplete(true) - }) - } else { - setShowAutocomplete(true) - if (isIOS) { - // We rely on selectTextOnFocus, but it's broken on iOS: - // https://github.com/facebook/react-native/issues/41988 - textInput.current?.setSelection(0, searchText.length) - // We still rely on selectTextOnFocus for it to be instant on Android. - } - } - }} - onChangeText={onChangeText} - onSubmitEditing={onSubmit} - autoFocus={false} - accessibilityRole="search" - accessibilityLabel={_(msg`Search`)} - accessibilityHint="" - autoCorrect={false} - autoComplete="off" - autoCapitalize="none" - /> - {showAutocomplete && searchText.length > 0 && ( - - - - )} - + {showAutocomplete && ( )} - - {showAutocomplete && searchText.length > 0 ? ( - <> - {(isAutocompleteFetching && !autocompleteData?.length) || - !moderationOpts ? ( - - ) : ( - - - - {queryMaybeHandle ? ( - - ) : null} - - {autocompleteData?.map(item => ( - { - if (isWeb) { - setShowAutocomplete(false) - } else { - textInput.current?.blur() - } - }} - /> - ))} - - - - )} - - ) : !queryParam && showAutocomplete ? ( - - - {searchHistory.length > 0 && ( - - - Recent Searches - - {searchHistory.map((historyItem, index) => ( - - handleHistoryItemClick(historyItem)} - style={[a.flex_1, a.py_sm]}> - {historyItem} - - handleRemoveHistoryItem(historyItem)} - style={[a.px_md, a.py_xs, a.justify_center]}> - - - - ))} - - )} - - - ) : ( + + {searchText.length > 0 ? ( + + ) : ( + + )} + + - )} + ) } +let SearchInputBox = ({ + textInput, + searchText, + showAutocomplete, + setShowAutocomplete, + onChangeText, + onSubmit, + onPressClearQuery, +}: { + textInput: React.RefObject + searchText: string + showAutocomplete: boolean + setShowAutocomplete: (show: boolean) => void + onChangeText: (text: string) => void + onSubmit: () => void + onPressClearQuery: () => void +}): React.ReactNode => { + const pal = usePalette('default') + const {_} = useLingui() + const theme = useTheme() + return ( + { + textInput.current?.focus() + }}> + + { + if (isWeb) { + // Prevent a jump on iPad by ensuring that + // the initial focused render has no result list. + requestAnimationFrame(() => { + setShowAutocomplete(true) + }) + } else { + setShowAutocomplete(true) + if (isIOS) { + // We rely on selectTextOnFocus, but it's broken on iOS: + // https://github.com/facebook/react-native/issues/41988 + textInput.current?.setSelection(0, searchText.length) + // We still rely on selectTextOnFocus for it to be instant on Android. + } + } + }} + onChangeText={onChangeText} + onSubmitEditing={onSubmit} + autoFocus={false} + accessibilityRole="search" + accessibilityLabel={_(msg`Search`)} + accessibilityHint="" + autoCorrect={false} + autoComplete="off" + autoCapitalize="none" + /> + {showAutocomplete && searchText.length > 0 && ( + + + + )} + + ) +} +SearchInputBox = React.memo(SearchInputBox) + +let AutocompleteResults = ({ + isAutocompleteFetching, + autocompleteData, + searchText, + onSubmit, + onResultPress, +}: { + isAutocompleteFetching: boolean + autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined + searchText: string + onSubmit: () => void + onResultPress: () => void +}): React.ReactNode => { + const moderationOpts = useModerationOpts() + const {_} = useLingui() + return ( + <> + {(isAutocompleteFetching && !autocompleteData?.length) || + !moderationOpts ? ( + + ) : ( + + + {autocompleteData?.map(item => ( + + ))} + + + )} + + ) +} +AutocompleteResults = React.memo(AutocompleteResults) + +function SearchHistory({ + searchHistory, + onItemClick, + onRemoveItemClick, +}: { + searchHistory: string[] + onItemClick: (item: string) => void + onRemoveItemClick: (item: string) => void +}) { + const {isTabletOrDesktop} = useWebMediaQueries() + const pal = usePalette('default') + return ( + + + {searchHistory.length > 0 && ( + + + Recent Searches + + {searchHistory.map((historyItem, index) => ( + + onItemClick(historyItem)} + hitSlop={HITSLOP_10} + style={[a.flex_1, a.py_sm]}> + {historyItem} + + onRemoveItemClick(historyItem)} + hitSlop={HITSLOP_10} + style={[a.px_md, a.py_xs, a.justify_center]}> + + + + ))} + + )} + + + ) +} + function scrollToTopWeb() { if (isWeb) { window.scrollTo(0, 0) diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 52f28cc6..683d4421 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -31,10 +31,7 @@ import {Link} from '#/view/com/util/Link' import {UserAvatar} from '#/view/com/util/UserAvatar' import {Text} from 'view/com/util/text/Text' -export const MATCH_HANDLE = - /@?([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))/ - -export function SearchLinkCard({ +let SearchLinkCard = ({ label, to, onPress, @@ -44,7 +41,7 @@ export function SearchLinkCard({ to?: string onPress?: () => void style?: ViewStyle -}) { +}): React.ReactNode => { const pal = usePalette('default') const inner = ( @@ -82,8 +79,10 @@ export function SearchLinkCard({ ) } +SearchLinkCard = React.memo(SearchLinkCard) +export {SearchLinkCard} -export function SearchProfileCard({ +let SearchProfileCard = ({ profile, moderation, onPress: onPressInner, @@ -91,7 +90,7 @@ export function SearchProfileCard({ profile: AppBskyActorDefs.ProfileViewBasic moderation: ModerationDecision onPress: () => void -}) { +}): React.ReactNode => { const pal = usePalette('default') const queryClient = useQueryClient() @@ -144,6 +143,8 @@ export function SearchProfileCard({ ) } +SearchProfileCard = React.memo(SearchProfileCard) +export {SearchProfileCard} export function DesktopSearch() { const {_} = useLingui() @@ -179,11 +180,6 @@ export function DesktopSearch() { setIsActive(false) }, []) - const queryMaybeHandle = React.useMemo(() => { - const match = MATCH_HANDLE.exec(query) - return match && match[1] - }, [query]) - return ( 0 + (autocompleteData?.length ?? 0) > 0 ? {borderBottomWidth: 1} : undefined } /> - - {queryMaybeHandle ? ( - - ) : null} - {autocompleteData?.map(item => (