Skip to content
Merged
244 changes: 144 additions & 100 deletions app/composables/npm/useSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import type { AlgoliaMultiSearchChecks } from './useAlgoliaSearch'
import { type SearchSuggestion, emptySearchResponse, parseSuggestionIntent } from './search-utils'
import { isValidNewPackageName, checkPackageExists } from '~/utils/package-name'

function emptySearchPayload() {
return {
searchResponse: emptySearchResponse(),
suggestions: [] as SearchSuggestion[],
packageAvailability: null as { name: string; available: boolean } | null,
}
}

export interface SearchOptions {
size?: number
}
Expand Down Expand Up @@ -44,7 +52,7 @@ export function useSearch(
const suggestionsLoading = shallowRef(false)
const packageAvailability = shallowRef<{ name: string; available: boolean } | null>(null)
const existenceCache = shallowRef<Record<string, boolean>>({})
let suggestionRequestId = 0
const suggestionRequestId = shallowRef(0)

/**
* Determine which extra checks to include in the Algolia multi-search.
Expand Down Expand Up @@ -142,7 +150,7 @@ export function useSearch(

if (!q.trim()) {
isRateLimited.value = false
return emptySearchResponse()
return emptySearchPayload()
}

const opts = toValue(options)
Expand All @@ -156,29 +164,37 @@ export function useSearch(
const result = await algoliaMultiSearch(q, { size: opts.size ?? 25 }, checks)

if (q !== toValue(query)) {
return emptySearchResponse()
return emptySearchPayload()
}

isRateLimited.value = false
processAlgoliaChecks(q, checks, result)
return result.search
return {
searchResponse: result.search,
suggestions: suggestions.value,
packageAvailability: packageAvailability.value,
}
}

const response = await searchAlgolia(q, { size: opts.size ?? 25 })

if (q !== toValue(query)) {
return emptySearchResponse()
return emptySearchPayload()
}

isRateLimited.value = false
return response
return {
searchResponse: response,
suggestions: [],
packageAvailability: null,
}
}

try {
const response = await searchNpm(q, { size: opts.size ?? 25 }, signal)

if (q !== toValue(query)) {
return emptySearchResponse()
return emptySearchPayload()
}

cache.value = {
Expand All @@ -189,20 +205,24 @@ export function useSearch(
}

isRateLimited.value = false
return response
return {
searchResponse: response,
suggestions: [],
packageAvailability: null,
}
} catch (error: unknown) {
const errorMessage = (error as { message?: string })?.message || String(error)
const isRateLimitError =
errorMessage.includes('Failed to fetch') || errorMessage.includes('429')

if (isRateLimitError) {
isRateLimited.value = true
return emptySearchResponse()
return emptySearchPayload()
}
throw error
}
},
{ default: emptySearchResponse },
{ default: emptySearchPayload },
)

async function fetchMore(targetSize: number): Promise<void> {
Expand All @@ -222,12 +242,12 @@ export function useSearch(

// Seed cache from asyncData for Algolia (which skips cache on initial fetch)
if (!cache.value && asyncData.data.value) {
const d = asyncData.data.value
const { searchResponse } = asyncData.data.value
cache.value = {
query: q,
provider,
objects: [...d.objects],
total: d.total,
objects: [...searchResponse.objects],
total: searchResponse.total,
}
}

Expand Down Expand Up @@ -306,121 +326,145 @@ export function useSearch(
time: new Date().toISOString(),
}
}
return asyncData.data.value
return asyncData.data.value?.searchResponse ?? null
})

if (import.meta.client && asyncData.data.value?.isStale) {
onMounted(() => {
asyncData.refresh()
})
}

const hasMore = computed(() => {
if (!cache.value) return true
return cache.value.objects.length < cache.value.total
})

// npm suggestion checking (Algolia handles suggestions inside the search handler above)
if (config.suggestions) {
async function validateSuggestionsNpm(q: string) {
const requestId = ++suggestionRequestId
const { intent, name } = parseSuggestionIntent(q)
async function validateSuggestionsNpm(q: string) {
const requestId = ++suggestionRequestId.value
const { intent, name } = parseSuggestionIntent(q)
let availability: { name: string; available: boolean } | null = null

const trimmed = q.trim()
if (isValidNewPackageName(trimmed)) {
const promises: Promise<void>[] = []

const trimmed = q.trim()
if (isValidNewPackageName(trimmed)) {
promises.push(
checkPackageExists(trimmed)
.then(exists => {
if (trimmed === toValue(query).trim()) {
packageAvailability.value = { name: trimmed, available: !exists }
availability = { name: trimmed, available: !exists }
packageAvailability.value = availability
}
})
.catch(() => {
packageAvailability.value = null
})
} else {
packageAvailability.value = null
}
availability = null
}),
)
} else {
availability = null
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!intent || !name) {
suggestions.value = []
suggestionsLoading.value = false
return
}
if (!intent || !name) {
suggestionsLoading.value = false
return { suggestions: [], packageAvailability: availability }
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

suggestionsLoading.value = true
const result: SearchSuggestion[] = []
const lowerName = name.toLowerCase()
suggestionsLoading.value = true
const result: SearchSuggestion[] = []
const lowerName = name.toLowerCase()

try {
const wantOrg = intent === 'org' || intent === 'both'
const wantUser = intent === 'user' || intent === 'both'

const promises: Promise<void>[] = []

if (wantOrg && existenceCache.value[`org:${lowerName}`] === undefined) {
promises.push(
checkOrgNpm(name)
.then(exists => {
existenceCache.value = { ...existenceCache.value, [`org:${lowerName}`]: exists }
})
.catch(() => {
existenceCache.value = { ...existenceCache.value, [`org:${lowerName}`]: false }
}),
)
}
try {
const wantOrg = intent === 'org' || intent === 'both'
const wantUser = intent === 'user' || intent === 'both'

if (wantUser && existenceCache.value[`user:${lowerName}`] === undefined) {
promises.push(
checkUserNpm(name)
.then(exists => {
existenceCache.value = { ...existenceCache.value, [`user:${lowerName}`]: exists }
})
.catch(() => {
existenceCache.value = { ...existenceCache.value, [`user:${lowerName}`]: false }
}),
)
}
if (wantOrg && existenceCache.value[`org:${lowerName}`] === undefined) {
promises.push(
checkOrgNpm(name)
.then(exists => {
existenceCache.value = { ...existenceCache.value, [`org:${lowerName}`]: exists }
})
.catch(() => {
existenceCache.value = { ...existenceCache.value, [`org:${lowerName}`]: false }
}),
)
}

if (promises.length > 0) {
await Promise.all(promises)
}
if (wantUser && existenceCache.value[`user:${lowerName}`] === undefined) {
promises.push(
checkUserNpm(name)
.then(exists => {
existenceCache.value = { ...existenceCache.value, [`user:${lowerName}`]: exists }
})
.catch(() => {
existenceCache.value = { ...existenceCache.value, [`user:${lowerName}`]: false }
}),
)
}

if (requestId !== suggestionRequestId) return
if (promises.length > 0) {
await Promise.all(promises)
}

const isOrg = wantOrg && existenceCache.value[`org:${lowerName}`]
const isUser = wantUser && existenceCache.value[`user:${lowerName}`]
if (requestId !== suggestionRequestId.value)
return { suggestions: [], packageAvailability: availability }

if (isOrg) {
result.push({ type: 'org', name, exists: true })
}
if (isUser && !isOrg) {
result.push({ type: 'user', name, exists: true })
}
} finally {
if (requestId === suggestionRequestId) {
suggestionsLoading.value = false
}
}
const isOrg = wantOrg && existenceCache.value[`org:${lowerName}`]
const isUser = wantUser && existenceCache.value[`user:${lowerName}`]

if (requestId === suggestionRequestId) {
suggestions.value = result
if (isOrg) {
result.push({ type: 'org', name, exists: true })
}
if (isUser && !isOrg) {
result.push({ type: 'user', name, exists: true })
}
} finally {
if (requestId === suggestionRequestId.value) {
suggestionsLoading.value = false
}
}

watch(
() => toValue(query),
q => {
if (searchProvider.value !== 'algolia') {
validateSuggestionsNpm(q)
}
},
{ immediate: true },
)
if (requestId === suggestionRequestId.value) {
suggestions.value = result
return { suggestions: result, packageAvailability: availability }
}

watch(searchProvider, () => {
if (searchProvider.value !== 'algolia') {
validateSuggestionsNpm(toValue(query))
return { suggestions: [], packageAvailability: availability }
}

const npmSuggestions = useLazyAsyncData(
() => `npm-suggestions:${searchProvider.value}:${toValue(query)}`,
async () => {
const q = toValue(query).trim()
if (searchProvider.value === 'algolia' || !q)
return { suggestions: [], packageAvailability: null }
const { intent, name } = parseSuggestionIntent(q)
if (!intent || !name) return { suggestions: [], packageAvailability: null }
return validateSuggestionsNpm(q)
},
{ default: () => ({ suggestions: [], packageAvailability: null }) },
)

watch(
[() => asyncData.data.value.suggestions, () => npmSuggestions.data.value.suggestions],
([algoliaSuggestions, npmSuggestionsValue]) => {
if (algoliaSuggestions.length || npmSuggestionsValue.length) {
suggestions.value = algoliaSuggestions.length ? algoliaSuggestions : npmSuggestionsValue
}
},
{ immediate: true },
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

watch(
[
() => asyncData.data.value?.packageAvailability,
() => npmSuggestions.data.value.packageAvailability,
],
([algoliaPackageAvailability, npmPackageAvailability]) => {
if (algoliaPackageAvailability || npmPackageAvailability) {
packageAvailability.value = algoliaPackageAvailability || npmPackageAvailability
}
},
{ immediate: true },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)

if (import.meta.client && asyncData.data.value?.searchResponse.isStale) {
onMounted(() => {
asyncData.refresh()
})
}

Expand Down
9 changes: 8 additions & 1 deletion app/composables/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,18 @@ export function useAccentColor() {
* Composable for managing the search provider setting.
*/
export function useSearchProvider() {
const cookie = useCookie('search-provider', {
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 30,
path: '/',
})
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ideally we wouldn't have a cookie here, so that we could cache the search page (by query key).

I think we could show algolia data in initial load, but refresh on client-side with npm data if that's what they prefer.

wdyt?

Copy link
Copy Markdown
Member Author

@alexdln alexdln Feb 11, 2026

Choose a reason for hiding this comment

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

This will be a very slow behavior. It will first download the full HTML, then clean everything up and start loading npm...

It feels like in this case - this option will be an example of a bad experience and will rather harm the service than give the user an option

Copy link
Copy Markdown
Member Author

@alexdln alexdln Feb 11, 2026

Choose a reason for hiding this comment

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

But I had one pretty crazy idea of ​​adding the provider to the query before this approach. Is that suitable for us?

So, if the user has npm selected by default, every time they visit the search page, the provider will be added along with the query. But this won't work if they go directly to the search...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done. This setting is now just a preference. If the user accesses the search via any mechanism within the site, the provider will also be added to the URL. Otherwise, the page will continue to use algolia until the user switches to npm.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

hm. so I was thinking that algolia is enabled by default, so this is the edge case of the user preferring npm and hard reloading the search page rather than using client-side navigation.

the other reason was that algolia is much faster and only requires one http request rather than three, so we are more performant by skipping the npm search.

(but I guess if you have npmx as a search engine in the browser, and prefer npm, you might hit this more often than I originally expected)

we could also prerender the search page and do the fetching entirely on the client side. this would mean no danger of mismatch, very fast initial response, no double fetching....

Copy link
Copy Markdown
Member Author

@alexdln alexdln Feb 11, 2026

Choose a reason for hiding this comment

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

Yes, definitely, rare case, but sometimes the results don't load and the user reloads the page and gets one more bad experience.

I think the current solution is the most optimal in terms of scenario coverage. What do you think about latest changes?

UPD: updated a bit, now it works as I planned and is unnoticeable for the user

Copy link
Copy Markdown
Member Author

@alexdln alexdln Feb 11, 2026

Choose a reason for hiding this comment

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

Another nice bonus of the current loading approach is that we now cache suggestions. I'd like to keep this logic anyway - it improves behavior for both providers.

And I think question now only in the routing behavior with the npm provider. I can remove/change it, but to prevent the server from returning results under algolia, we'll still need to let the server know it's npm

@danielroe check the current behavior please, perhaps it already solves the problems

const { settings } = useSettings()

const searchProvider = computed({
get: () => settings.value.searchProvider,
get: () => (cookie.value === 'npm' ? 'npm' : settings.value.searchProvider),
set: (value: SearchProvider) => {
cookie.value = value
settings.value.searchProvider = value
},
Comment thread
alexdln marked this conversation as resolved.
Outdated
})
Expand Down
Loading