Skip to content

Commit fd3a597

Browse files
authored
perf: search improvements (#1431)
1 parent a725ea6 commit fd3a597

File tree

7 files changed

+94
-127
lines changed

7 files changed

+94
-127
lines changed

app/components/Header/SearchBox.vue

Lines changed: 2 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
11
<script setup lang="ts">
2-
import { debounce } from 'perfect-debounce'
3-
import { normalizeSearchParam } from '#shared/utils/url'
4-
52
withDefaults(
63
defineProps<{
74
inputClass?: string
@@ -12,80 +9,17 @@ withDefaults(
129
)
1310
1411
const emit = defineEmits(['blur', 'focus'])
15-
16-
const router = useRouter()
1712
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-
2513
const isSearchFocused = shallowRef(false)
2614
2715
const showSearchBar = computed(() => {
2816
return route.name !== 'index'
2917
})
3018
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()
7620
7721
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()
8923
}
9024
9125
// Expose focus method for parent components

app/components/Package/Keywords.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
defineProps<{
33
keywords?: string[]
44
}>()
5+
6+
const { model } = useGlobalSearch()
57
</script>
68
<template>
79
<CollapsibleSection v-if="keywords?.length" :title="$t('package.keywords_title')" id="keywords">
@@ -10,7 +12,8 @@ defineProps<{
1012
<LinkBase
1113
variant="button-secondary"
1214
size="small"
13-
:to="{ name: 'search', query: { q: `keywords:${keyword}` } }"
15+
:to="{ name: 'search', query: { q: `keyword:${keyword}` } }"
16+
@click="model = `keyword:${keyword}`"
1417
>
1518
{{ keyword }}
1619
</LinkBase>

app/composables/useGlobalSearch.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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+
}

app/composables/useGlobalSearchQuery.ts

Lines changed: 0 additions & 16 deletions
This file was deleted.

app/composables/useStructuredFilters.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export function hasSearchOperators(parsed: ParsedSearchOperators): boolean {
8787

8888
interface UseStructuredFiltersOptions {
8989
packages: Ref<NpmSearchResult[]>
90+
searchQueryModel?: Ref<string>
9091
initialFilters?: Partial<StructuredFilters>
9192
initialSort?: SortOption
9293
}
@@ -114,7 +115,7 @@ function matchesSecurity(pkg: NpmSearchResult, security: SecurityFilter): boolea
114115
export function useStructuredFilters(options: UseStructuredFiltersOptions) {
115116
const route = useRoute()
116117
const router = useRouter()
117-
const { packages, initialFilters, initialSort } = options
118+
const { packages, initialFilters, initialSort, searchQueryModel } = options
118119
const { t } = useI18n()
119120

120121
const searchQuery = shallowRef(normalizeSearchParam(route.query.q))
@@ -404,13 +405,16 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) {
404405
? `${searchQuery.value.trim()} keyword:${keyword}`
405406
: `keyword:${keyword}`
406407
router.replace({ query: { ...route.query, q: newQ } })
408+
409+
if (searchQueryModel) searchQueryModel.value = newQ
407410
}
408411
}
409412

410413
function removeKeyword(keyword: string) {
411414
filters.value.keywords = filters.value.keywords.filter(k => k !== keyword)
412415
const newQ = searchQuery.value.replace(new RegExp(`keyword:${keyword}($| )`, 'g'), '').trim()
413416
router.replace({ query: { ...route.query, q: newQ || undefined } })
417+
if (searchQueryModel) searchQueryModel.value = newQ
414418
}
415419

416420
function toggleKeyword(keyword: string) {

app/pages/index.vue

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,11 @@
11
<script setup lang="ts">
2-
import { debounce } from 'perfect-debounce'
32
import { SHOWCASED_FRAMEWORKS } from '~/utils/frameworks'
43
5-
const { searchProvider } = useSearchProvider()
6-
7-
const searchQuery = useGlobalSearchQuery()
4+
const { model: searchQuery, flushUpdateUrlQuery } = useGlobalSearch()
85
const isSearchFocused = shallowRef(false)
96
107
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()
349
}
3510
3611
useSeoMeta({
@@ -104,7 +79,6 @@ defineOgImageComponent('Default', {
10479
class="w-full ps-8 pe-24"
10580
@focus="isSearchFocused = true"
10681
@blur="isSearchFocused = false"
107-
@input="handleInput"
10882
/>
10983

11084
<ButtonBase

app/pages/search.vue

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,6 @@ import { normalizeSearchParam } from '#shared/utils/url'
1010
const route = useRoute()
1111
const router = useRouter()
1212
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-
2013
// Preferences (persisted to localStorage)
2114
const {
2215
viewMode,
@@ -33,12 +26,11 @@ const updateUrlPage = debounce((page: number) => {
3326
query: {
3427
...route.query,
3528
page: page > 1 ? page : undefined,
36-
p: searchProviderValue.value === 'npm' ? 'npm' : undefined,
3729
},
3830
})
3931
}, 500)
4032
41-
const searchQuery = useGlobalSearchQuery()
33+
const { model: searchQuery, provider: searchProvider } = useGlobalSearch()
4234
const query = computed(() => searchQuery.value)
4335
4436
// Track if page just loaded (for hiding "Searching..." during view transition)
@@ -131,7 +123,7 @@ const ALL_SORT_KEYS: SortKey[] = [
131123
132124
// Disable sort keys the current provider can't meaningfully sort by
133125
const disabledSortKeys = computed<SortKey[]>(() => {
134-
const supported = PROVIDER_SORT_KEYS[searchProviderValue.value]
126+
const supported = PROVIDER_SORT_KEYS[searchProvider.value]
135127
return ALL_SORT_KEYS.filter(k => !supported.has(k))
136128
})
137129
@@ -155,6 +147,7 @@ const {
155147
...parseSearchOperators(normalizeSearchParam(route.query.q)),
156148
},
157149
initialSort: 'relevance-desc', // Default to search relevance
150+
searchQueryModel: searchQuery,
158151
})
159152
160153
const isRelevanceSort = computed(
@@ -173,14 +166,14 @@ const requestedSize = computed(() => {
173166
// When sorting by something other than relevance, fetch a large batch
174167
// so client-side sorting operates on a meaningful pool of matching results
175168
if (!isRelevanceSort.value) {
176-
const cap = EAGER_LOAD_SIZE[searchProviderValue.value]
169+
const cap = EAGER_LOAD_SIZE[searchProvider.value]
177170
return Math.max(base, cap)
178171
}
179172
return base
180173
})
181174
182175
// 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 => {
184177
const { key } = parseSortOption(sortOption.value)
185178
const supported = PROVIDER_SORT_KEYS[provider]
186179
if (!supported.has(key)) {
@@ -200,7 +193,7 @@ const {
200193
packageAvailability,
201194
} = useSearch(
202195
query,
203-
searchProviderValue,
196+
searchProvider,
204197
() => ({
205198
size: requestedSize.value,
206199
}),

0 commit comments

Comments
 (0)