[READ-ONLY] a fast, modern browser for the npm registry

perf: search improvements (#1431)

authored by

Alex Savelyev and committed by
GitHub
fd3a597d a725ea60

+94 -127
+2 -68
app/components/Header/SearchBox.vue
··· 1 1 <script setup lang="ts"> 2 - import { debounce } from 'perfect-debounce' 3 - import { normalizeSearchParam } from '#shared/utils/url' 4 - 5 2 withDefaults( 6 3 defineProps<{ 7 4 inputClass?: string ··· 12 9 ) 13 10 14 11 const emit = defineEmits(['blur', 'focus']) 15 - 16 - const router = useRouter() 17 12 const route = useRoute() 18 - const { searchProvider } = useSearchProvider() 19 - const searchProviderValue = computed(() => { 20 - const p = normalizeSearchParam(route.query.p) 21 - if (p === 'npm' || searchProvider.value === 'npm') return 'npm' 22 - return 'algolia' 23 - }) 24 - 25 13 const isSearchFocused = shallowRef(false) 26 14 27 15 const showSearchBar = computed(() => { 28 16 return route.name !== 'index' 29 17 }) 30 18 31 - const searchQuery = useGlobalSearchQuery() 32 - 33 - // Pages that have their own local filter using ?q 34 - const pagesWithLocalFilter = new Set(['~username', 'org']) 35 - 36 - function updateUrlQueryImpl(value: string, provider: 'npm' | 'algolia') { 37 - // Don't navigate away from pages that use ?q for local filtering 38 - if (pagesWithLocalFilter.has(route.name as string)) { 39 - return 40 - } 41 - if (route.name === 'search') { 42 - router.replace({ query: { q: value || undefined, p: provider === 'npm' ? 'npm' : undefined } }) 43 - return 44 - } 45 - if (!value) { 46 - return 47 - } 48 - 49 - router.push({ 50 - name: 'search', 51 - query: { 52 - q: value, 53 - p: provider === 'npm' ? 'npm' : undefined, 54 - }, 55 - }) 56 - } 57 - 58 - const updateUrlQueryNpm = debounce(updateUrlQueryImpl, 250) 59 - const updateUrlQueryAlgolia = debounce(updateUrlQueryImpl, 80) 60 - 61 - const updateUrlQuery = Object.assign( 62 - (value: string) => 63 - (searchProviderValue.value === 'algolia' ? updateUrlQueryAlgolia : updateUrlQueryNpm)( 64 - value, 65 - searchProviderValue.value, 66 - ), 67 - { 68 - flush: () => 69 - (searchProviderValue.value === 'algolia' ? updateUrlQueryAlgolia : updateUrlQueryNpm).flush(), 70 - }, 71 - ) 72 - 73 - watch(searchQuery, value => { 74 - updateUrlQuery(value) 75 - }) 19 + const { model: searchQuery, flushUpdateUrlQuery } = useGlobalSearch() 76 20 77 21 function handleSubmit() { 78 - if (pagesWithLocalFilter.has(route.name as string)) { 79 - router.push({ 80 - name: 'search', 81 - query: { 82 - q: searchQuery.value, 83 - p: searchProviderValue.value === 'npm' ? 'npm' : undefined, 84 - }, 85 - }) 86 - } else { 87 - updateUrlQuery.flush() 88 - } 22 + flushUpdateUrlQuery() 89 23 } 90 24 91 25 // Expose focus method for parent components
+4 -1
app/components/Package/Keywords.vue
··· 2 2 defineProps<{ 3 3 keywords?: string[] 4 4 }>() 5 + 6 + const { model } = useGlobalSearch() 5 7 </script> 6 8 <template> 7 9 <CollapsibleSection v-if="keywords?.length" :title="$t('package.keywords_title')" id="keywords"> ··· 10 12 <LinkBase 11 13 variant="button-secondary" 12 14 size="small" 13 - :to="{ name: 'search', query: { q: `keywords:${keyword}` } }" 15 + :to="{ name: 'search', query: { q: `keyword:${keyword}` } }" 16 + @click="model = `keyword:${keyword}`" 14 17 > 15 18 {{ keyword }} 16 19 </LinkBase>
+75
app/composables/useGlobalSearch.ts
··· 1 + import { normalizeSearchParam } from '#shared/utils/url' 2 + import { debounce } from 'perfect-debounce' 3 + 4 + // Pages that have their own local filter using ?q 5 + const pagesWithLocalFilter = new Set(['~username', 'org']) 6 + 7 + export function useGlobalSearch() { 8 + const { searchProvider } = useSearchProvider() 9 + const searchProviderValue = computed(() => { 10 + const p = normalizeSearchParam(route.query.p) 11 + if (p === 'npm' || searchProvider.value === 'npm') return 'npm' 12 + return 'algolia' 13 + }) 14 + const router = useRouter() 15 + const route = useRoute() 16 + const searchQuery = useState<string>('search-query', () => { 17 + if (pagesWithLocalFilter.has(route.name as string)) { 18 + return '' 19 + } 20 + return normalizeSearchParam(route.query.q) 21 + }) 22 + 23 + // clean search input when navigating away from search page 24 + watch( 25 + () => route.query.q, 26 + urlQuery => { 27 + const value = normalizeSearchParam(urlQuery) 28 + if (!value) searchQuery.value = '' 29 + }, 30 + ) 31 + const updateUrlQueryImpl = (value: string, provider: 'npm' | 'algolia') => { 32 + const isSameQuery = route.query.q === value && route.query.p === provider 33 + // Don't navigate away from pages that use ?q for local filtering 34 + if (pagesWithLocalFilter.has(route.name as string) || isSameQuery) { 35 + return 36 + } 37 + 38 + if (route.name === 'search') { 39 + router.replace({ 40 + query: { 41 + ...route.query, 42 + q: value || undefined, 43 + p: provider === 'npm' ? 'npm' : undefined, 44 + }, 45 + }) 46 + return 47 + } 48 + router.push({ 49 + name: 'search', 50 + query: { 51 + q: value, 52 + p: provider === 'npm' ? 'npm' : undefined, 53 + }, 54 + }) 55 + } 56 + const updateUrlQuery = debounce(updateUrlQueryImpl, 250) 57 + 58 + function flushUpdateUrlQuery() { 59 + updateUrlQuery.flush() 60 + } 61 + 62 + const searchQueryValue = computed({ 63 + get: () => searchQuery.value, 64 + set: async (value: string) => { 65 + searchQuery.value = value 66 + 67 + // Leading debounce implementation as it doesn't work properly out of the box (https://github.com/unjs/perfect-debounce/issues/43) 68 + if (!updateUrlQuery.isPending()) { 69 + updateUrlQueryImpl(value, searchProvider.value) 70 + } 71 + updateUrlQuery(value, searchProvider.value) 72 + }, 73 + }) 74 + return { model: searchQueryValue, provider: searchProviderValue, flushUpdateUrlQuery } 75 + }
-16
app/composables/useGlobalSearchQuery.ts
··· 1 - import { normalizeSearchParam } from '#shared/utils/url' 2 - 3 - export function useGlobalSearchQuery() { 4 - const route = useRoute() 5 - const searchQuery = useState<string>('search-query', () => normalizeSearchParam(route.query.q)) 6 - 7 - // clean search input when navigating away from search page 8 - watch( 9 - () => route.query.q, 10 - urlQuery => { 11 - const value = normalizeSearchParam(urlQuery) 12 - if (!value) searchQuery.value = '' 13 - }, 14 - ) 15 - return searchQuery 16 - }
+5 -1
app/composables/useStructuredFilters.ts
··· 87 87 88 88 interface UseStructuredFiltersOptions { 89 89 packages: Ref<NpmSearchResult[]> 90 + searchQueryModel?: Ref<string> 90 91 initialFilters?: Partial<StructuredFilters> 91 92 initialSort?: SortOption 92 93 } ··· 114 115 export function useStructuredFilters(options: UseStructuredFiltersOptions) { 115 116 const route = useRoute() 116 117 const router = useRouter() 117 - const { packages, initialFilters, initialSort } = options 118 + const { packages, initialFilters, initialSort, searchQueryModel } = options 118 119 const { t } = useI18n() 119 120 120 121 const searchQuery = shallowRef(normalizeSearchParam(route.query.q)) ··· 404 405 ? `${searchQuery.value.trim()} keyword:${keyword}` 405 406 : `keyword:${keyword}` 406 407 router.replace({ query: { ...route.query, q: newQ } }) 408 + 409 + if (searchQueryModel) searchQueryModel.value = newQ 407 410 } 408 411 } 409 412 ··· 411 414 filters.value.keywords = filters.value.keywords.filter(k => k !== keyword) 412 415 const newQ = searchQuery.value.replace(new RegExp(`keyword:${keyword}($| )`, 'g'), '').trim() 413 416 router.replace({ query: { ...route.query, q: newQ || undefined } }) 417 + if (searchQueryModel) searchQueryModel.value = newQ 414 418 } 415 419 416 420 function toggleKeyword(keyword: string) {
+2 -28
app/pages/index.vue
··· 1 1 <script setup lang="ts"> 2 - import { debounce } from 'perfect-debounce' 3 2 import { SHOWCASED_FRAMEWORKS } from '~/utils/frameworks' 4 3 5 - const { searchProvider } = useSearchProvider() 6 - 7 - const searchQuery = useGlobalSearchQuery() 4 + const { model: searchQuery, flushUpdateUrlQuery } = useGlobalSearch() 8 5 const isSearchFocused = shallowRef(false) 9 6 10 7 async function search() { 11 - const query = searchQuery.value.trim() 12 - if (!query) return 13 - await navigateTo({ 14 - path: '/search', 15 - query: query ? { q: query, p: searchProvider.value === 'npm' ? 'npm' : undefined } : undefined, 16 - }) 17 - const newQuery = searchQuery.value.trim() 18 - if (newQuery !== query) { 19 - await search() 20 - } 21 - } 22 - 23 - const handleInputNpm = debounce(search, 250, { leading: true, trailing: true }) 24 - const handleInputAlgolia = debounce(search, 80, { leading: true, trailing: true }) 25 - 26 - function handleInput() { 27 - if (isTouchDevice()) { 28 - search() 29 - } else if (searchProvider.value === 'algolia') { 30 - handleInputAlgolia() 31 - } else { 32 - handleInputNpm() 33 - } 8 + flushUpdateUrlQuery() 34 9 } 35 10 36 11 useSeoMeta({ ··· 104 79 class="w-full ps-8 pe-24" 105 80 @focus="isSearchFocused = true" 106 81 @blur="isSearchFocused = false" 107 - @input="handleInput" 108 82 /> 109 83 110 84 <ButtonBase
+6 -13
app/pages/search.vue
··· 10 10 const route = useRoute() 11 11 const router = useRouter() 12 12 13 - const { searchProvider } = useSearchProvider() 14 - const searchProviderValue = computed(() => { 15 - const p = normalizeSearchParam(route.query.p) 16 - if (p === 'npm' || searchProvider.value === 'npm') return 'npm' 17 - return 'algolia' 18 - }) 19 - 20 13 // Preferences (persisted to localStorage) 21 14 const { 22 15 viewMode, ··· 33 26 query: { 34 27 ...route.query, 35 28 page: page > 1 ? page : undefined, 36 - p: searchProviderValue.value === 'npm' ? 'npm' : undefined, 37 29 }, 38 30 }) 39 31 }, 500) 40 32 41 - const searchQuery = useGlobalSearchQuery() 33 + const { model: searchQuery, provider: searchProvider } = useGlobalSearch() 42 34 const query = computed(() => searchQuery.value) 43 35 44 36 // Track if page just loaded (for hiding "Searching..." during view transition) ··· 131 123 132 124 // Disable sort keys the current provider can't meaningfully sort by 133 125 const disabledSortKeys = computed<SortKey[]>(() => { 134 - const supported = PROVIDER_SORT_KEYS[searchProviderValue.value] 126 + const supported = PROVIDER_SORT_KEYS[searchProvider.value] 135 127 return ALL_SORT_KEYS.filter(k => !supported.has(k)) 136 128 }) 137 129 ··· 155 147 ...parseSearchOperators(normalizeSearchParam(route.query.q)), 156 148 }, 157 149 initialSort: 'relevance-desc', // Default to search relevance 150 + searchQueryModel: searchQuery, 158 151 }) 159 152 160 153 const isRelevanceSort = computed( ··· 173 166 // When sorting by something other than relevance, fetch a large batch 174 167 // so client-side sorting operates on a meaningful pool of matching results 175 168 if (!isRelevanceSort.value) { 176 - const cap = EAGER_LOAD_SIZE[searchProviderValue.value] 169 + const cap = EAGER_LOAD_SIZE[searchProvider.value] 177 170 return Math.max(base, cap) 178 171 } 179 172 return base 180 173 }) 181 174 182 175 // Reset to relevance sort when switching to a provider that doesn't support the current sort key 183 - watch(searchProviderValue, provider => { 176 + watch(searchProvider, provider => { 184 177 const { key } = parseSortOption(sortOption.value) 185 178 const supported = PROVIDER_SORT_KEYS[provider] 186 179 if (!supported.has(key)) { ··· 200 193 packageAvailability, 201 194 } = useSearch( 202 195 query, 203 - searchProviderValue, 196 + searchProvider, 204 197 () => ({ 205 198 size: requestedSize.value, 206 199 }),