Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 2 additions & 68 deletions app/components/Header/SearchBox.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
<script setup lang="ts">
import { debounce } from 'perfect-debounce'
import { normalizeSearchParam } from '#shared/utils/url'

withDefaults(
defineProps<{
inputClass?: string
Expand All @@ -12,80 +9,17 @@ withDefaults(
)

const emit = defineEmits(['blur', 'focus'])

const router = useRouter()
const route = useRoute()
const { searchProvider } = useSearchProvider()
const searchProviderValue = computed(() => {
const p = normalizeSearchParam(route.query.p)
if (p === 'npm' || searchProvider.value === 'npm') return 'npm'
return 'algolia'
})

const isSearchFocused = shallowRef(false)

const showSearchBar = computed(() => {
return route.name !== 'index'
})

const searchQuery = useGlobalSearchQuery()

// Pages that have their own local filter using ?q
const pagesWithLocalFilter = new Set(['~username', 'org'])

function updateUrlQueryImpl(value: string, provider: 'npm' | 'algolia') {
// Don't navigate away from pages that use ?q for local filtering
if (pagesWithLocalFilter.has(route.name as string)) {
return
}
if (route.name === 'search') {
router.replace({ query: { q: value || undefined, p: provider === 'npm' ? 'npm' : undefined } })
return
}
if (!value) {
return
}

router.push({
name: 'search',
query: {
q: value,
p: provider === 'npm' ? 'npm' : undefined,
},
})
}

const updateUrlQueryNpm = debounce(updateUrlQueryImpl, 250)
const updateUrlQueryAlgolia = debounce(updateUrlQueryImpl, 80)

const updateUrlQuery = Object.assign(
(value: string) =>
(searchProviderValue.value === 'algolia' ? updateUrlQueryAlgolia : updateUrlQueryNpm)(
value,
searchProviderValue.value,
),
{
flush: () =>
(searchProviderValue.value === 'algolia' ? updateUrlQueryAlgolia : updateUrlQueryNpm).flush(),
},
)

watch(searchQuery, value => {
updateUrlQuery(value)
})
const { model: searchQuery, flushUpdateUrlQuery } = useGlobalSearch()

function handleSubmit() {
if (pagesWithLocalFilter.has(route.name as string)) {
router.push({
name: 'search',
query: {
q: searchQuery.value,
p: searchProviderValue.value === 'npm' ? 'npm' : undefined,
},
})
} else {
updateUrlQuery.flush()
}
flushUpdateUrlQuery()
}

// Expose focus method for parent components
Expand Down
5 changes: 4 additions & 1 deletion app/components/Package/Keywords.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
defineProps<{
keywords?: string[]
}>()

const { model } = useGlobalSearch()
</script>
<template>
<CollapsibleSection v-if="keywords?.length" :title="$t('package.keywords_title')" id="keywords">
Expand All @@ -10,7 +12,8 @@ defineProps<{
<LinkBase
variant="button-secondary"
size="small"
:to="{ name: 'search', query: { q: `keywords:${keyword}` } }"
:to="{ name: 'search', query: { q: `keyword:${keyword}` } }"
@click="model = `keyword:${keyword}`"
>
{{ keyword }}
</LinkBase>
Expand Down
75 changes: 75 additions & 0 deletions app/composables/useGlobalSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { normalizeSearchParam } from '#shared/utils/url'
import { debounce } from 'perfect-debounce'

// Pages that have their own local filter using ?q
const pagesWithLocalFilter = new Set(['~username', 'org'])

export function useGlobalSearch() {
const { searchProvider } = useSearchProvider()
const searchProviderValue = computed(() => {
const p = normalizeSearchParam(route.query.p)
if (p === 'npm' || searchProvider.value === 'npm') return 'npm'
return 'algolia'
})
const router = useRouter()
const route = useRoute()
const searchQuery = useState<string>('search-query', () => {
if (pagesWithLocalFilter.has(route.name as string)) {
return ''
}
return normalizeSearchParam(route.query.q)
})

// clean search input when navigating away from search page
watch(
() => route.query.q,
urlQuery => {
const value = normalizeSearchParam(urlQuery)
if (!value) searchQuery.value = ''
},
)
const updateUrlQueryImpl = (value: string, provider: 'npm' | 'algolia') => {
const isSameQuery = route.query.q === value && route.query.p === provider
// Don't navigate away from pages that use ?q for local filtering
if (pagesWithLocalFilter.has(route.name as string) || isSameQuery) {
return
Comment on lines +31 to +35
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve route-level provider overrides when updating the URL.

Right now the setter passes searchProvider.value (settings) into URL updates. If a user arrives with p=npm while their settings default to Algolia, the first keystroke drops p and flips providers. Use the effective provider (route + settings) and normalise current route values for isSameQuery.

💡 Suggested fix
-  const isSameQuery = route.query.q === value && route.query.p === provider
+  const currentQuery = normalizeSearchParam(route.query.q)
+  const currentProvider = normalizeSearchParam(route.query.p) === 'npm' ? 'npm' : 'algolia'
+  const isSameQuery = currentQuery === value && currentProvider === provider
@@
-      if (!updateUrlQuery.isPending()) {
-        updateUrlQueryImpl(value, searchProvider.value)
-      }
-      updateUrlQuery(value, searchProvider.value)
+      const effectiveProvider = searchProviderValue.value
+      if (!updateUrlQuery.isPending()) {
+        updateUrlQueryImpl(value, effectiveProvider)
+      }
+      updateUrlQuery(value, effectiveProvider)

Also applies to: 64-71

}

if (route.name === 'search') {
router.replace({
query: {
...route.query,
q: value || undefined,
p: provider === 'npm' ? 'npm' : undefined,
},
})
return
}
router.push({
name: 'search',
query: {
q: value,
p: provider === 'npm' ? 'npm' : undefined,
},
})
}
const updateUrlQuery = debounce(updateUrlQueryImpl, 250)

function flushUpdateUrlQuery() {
updateUrlQuery.flush()
}
Comment on lines +58 to +60
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Submitting a stable query can no‑op.

flushUpdateUrlQuery() only flushes pending debounced updates. If the user submits an already‑settled query (for example, the prefilled header search), nothing navigates. Consider falling back to an immediate update when no debounce is pending.

💡 Suggested fix
 function flushUpdateUrlQuery() {
-  updateUrlQuery.flush()
+  if (updateUrlQuery.isPending()) {
+    updateUrlQuery.flush()
+    return
+  }
+  updateUrlQueryImpl(searchQuery.value, searchProviderValue.value)
 }


const searchQueryValue = computed({
get: () => searchQuery.value,
set: async (value: string) => {
searchQuery.value = value

// Leading debounce implementation as it doesn't work properly out of the box (https://github.com/unjs/perfect-debounce/issues/43)
if (!updateUrlQuery.isPending()) {
updateUrlQueryImpl(value, searchProvider.value)
}
updateUrlQuery(value, searchProvider.value)
},
})
return { model: searchQueryValue, provider: searchProviderValue, flushUpdateUrlQuery }
}
16 changes: 0 additions & 16 deletions app/composables/useGlobalSearchQuery.ts

This file was deleted.

6 changes: 5 additions & 1 deletion app/composables/useStructuredFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export function hasSearchOperators(parsed: ParsedSearchOperators): boolean {

interface UseStructuredFiltersOptions {
packages: Ref<NpmSearchResult[]>
searchQueryModel?: Ref<string>
initialFilters?: Partial<StructuredFilters>
initialSort?: SortOption
}
Expand Down Expand Up @@ -114,7 +115,7 @@ function matchesSecurity(pkg: NpmSearchResult, security: SecurityFilter): boolea
export function useStructuredFilters(options: UseStructuredFiltersOptions) {
const route = useRoute()
const router = useRouter()
const { packages, initialFilters, initialSort } = options
const { packages, initialFilters, initialSort, searchQueryModel } = options
const { t } = useI18n()

const searchQuery = shallowRef(normalizeSearchParam(route.query.q))
Expand Down Expand Up @@ -404,13 +405,16 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) {
? `${searchQuery.value.trim()} keyword:${keyword}`
: `keyword:${keyword}`
router.replace({ query: { ...route.query, q: newQ } })

if (searchQueryModel) searchQueryModel.value = newQ
}
}

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

function toggleKeyword(keyword: string) {
Expand Down
30 changes: 2 additions & 28 deletions app/pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,36 +1,11 @@
<script setup lang="ts">
import { debounce } from 'perfect-debounce'
import { SHOWCASED_FRAMEWORKS } from '~/utils/frameworks'

const { searchProvider } = useSearchProvider()

const searchQuery = useGlobalSearchQuery()
const { model: searchQuery, flushUpdateUrlQuery } = useGlobalSearch()
const isSearchFocused = shallowRef(false)

async function search() {
const query = searchQuery.value.trim()
if (!query) return
await navigateTo({
path: '/search',
query: query ? { q: query, p: searchProvider.value === 'npm' ? 'npm' : undefined } : undefined,
})
const newQuery = searchQuery.value.trim()
if (newQuery !== query) {
await search()
}
}

const handleInputNpm = debounce(search, 250, { leading: true, trailing: true })
const handleInputAlgolia = debounce(search, 80, { leading: true, trailing: true })

function handleInput() {
if (isTouchDevice()) {
search()
} else if (searchProvider.value === 'algolia') {
handleInputAlgolia()
} else {
handleInputNpm()
}
flushUpdateUrlQuery()
}

useSeoMeta({
Expand Down Expand Up @@ -104,7 +79,6 @@ defineOgImageComponent('Default', {
class="w-full ps-8 pe-24"
@focus="isSearchFocused = true"
@blur="isSearchFocused = false"
@input="handleInput"
/>

<ButtonBase
Expand Down
19 changes: 6 additions & 13 deletions app/pages/search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,6 @@ import { normalizeSearchParam } from '#shared/utils/url'
const route = useRoute()
const router = useRouter()

const { searchProvider } = useSearchProvider()
const searchProviderValue = computed(() => {
const p = normalizeSearchParam(route.query.p)
if (p === 'npm' || searchProvider.value === 'npm') return 'npm'
return 'algolia'
})

// Preferences (persisted to localStorage)
const {
viewMode,
Expand All @@ -33,12 +26,11 @@ const updateUrlPage = debounce((page: number) => {
query: {
...route.query,
page: page > 1 ? page : undefined,
p: searchProviderValue.value === 'npm' ? 'npm' : undefined,
},
})
}, 500)

const searchQuery = useGlobalSearchQuery()
const { model: searchQuery, provider: searchProvider } = useGlobalSearch()
const query = computed(() => searchQuery.value)

// Track if page just loaded (for hiding "Searching..." during view transition)
Expand Down Expand Up @@ -131,7 +123,7 @@ const ALL_SORT_KEYS: SortKey[] = [

// Disable sort keys the current provider can't meaningfully sort by
const disabledSortKeys = computed<SortKey[]>(() => {
const supported = PROVIDER_SORT_KEYS[searchProviderValue.value]
const supported = PROVIDER_SORT_KEYS[searchProvider.value]
return ALL_SORT_KEYS.filter(k => !supported.has(k))
})

Expand All @@ -155,6 +147,7 @@ const {
...parseSearchOperators(normalizeSearchParam(route.query.q)),
},
initialSort: 'relevance-desc', // Default to search relevance
searchQueryModel: searchQuery,
})

const isRelevanceSort = computed(
Expand All @@ -173,14 +166,14 @@ const requestedSize = computed(() => {
// When sorting by something other than relevance, fetch a large batch
// so client-side sorting operates on a meaningful pool of matching results
if (!isRelevanceSort.value) {
const cap = EAGER_LOAD_SIZE[searchProviderValue.value]
const cap = EAGER_LOAD_SIZE[searchProvider.value]
return Math.max(base, cap)
}
return base
})

// Reset to relevance sort when switching to a provider that doesn't support the current sort key
watch(searchProviderValue, provider => {
watch(searchProvider, provider => {
const { key } = parseSortOption(sortOption.value)
const supported = PROVIDER_SORT_KEYS[provider]
if (!supported.has(key)) {
Expand All @@ -200,7 +193,7 @@ const {
packageAvailability,
} = useSearch(
query,
searchProviderValue,
searchProvider,
() => ({
size: requestedSize.value,
}),
Expand Down
Loading