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
zio/stable
dan 2024-04-29 16:52:24 +01:00 committed by GitHub
parent 3c2d73909b
commit 5d715ae1d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 295 additions and 222 deletions

View File

@ -49,11 +49,7 @@ import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {List} from '#/view/com/util/List' import {List} from '#/view/com/util/List'
import {Text} from '#/view/com/util/text/Text' import {Text} from '#/view/com/util/text/Text'
import {CenteredView, ScrollView} from '#/view/com/util/Views' import {CenteredView, ScrollView} from '#/view/com/util/Views'
import { import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search'
MATCH_HANDLE,
SearchLinkCard,
SearchProfileCard,
} from '#/view/shell/desktop/Search'
import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
@ -156,7 +152,7 @@ function useSuggestedFollows(): [
return [items, onEndReached] return [items, onEndReached]
} }
function SearchScreenSuggestedFollows() { let SearchScreenSuggestedFollows = (_props: {}): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
const [suggestions, onEndReached] = useSuggestedFollows() const [suggestions, onEndReached] = useSuggestedFollows()
@ -180,6 +176,7 @@ function SearchScreenSuggestedFollows() {
</CenteredView> </CenteredView>
) )
} }
SearchScreenSuggestedFollows = React.memo(SearchScreenSuggestedFollows)
type SearchResultSlice = type SearchResultSlice =
| { | {
@ -192,7 +189,7 @@ type SearchResultSlice =
key: string key: string
} }
function SearchScreenPostResults({ let SearchScreenPostResults = ({
query, query,
sort, sort,
active, active,
@ -200,7 +197,7 @@ function SearchScreenPostResults({
query: string query: string
sort?: 'top' | 'latest' sort?: 'top' | 'latest'
active: boolean active: boolean
}) { }): React.ReactNode => {
const {_} = useLingui() const {_} = useLingui()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const [isPTR, setIsPTR] = React.useState(false) const [isPTR, setIsPTR] = React.useState(false)
@ -298,14 +295,15 @@ function SearchScreenPostResults({
</> </>
) )
} }
SearchScreenPostResults = React.memo(SearchScreenPostResults)
function SearchScreenUserResults({ let SearchScreenUserResults = ({
query, query,
active, active,
}: { }: {
query: string query: string
active: boolean active: boolean
}) { }): React.ReactNode => {
const {_} = useLingui() const {_} = useLingui()
const {data: results, isFetched} = useActorSearch({ const {data: results, isFetched} = useActorSearch({
@ -334,8 +332,9 @@ function SearchScreenUserResults({
<Loader /> <Loader />
) )
} }
SearchScreenUserResults = React.memo(SearchScreenUserResults)
export function SearchScreenInner({query}: {query?: string}) { let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
@ -467,18 +466,17 @@ export function SearchScreenInner({query}: {query?: string}) {
</CenteredView> </CenteredView>
) )
} }
SearchScreenInner = React.memo(SearchScreenInner)
export function SearchScreen( export function SearchScreen(
props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
) { ) {
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const theme = useTheme()
const textInput = React.useRef<TextInput>(null) const textInput = React.useRef<TextInput>(null)
const {_} = useLingui() const {_} = useLingui()
const pal = usePalette('default') const pal = usePalette('default')
const {track} = useAnalytics() const {track} = useAnalytics()
const setDrawerOpen = useSetDrawerOpen() const setDrawerOpen = useSetDrawerOpen()
const moderationOpts = useModerationOpts()
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries() const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries()
@ -584,21 +582,27 @@ export function SearchScreen(
navigateToItem(searchText) navigateToItem(searchText)
}, [navigateToItem, searchText]) }, [navigateToItem, searchText])
const handleHistoryItemClick = (item: string) => { const onAutocompleteResultPress = React.useCallback(() => {
setSearchText(item) if (isWeb) {
navigateToItem(item) setShowAutocomplete(false)
} } else {
textInput.current?.blur()
}
}, [])
const handleHistoryItemClick = React.useCallback(
(item: string) => {
setSearchText(item)
navigateToItem(item)
},
[navigateToItem],
)
const onSoftReset = React.useCallback(() => { const onSoftReset = React.useCallback(() => {
scrollToTopWeb() scrollToTopWeb()
onPressCancelSearch() onPressCancelSearch()
}, [onPressCancelSearch]) }, [onPressCancelSearch])
const queryMaybeHandle = React.useMemo(() => {
const match = MATCH_HANDLE.exec(queryParam)
return match && match[1]
}, [queryParam])
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
setMinimalShellMode(false) setMinimalShellMode(false)
@ -606,15 +610,19 @@ export function SearchScreen(
}, [onSoftReset, setMinimalShellMode]), }, [onSoftReset, setMinimalShellMode]),
) )
const handleRemoveHistoryItem = (itemToRemove: string) => { const handleRemoveHistoryItem = React.useCallback(
const updatedHistory = searchHistory.filter(item => item !== itemToRemove) (itemToRemove: string) => {
setSearchHistory(updatedHistory) const updatedHistory = searchHistory.filter(item => item !== itemToRemove)
AsyncStorage.setItem('searchHistory', JSON.stringify(updatedHistory)).catch( setSearchHistory(updatedHistory)
e => { AsyncStorage.setItem(
'searchHistory',
JSON.stringify(updatedHistory),
).catch(e => {
logger.error('Failed to update search history', {message: e}) logger.error('Failed to update search history', {message: e})
}, })
) },
} [searchHistory],
)
return ( return (
<View style={isWeb ? null : {flex: 1}}> <View style={isWeb ? null : {flex: 1}}>
@ -642,81 +650,15 @@ export function SearchScreen(
/> />
</Pressable> </Pressable>
)} )}
<SearchInputBox
<Pressable textInput={textInput}
// This only exists only for extra hitslop so don't expose it to the a11y tree. searchText={searchText}
accessible={false} showAutocomplete={showAutocomplete}
focusable={false} setShowAutocomplete={setShowAutocomplete}
// @ts-ignore web-only onChangeText={onChangeText}
tabIndex={-1} onSubmit={onSubmit}
style={[ onPressClearQuery={onPressClearQuery}
{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>
{showAutocomplete && ( {showAutocomplete && (
<View style={[styles.headerCancelBtn]}> <View style={[styles.headerCancelBtn]}>
<Pressable <Pressable
@ -730,107 +672,250 @@ export function SearchScreen(
</View> </View>
)} )}
</CenteredView> </CenteredView>
<View
{showAutocomplete && searchText.length > 0 ? ( style={{
<> display: showAutocomplete ? 'flex' : 'none',
{(isAutocompleteFetching && !autocompleteData?.length) || flex: 1,
!moderationOpts ? ( }}>
<Loader /> {searchText.length > 0 ? (
) : ( <AutocompleteResults
<ScrollView isAutocompleteFetching={isAutocompleteFetching}
style={{height: '100%'}} autocompleteData={autocompleteData}
// @ts-ignore web only -prf searchText={searchText}
dataSet={{stableGutters: '1'}} onSubmit={onSubmit}
keyboardShouldPersistTaps="handled" onResultPress={onAutocompleteResultPress}
keyboardDismissMode="on-drag"> />
<SearchLinkCard ) : (
label={_(msg`Search for "${searchText}"`)} <SearchHistory
onPress={isNative ? onSubmit : undefined} searchHistory={searchHistory}
to={ onItemClick={handleHistoryItemClick}
isNative onRemoveItemClick={handleRemoveHistoryItem}
? undefined />
: `/search?q=${encodeURIComponent(searchText)}` )}
} </View>
style={{borderBottomWidth: 1}} <View
/> style={{
display: showAutocomplete ? 'none' : 'flex',
{queryMaybeHandle ? ( flex: 1,
<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}
// @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>
))}
</View>
)}
</View>
</CenteredView>
) : (
<SearchScreenInner query={queryParam} /> <SearchScreenInner query={queryParam} />
)} </View>
</View> </View>
) )
} }
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
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>
)}
</View>
</CenteredView>
)
}
function scrollToTopWeb() { function scrollToTopWeb() {
if (isWeb) { if (isWeb) {
window.scrollTo(0, 0) window.scrollTo(0, 0)

View File

@ -31,10 +31,7 @@ import {Link} from '#/view/com/util/Link'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
export const MATCH_HANDLE = let SearchLinkCard = ({
/@?([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))/
export function SearchLinkCard({
label, label,
to, to,
onPress, onPress,
@ -44,7 +41,7 @@ export function SearchLinkCard({
to?: string to?: string
onPress?: () => void onPress?: () => void
style?: ViewStyle style?: ViewStyle
}) { }): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
const inner = ( const inner = (
@ -82,8 +79,10 @@ export function SearchLinkCard({
</Link> </Link>
) )
} }
SearchLinkCard = React.memo(SearchLinkCard)
export {SearchLinkCard}
export function SearchProfileCard({ let SearchProfileCard = ({
profile, profile,
moderation, moderation,
onPress: onPressInner, onPress: onPressInner,
@ -91,7 +90,7 @@ export function SearchProfileCard({
profile: AppBskyActorDefs.ProfileViewBasic profile: AppBskyActorDefs.ProfileViewBasic
moderation: ModerationDecision moderation: ModerationDecision
onPress: () => void onPress: () => void
}) { }): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
const queryClient = useQueryClient() const queryClient = useQueryClient()
@ -144,6 +143,8 @@ export function SearchProfileCard({
</Link> </Link>
) )
} }
SearchProfileCard = React.memo(SearchProfileCard)
export {SearchProfileCard}
export function DesktopSearch() { export function DesktopSearch() {
const {_} = useLingui() const {_} = useLingui()
@ -179,11 +180,6 @@ export function DesktopSearch() {
setIsActive(false) setIsActive(false)
}, []) }, [])
const queryMaybeHandle = React.useMemo(() => {
const match = MATCH_HANDLE.exec(query)
return match && match[1]
}, [query])
return ( return (
<View style={[styles.container, pal.view]}> <View style={[styles.container, pal.view]}>
<View <View
@ -239,19 +235,11 @@ export function DesktopSearch() {
label={_(msg`Search for "${query}"`)} label={_(msg`Search for "${query}"`)}
to={`/search?q=${encodeURIComponent(query)}`} to={`/search?q=${encodeURIComponent(query)}`}
style={ style={
queryMaybeHandle || (autocompleteData?.length ?? 0) > 0 (autocompleteData?.length ?? 0) > 0
? {borderBottomWidth: 1} ? {borderBottomWidth: 1}
: undefined : undefined
} }
/> />
{queryMaybeHandle ? (
<SearchLinkCard
label={_(msg`Go to @${queryMaybeHandle}`)}
to={`/profile/${queryMaybeHandle}`}
/>
) : null}
{autocompleteData?.map(item => ( {autocompleteData?.map(item => (
<SearchProfileCard <SearchProfileCard
key={item.did} key={item.did}