diff options
author | dan <dan.abramov@gmail.com> | 2024-04-29 16:52:24 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-04-29 16:52:24 +0100 |
commit | 5d715ae1d0266937cd877e6ed5c457975615452f (patch) | |
tree | 86cd00a67d387b3143259199c4ac95ce10a15d95 | |
parent | 3c2d73909b1cf4716764ccb3e86ce77832d2e37d (diff) | |
download | voidsky-5d715ae1d0266937cd877e6ed5c457975615452f.tar.zst |
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
-rw-r--r-- | src/view/screens/Search/Search.tsx | 483 | ||||
-rw-r--r-- | src/view/shell/desktop/Search.tsx | 30 |
2 files changed, 293 insertions, 220 deletions
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index 1524c2446..2335549af 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() { </CenteredView> ) } +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({ <Loader /> ) } +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}) { </CenteredView> ) } +SearchScreenInner = React.memo(SearchScreenInner) export function SearchScreen( props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, ) { const navigation = useNavigation<NavigationProp>() - const theme = useTheme() const textInput = React.useRef<TextInput>(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 ( <View style={isWeb ? null : {flex: 1}}> @@ -642,81 +650,15 @@ export function SearchScreen( /> </Pressable> )} - - <Pressable - // This only exists only for extra hitslop so don't expose it to the a11y tree. - accessible={false} - focusable={false} - // @ts-ignore web-only - tabIndex={-1} - style={[ - {backgroundColor: pal.colors.backgroundLight}, - styles.headerSearchContainer, - isWeb && { - // @ts-ignore web only - cursor: 'default', - }, - ]} - onPress={() => { - textInput.current?.focus() - }}> - <MagnifyingGlassIcon - style={[pal.icon, styles.headerSearchIcon]} - size={21} - /> - <TextInput - testID="searchTextInput" - ref={textInput} - placeholder={_(msg`Search`)} - placeholderTextColor={pal.colors.textLight} - returnKeyType="search" - value={searchText} - style={[pal.text, styles.headerSearchInput]} - keyboardAppearance={theme.colorScheme} - selectTextOnFocus={isNative} - onFocus={() => { - 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 && ( - <Pressable - testID="searchTextInputClearBtn" - onPress={onPressClearQuery} - accessibilityRole="button" - accessibilityLabel={_(msg`Clear search query`)} - accessibilityHint="" - hitSlop={HITSLOP_10}> - <FontAwesomeIcon - icon="xmark" - size={16} - style={pal.textLight as FontAwesomeIconStyle} - /> - </Pressable> - )} - </Pressable> + <SearchInputBox + textInput={textInput} + searchText={searchText} + showAutocomplete={showAutocomplete} + setShowAutocomplete={setShowAutocomplete} + onChangeText={onChangeText} + onSubmit={onSubmit} + onPressClearQuery={onPressClearQuery} + /> {showAutocomplete && ( <View style={[styles.headerCancelBtn]}> <Pressable @@ -730,104 +672,247 @@ export function SearchScreen( </View> )} </CenteredView> + <View + style={{ + display: showAutocomplete ? 'flex' : 'none', + flex: 1, + }}> + {searchText.length > 0 ? ( + <AutocompleteResults + isAutocompleteFetching={isAutocompleteFetching} + autocompleteData={autocompleteData} + searchText={searchText} + onSubmit={onSubmit} + onResultPress={onAutocompleteResultPress} + /> + ) : ( + <SearchHistory + searchHistory={searchHistory} + onItemClick={handleHistoryItemClick} + onRemoveItemClick={handleRemoveHistoryItem} + /> + )} + </View> + <View + style={{ + display: showAutocomplete ? 'none' : 'flex', + flex: 1, + }}> + <SearchScreenInner query={queryParam} /> + </View> + </View> + ) +} - {showAutocomplete && searchText.length > 0 ? ( - <> - {(isAutocompleteFetching && !autocompleteData?.length) || - !moderationOpts ? ( - <Loader /> - ) : ( - <ScrollView - style={{height: '100%'}} - // @ts-ignore web only -prf - dataSet={{stableGutters: '1'}} - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag"> - <SearchLinkCard - label={_(msg`Search for "${searchText}"`)} - onPress={isNative ? onSubmit : undefined} - to={ - isNative - ? undefined - : `/search?q=${encodeURIComponent(searchText)}` - } - style={{borderBottomWidth: 1}} - /> - - {queryMaybeHandle ? ( - <SearchLinkCard - label={_(msg`Go to @${queryMaybeHandle}`)} - to={`/profile/${queryMaybeHandle}`} - /> - ) : null} - - {autocompleteData?.map(item => ( - <SearchProfileCard - key={item.did} - profile={item} - moderation={moderateProfile(item, moderationOpts)} - onPress={() => { - if (isWeb) { - setShowAutocomplete(false) - } else { - textInput.current?.blur() - } - }} - /> - ))} - - <View style={{height: 200}} /> - </ScrollView> - )} - </> - ) : !queryParam && showAutocomplete ? ( - <CenteredView - sideBorders={isTabletOrDesktop} +let SearchInputBox = ({ + textInput, + searchText, + showAutocomplete, + setShowAutocomplete, + onChangeText, + onSubmit, + onPressClearQuery, +}: { + textInput: React.RefObject<TextInput> + 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 ( + <Pressable + // This only exists only for extra hitslop so don't expose it to the a11y tree. + accessible={false} + focusable={false} + // @ts-ignore web-only + tabIndex={-1} + style={[ + {backgroundColor: pal.colors.backgroundLight}, + styles.headerSearchContainer, + isWeb && { + // @ts-ignore web only + cursor: 'default', + }, + ]} + onPress={() => { + textInput.current?.focus() + }}> + <MagnifyingGlassIcon + style={[pal.icon, styles.headerSearchIcon]} + size={21} + /> + <TextInput + testID="searchTextInput" + ref={textInput} + placeholder={_(msg`Search`)} + placeholderTextColor={pal.colors.textLight} + returnKeyType="search" + value={searchText} + style={[pal.text, styles.headerSearchInput]} + keyboardAppearance={theme.colorScheme} + selectTextOnFocus={isNative} + onFocus={() => { + 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 && ( + <Pressable + testID="searchTextInputClearBtn" + onPress={onPressClearQuery} + accessibilityRole="button" + accessibilityLabel={_(msg`Clear search query`)} + accessibilityHint="" + hitSlop={HITSLOP_10}> + <FontAwesomeIcon + icon="xmark" + size={16} + style={pal.textLight as FontAwesomeIconStyle} + /> + </Pressable> + )} + </Pressable> + ) +} +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 ? ( + <Loader /> + ) : ( + <ScrollView + style={{height: '100%'}} // @ts-ignore web only -prf - style={{ - height: isWeb ? '100vh' : undefined, - }}> - <View style={styles.searchHistoryContainer}> - {searchHistory.length > 0 && ( - <View style={styles.searchHistoryContent}> - <Text style={[pal.text, styles.searchHistoryTitle]}> - <Trans>Recent Searches</Trans> - </Text> - {searchHistory.map((historyItem, index) => ( - <View - key={index} - style={[ - a.flex_row, - a.mt_md, - a.justify_center, - a.justify_between, - ]}> - <Pressable - accessibilityRole="button" - onPress={() => handleHistoryItemClick(historyItem)} - style={[a.flex_1, a.py_sm]}> - <Text style={pal.text}>{historyItem}</Text> - </Pressable> - <Pressable - accessibilityRole="button" - onPress={() => handleRemoveHistoryItem(historyItem)} - style={[a.px_md, a.py_xs, a.justify_center]}> - <FontAwesomeIcon - icon="xmark" - size={16} - style={pal.textLight as FontAwesomeIconStyle} - /> - </Pressable> - </View> - ))} + dataSet={{stableGutters: '1'}} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag"> + <SearchLinkCard + label={_(msg`Search for "${searchText}"`)} + onPress={isNative ? onSubmit : undefined} + to={ + isNative + ? undefined + : `/search?q=${encodeURIComponent(searchText)}` + } + style={{borderBottomWidth: 1}} + /> + {autocompleteData?.map(item => ( + <SearchProfileCard + key={item.did} + profile={item} + moderation={moderateProfile(item, moderationOpts)} + onPress={onResultPress} + /> + ))} + <View style={{height: 200}} /> + </ScrollView> + )} + </> + ) +} +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 ( + <CenteredView + sideBorders={isTabletOrDesktop} + // @ts-ignore web only -prf + style={{ + height: isWeb ? '100vh' : undefined, + }}> + <View style={styles.searchHistoryContainer}> + {searchHistory.length > 0 && ( + <View style={styles.searchHistoryContent}> + <Text style={[pal.text, styles.searchHistoryTitle]}> + <Trans>Recent Searches</Trans> + </Text> + {searchHistory.map((historyItem, index) => ( + <View + key={index} + style={[ + a.flex_row, + a.mt_md, + a.justify_center, + a.justify_between, + ]}> + <Pressable + accessibilityRole="button" + onPress={() => onItemClick(historyItem)} + hitSlop={HITSLOP_10} + style={[a.flex_1, a.py_sm]}> + <Text style={pal.text}>{historyItem}</Text> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => onRemoveItemClick(historyItem)} + hitSlop={HITSLOP_10} + style={[a.px_md, a.py_xs, a.justify_center]}> + <FontAwesomeIcon + icon="xmark" + size={16} + style={pal.textLight as FontAwesomeIconStyle} + /> + </Pressable> </View> - )} + ))} </View> - </CenteredView> - ) : ( - <SearchScreenInner query={queryParam} /> - )} - </View> + )} + </View> + </CenteredView> ) } diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 52f28cc63..683d4421a 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({ </Link> ) } +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({ </Link> ) } +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 ( <View style={[styles.container, pal.view]}> <View @@ -239,19 +235,11 @@ export function DesktopSearch() { label={_(msg`Search for "${query}"`)} to={`/search?q=${encodeURIComponent(query)}`} style={ - queryMaybeHandle || (autocompleteData?.length ?? 0) > 0 + (autocompleteData?.length ?? 0) > 0 ? {borderBottomWidth: 1} : undefined } /> - - {queryMaybeHandle ? ( - <SearchLinkCard - label={_(msg`Go to @${queryMaybeHandle}`)} - to={`/profile/${queryMaybeHandle}`} - /> - ) : null} - {autocompleteData?.map(item => ( <SearchProfileCard key={item.did} |