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

fix: search hydration errors (#1358)

authored by

Alex Savelyev and committed by
GitHub
7294962a 8722e87e

+249 -149
+2 -1
app/components/Compare/PackageSelector.vue
··· 15 15 const isInputFocused = shallowRef(false) 16 16 17 17 // Use the shared search composable (supports both npm and Algolia providers) 18 - const { data: searchData, status } = useSearch(inputValue, { size: 15 }) 18 + const { searchProvider } = useSearchProvider() 19 + const { data: searchData, status } = useSearch(inputValue, searchProvider, { size: 15 }) 19 20 20 21 const isSearching = computed(() => status.value === 'pending') 21 22
+17 -5
app/components/Header/SearchBox.vue
··· 15 15 16 16 const router = useRouter() 17 17 const route = useRoute() 18 - const { isAlgolia } = useSearchProvider() 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 + }) 19 24 20 25 const isSearchFocused = shallowRef(false) 21 26 ··· 29 34 // Pages that have their own local filter using ?q 30 35 const pagesWithLocalFilter = new Set(['~username', 'org']) 31 36 32 - function updateUrlQueryImpl(value: string) { 37 + function updateUrlQueryImpl(value: string, provider: 'npm' | 'algolia') { 33 38 // Don't navigate away from pages that use ?q for local filtering 34 39 if (pagesWithLocalFilter.has(route.name as string)) { 35 40 return 36 41 } 37 42 if (route.name === 'search') { 38 - router.replace({ query: { q: value || undefined } }) 43 + router.replace({ query: { q: value || undefined, p: provider === 'npm' ? 'npm' : undefined } }) 39 44 return 40 45 } 41 46 if (!value) { ··· 46 51 name: 'search', 47 52 query: { 48 53 q: value, 54 + p: provider === 'npm' ? 'npm' : undefined, 49 55 }, 50 56 }) 51 57 } ··· 54 60 const updateUrlQueryAlgolia = debounce(updateUrlQueryImpl, 80) 55 61 56 62 const updateUrlQuery = Object.assign( 57 - (value: string) => (isAlgolia.value ? updateUrlQueryAlgolia : updateUrlQueryNpm)(value), 63 + (value: string) => 64 + (searchProviderValue.value === 'algolia' ? updateUrlQueryAlgolia : updateUrlQueryNpm)( 65 + value, 66 + searchProviderValue.value, 67 + ), 58 68 { 59 - flush: () => (isAlgolia.value ? updateUrlQueryAlgolia : updateUrlQueryNpm).flush(), 69 + flush: () => 70 + (searchProviderValue.value === 'algolia' ? updateUrlQueryAlgolia : updateUrlQueryNpm).flush(), 60 71 }, 61 72 ) 62 73 ··· 85 96 name: 'search', 86 97 query: { 87 98 q: searchQuery.value, 99 + p: searchProviderValue.value === 'npm' ? 'npm' : undefined, 88 100 }, 89 101 }) 90 102 } else {
+26 -8
app/components/SearchProviderToggle.client.vue
··· 1 1 <script setup lang="ts"> 2 - const { searchProvider, isAlgolia } = useSearchProvider() 2 + const route = useRoute() 3 + const router = useRouter() 4 + const { searchProvider } = useSearchProvider() 5 + const searchProviderValue = computed(() => { 6 + const p = normalizeSearchParam(route.query.p) 7 + if (p === 'npm' || searchProvider.value === 'npm') return 'npm' 8 + return 'algolia' 9 + }) 3 10 4 11 const isOpen = shallowRef(false) 5 12 const toggleRef = useTemplateRef('toggleRef') ··· 47 54 type="button" 48 55 role="menuitem" 49 56 class="w-full flex items-start gap-3 px-3 py-2.5 rounded-md text-start transition-colors hover:bg-bg-muted" 50 - :class="[!isAlgolia ? 'bg-bg-muted' : '']" 57 + :class="[searchProviderValue !== 'algolia' ? 'bg-bg-muted' : '']" 51 58 @click=" 52 59 () => { 53 60 searchProvider = 'npm' 61 + router.push({ query: { ...route.query, p: 'npm' } }) 54 62 isOpen = false 55 63 } 56 64 " 57 65 > 58 66 <span 59 67 class="i-carbon:catalog w-4 h-4 mt-0.5 shrink-0" 60 - :class="!isAlgolia ? 'text-accent' : 'text-fg-muted'" 68 + :class="searchProviderValue !== 'algolia' ? 'text-accent' : 'text-fg-muted'" 61 69 aria-hidden="true" 62 70 /> 63 71 <div class="min-w-0 flex-1"> 64 - <div class="text-sm font-medium" :class="!isAlgolia ? 'text-fg' : 'text-fg-muted'"> 72 + <div 73 + class="text-sm font-medium" 74 + :class="searchProviderValue !== 'algolia' ? 'text-fg' : 'text-fg-muted'" 75 + > 65 76 {{ $t('settings.data_source.npm') }} 66 77 </div> 67 78 <p class="text-xs text-fg-subtle mt-0.5"> ··· 75 86 type="button" 76 87 role="menuitem" 77 88 class="w-full flex items-start gap-3 px-3 py-2.5 rounded-md text-start transition-colors hover:bg-bg-muted mt-1" 78 - :class="[isAlgolia ? 'bg-bg-muted' : '']" 89 + :class="[searchProviderValue === 'algolia' ? 'bg-bg-muted' : '']" 79 90 @click=" 80 91 () => { 81 92 searchProvider = 'algolia' 93 + router.push({ query: { ...route.query, p: undefined } }) 82 94 isOpen = false 83 95 } 84 96 " 85 97 > 86 98 <span 87 99 class="i-carbon:search w-4 h-4 mt-0.5 shrink-0" 88 - :class="isAlgolia ? 'text-accent' : 'text-fg-muted'" 100 + :class="searchProviderValue === 'algolia' ? 'text-accent' : 'text-fg-muted'" 89 101 aria-hidden="true" 90 102 /> 91 103 <div class="min-w-0 flex-1"> 92 - <div class="text-sm font-medium" :class="isAlgolia ? 'text-fg' : 'text-fg-muted'"> 104 + <div 105 + class="text-sm font-medium" 106 + :class="searchProviderValue === 'algolia' ? 'text-fg' : 'text-fg-muted'" 107 + > 93 108 {{ $t('settings.data_source.algolia') }} 94 109 </div> 95 110 <p class="text-xs text-fg-subtle mt-0.5"> ··· 99 114 </button> 100 115 101 116 <!-- Algolia attribution --> 102 - <div v-if="isAlgolia" class="border-t border-border mx-1 mt-1 pt-2 pb-1"> 117 + <div 118 + v-if="searchProviderValue === 'algolia'" 119 + class="border-t border-border mx-1 mt-1 pt-2 pb-1" 120 + > 103 121 <a 104 122 href="https://www.algolia.com/developers" 105 123 target="_blank"
+8 -2
app/composables/npm/useOrgPackages.ts
··· 10 10 * 3. Falls back to lightweight server-side package-meta lookups 11 11 */ 12 12 export function useOrgPackages(orgName: MaybeRefOrGetter<string>) { 13 + const route = useRoute() 13 14 const { searchProvider } = useSearchProvider() 15 + const searchProviderValue = computed(() => { 16 + const p = normalizeSearchParam(route.query.p) 17 + if (p === 'npm' || searchProvider.value === 'npm') return 'npm' 18 + return 'algolia' 19 + }) 14 20 const { getPackagesByName } = useAlgoliaSearch() 15 21 16 22 const asyncData = useLazyAsyncData( 17 - () => `org-packages:${searchProvider.value}:${toValue(orgName)}`, 23 + () => `org-packages:${searchProviderValue.value}:${toValue(orgName)}`, 18 24 async ({ ssrContext }, { signal }) => { 19 25 const org = toValue(orgName) 20 26 if (!org) { ··· 51 57 } 52 58 53 59 // Fetch metadata + downloads from Algolia (single request via getObjects) 54 - if (searchProvider.value === 'algolia') { 60 + if (searchProviderValue.value === 'algolia') { 55 61 try { 56 62 const response = await getPackagesByName(packageNames) 57 63 if (response.objects.length > 0) {
+160 -112
app/composables/npm/useSearch.ts
··· 4 4 import { type SearchSuggestion, emptySearchResponse, parseSuggestionIntent } from './search-utils' 5 5 import { isValidNewPackageName, checkPackageExists } from '~/utils/package-name' 6 6 7 + function emptySearchPayload() { 8 + return { 9 + searchResponse: emptySearchResponse(), 10 + suggestions: [] as SearchSuggestion[], 11 + packageAvailability: null as { name: string; available: boolean } | null, 12 + } 13 + } 14 + 7 15 export interface SearchOptions { 8 16 size?: number 9 17 } ··· 19 27 20 28 export function useSearch( 21 29 query: MaybeRefOrGetter<string>, 30 + searchProvider: MaybeRefOrGetter<SearchProvider>, 22 31 options: MaybeRefOrGetter<SearchOptions> = {}, 23 32 config: UseSearchConfig = {}, 24 33 ) { 25 - const { searchProvider } = useSearchProvider() 26 34 const { search: searchAlgolia, searchWithSuggestions: algoliaMultiSearch } = useAlgoliaSearch() 27 35 const { 28 36 search: searchNpm, ··· 44 52 const suggestionsLoading = shallowRef(false) 45 53 const packageAvailability = shallowRef<{ name: string; available: boolean } | null>(null) 46 54 const existenceCache = shallowRef<Record<string, boolean>>({}) 47 - let suggestionRequestId = 0 55 + const suggestionRequestId = shallowRef(0) 48 56 49 57 /** 50 58 * Determine which extra checks to include in the Algolia multi-search. ··· 135 143 } 136 144 137 145 const asyncData = useLazyAsyncData( 138 - () => `search:${searchProvider.value}:${toValue(query)}`, 146 + () => `search:${toValue(searchProvider)}:${toValue(query)}`, 139 147 async (_nuxtApp, { signal }) => { 140 148 const q = toValue(query) 141 - const provider = searchProvider.value 149 + const provider = toValue(searchProvider) 142 150 143 151 if (!q.trim()) { 144 152 isRateLimited.value = false 145 - return emptySearchResponse() 153 + return emptySearchPayload() 146 154 } 147 155 148 156 const opts = toValue(options) ··· 156 164 const result = await algoliaMultiSearch(q, { size: opts.size ?? 25 }, checks) 157 165 158 166 if (q !== toValue(query)) { 159 - return emptySearchResponse() 167 + return emptySearchPayload() 160 168 } 161 169 162 170 isRateLimited.value = false 163 171 processAlgoliaChecks(q, checks, result) 164 - return result.search 172 + return { 173 + searchResponse: result.search, 174 + suggestions: suggestions.value, 175 + packageAvailability: packageAvailability.value, 176 + } 165 177 } 166 178 167 179 const response = await searchAlgolia(q, { size: opts.size ?? 25 }) 168 180 169 181 if (q !== toValue(query)) { 170 - return emptySearchResponse() 182 + return emptySearchPayload() 171 183 } 172 184 173 185 isRateLimited.value = false 174 - return response 186 + return { 187 + searchResponse: response, 188 + suggestions: [], 189 + packageAvailability: null, 190 + } 175 191 } 176 192 177 193 try { 178 194 const response = await searchNpm(q, { size: opts.size ?? 25 }, signal) 179 195 180 196 if (q !== toValue(query)) { 181 - return emptySearchResponse() 197 + return emptySearchPayload() 182 198 } 183 199 184 200 cache.value = { ··· 189 205 } 190 206 191 207 isRateLimited.value = false 192 - return response 208 + return { 209 + searchResponse: response, 210 + suggestions: [], 211 + packageAvailability: null, 212 + } 193 213 } catch (error: unknown) { 194 214 const errorMessage = (error as { message?: string })?.message || String(error) 195 215 const isRateLimitError = ··· 197 217 198 218 if (isRateLimitError) { 199 219 isRateLimited.value = true 200 - return emptySearchResponse() 220 + return emptySearchPayload() 201 221 } 202 222 throw error 203 223 } 204 224 }, 205 - { default: emptySearchResponse }, 225 + { default: emptySearchPayload }, 206 226 ) 207 227 208 228 async function fetchMore(targetSize: number): Promise<void> { 209 229 const q = toValue(query).trim() 210 - const provider = searchProvider.value 230 + const provider = toValue(searchProvider) 211 231 212 232 if (!q) { 213 233 cache.value = null ··· 222 242 223 243 // Seed cache from asyncData for Algolia (which skips cache on initial fetch) 224 244 if (!cache.value && asyncData.data.value) { 225 - const d = asyncData.data.value 245 + const { searchResponse } = asyncData.data.value 226 246 cache.value = { 227 247 query: q, 228 248 provider, 229 - objects: [...d.objects], 230 - total: d.total, 249 + objects: [...searchResponse.objects], 250 + total: searchResponse.total, 231 251 } 232 252 } 233 253 ··· 287 307 }, 288 308 ) 289 309 290 - watch(searchProvider, async () => { 291 - cache.value = null 292 - existenceCache.value = {} 293 - await asyncData.refresh() 294 - const targetSize = toValue(options).size 295 - if (targetSize) { 296 - await fetchMore(targetSize) 297 - } 298 - }) 310 + watch( 311 + () => toValue(searchProvider), 312 + async () => { 313 + cache.value = null 314 + existenceCache.value = {} 315 + await asyncData.refresh() 316 + const targetSize = toValue(options).size 317 + if (targetSize) { 318 + await fetchMore(targetSize) 319 + } 320 + }, 321 + ) 299 322 300 323 const data = computed<NpmSearchResponse | null>(() => { 301 324 if (cache.value) { ··· 306 329 time: new Date().toISOString(), 307 330 } 308 331 } 309 - return asyncData.data.value 332 + return asyncData.data.value?.searchResponse ?? null 310 333 }) 311 334 312 - if (import.meta.client && asyncData.data.value?.isStale) { 313 - onMounted(() => { 314 - asyncData.refresh() 315 - }) 316 - } 317 - 318 335 const hasMore = computed(() => { 319 336 if (!cache.value) return true 320 337 return cache.value.objects.length < cache.value.total 321 338 }) 322 339 323 - // npm suggestion checking (Algolia handles suggestions inside the search handler above) 324 - if (config.suggestions) { 325 - async function validateSuggestionsNpm(q: string) { 326 - const requestId = ++suggestionRequestId 327 - const { intent, name } = parseSuggestionIntent(q) 340 + async function validateSuggestionsNpm(q: string) { 341 + const requestId = ++suggestionRequestId.value 342 + const { intent, name } = parseSuggestionIntent(q) 343 + let availability: { name: string; available: boolean } | null = null 344 + 345 + const promises: Promise<void>[] = [] 328 346 329 - const trimmed = q.trim() 330 - if (isValidNewPackageName(trimmed)) { 347 + const trimmed = q.trim() 348 + if (isValidNewPackageName(trimmed)) { 349 + promises.push( 331 350 checkPackageExists(trimmed) 332 351 .then(exists => { 333 352 if (trimmed === toValue(query).trim()) { 334 - packageAvailability.value = { name: trimmed, available: !exists } 353 + availability = { name: trimmed, available: !exists } 354 + packageAvailability.value = availability 335 355 } 336 356 }) 337 357 .catch(() => { 338 - packageAvailability.value = null 339 - }) 340 - } else { 341 - packageAvailability.value = null 342 - } 343 - 344 - if (!intent || !name) { 345 - suggestions.value = [] 346 - suggestionsLoading.value = false 347 - return 348 - } 358 + availability = null 359 + }), 360 + ) 361 + } else { 362 + availability = null 363 + } 349 364 350 - suggestionsLoading.value = true 351 - const result: SearchSuggestion[] = [] 352 - const lowerName = name.toLowerCase() 365 + if (!intent || !name) { 366 + suggestionsLoading.value = false 367 + await Promise.all(promises) 368 + return { suggestions: [], packageAvailability: availability } 369 + } 353 370 354 - try { 355 - const wantOrg = intent === 'org' || intent === 'both' 356 - const wantUser = intent === 'user' || intent === 'both' 371 + suggestionsLoading.value = true 372 + const result: SearchSuggestion[] = [] 373 + const lowerName = name.toLowerCase() 357 374 358 - const promises: Promise<void>[] = [] 375 + try { 376 + const wantOrg = intent === 'org' || intent === 'both' 377 + const wantUser = intent === 'user' || intent === 'both' 359 378 360 - if (wantOrg && existenceCache.value[`org:${lowerName}`] === undefined) { 361 - promises.push( 362 - checkOrgNpm(name) 363 - .then(exists => { 364 - existenceCache.value = { ...existenceCache.value, [`org:${lowerName}`]: exists } 365 - }) 366 - .catch(() => { 367 - existenceCache.value = { ...existenceCache.value, [`org:${lowerName}`]: false } 368 - }), 369 - ) 370 - } 379 + if (wantOrg && existenceCache.value[`org:${lowerName}`] === undefined) { 380 + promises.push( 381 + checkOrgNpm(name) 382 + .then(exists => { 383 + existenceCache.value = { ...existenceCache.value, [`org:${lowerName}`]: exists } 384 + }) 385 + .catch(() => { 386 + existenceCache.value = { ...existenceCache.value, [`org:${lowerName}`]: false } 387 + }), 388 + ) 389 + } 371 390 372 - if (wantUser && existenceCache.value[`user:${lowerName}`] === undefined) { 373 - promises.push( 374 - checkUserNpm(name) 375 - .then(exists => { 376 - existenceCache.value = { ...existenceCache.value, [`user:${lowerName}`]: exists } 377 - }) 378 - .catch(() => { 379 - existenceCache.value = { ...existenceCache.value, [`user:${lowerName}`]: false } 380 - }), 381 - ) 382 - } 391 + if (wantUser && existenceCache.value[`user:${lowerName}`] === undefined) { 392 + promises.push( 393 + checkUserNpm(name) 394 + .then(exists => { 395 + existenceCache.value = { ...existenceCache.value, [`user:${lowerName}`]: exists } 396 + }) 397 + .catch(() => { 398 + existenceCache.value = { ...existenceCache.value, [`user:${lowerName}`]: false } 399 + }), 400 + ) 401 + } 383 402 384 - if (promises.length > 0) { 385 - await Promise.all(promises) 386 - } 403 + if (promises.length > 0) { 404 + await Promise.all(promises) 405 + } 387 406 388 - if (requestId !== suggestionRequestId) return 407 + if (requestId !== suggestionRequestId.value) 408 + return { suggestions: [], packageAvailability: availability } 389 409 390 - const isOrg = wantOrg && existenceCache.value[`org:${lowerName}`] 391 - const isUser = wantUser && existenceCache.value[`user:${lowerName}`] 410 + const isOrg = wantOrg && existenceCache.value[`org:${lowerName}`] 411 + const isUser = wantUser && existenceCache.value[`user:${lowerName}`] 392 412 393 - if (isOrg) { 394 - result.push({ type: 'org', name, exists: true }) 395 - } 396 - if (isUser && !isOrg) { 397 - result.push({ type: 'user', name, exists: true }) 398 - } 399 - } finally { 400 - if (requestId === suggestionRequestId) { 401 - suggestionsLoading.value = false 402 - } 413 + if (isOrg) { 414 + result.push({ type: 'org', name, exists: true }) 415 + } 416 + if (isUser && !isOrg) { 417 + result.push({ type: 'user', name, exists: true }) 403 418 } 404 - 405 - if (requestId === suggestionRequestId) { 406 - suggestions.value = result 419 + } finally { 420 + if (requestId === suggestionRequestId.value) { 421 + suggestionsLoading.value = false 407 422 } 408 423 } 409 424 410 - watch( 411 - () => toValue(query), 412 - q => { 413 - if (searchProvider.value !== 'algolia') { 414 - validateSuggestionsNpm(q) 415 - } 416 - }, 417 - { immediate: true }, 418 - ) 425 + if (requestId === suggestionRequestId.value) { 426 + suggestions.value = result 427 + return { suggestions: result, packageAvailability: availability } 428 + } 429 + 430 + return { suggestions: [], packageAvailability: availability } 431 + } 419 432 420 - watch(searchProvider, () => { 421 - if (searchProvider.value !== 'algolia') { 422 - validateSuggestionsNpm(toValue(query)) 433 + const npmSuggestions = useLazyAsyncData( 434 + () => `npm-suggestions:${toValue(searchProvider)}:${toValue(query)}`, 435 + async () => { 436 + const q = toValue(query).trim() 437 + if (toValue(searchProvider) === 'algolia' || !q) 438 + return { suggestions: [], packageAvailability: null } 439 + const { intent, name } = parseSuggestionIntent(q) 440 + if (!intent || !name) return { suggestions: [], packageAvailability: null } 441 + return validateSuggestionsNpm(q) 442 + }, 443 + { default: () => ({ suggestions: [], packageAvailability: null }) }, 444 + ) 445 + 446 + watch( 447 + [() => asyncData.data.value.suggestions, () => npmSuggestions.data.value.suggestions], 448 + ([algoliaSuggestions, npmSuggestionsValue]) => { 449 + if (algoliaSuggestions.length || npmSuggestionsValue.length) { 450 + suggestions.value = algoliaSuggestions.length ? algoliaSuggestions : npmSuggestionsValue 423 451 } 452 + }, 453 + { immediate: true }, 454 + ) 455 + 456 + watch( 457 + [ 458 + () => asyncData.data.value?.packageAvailability, 459 + () => npmSuggestions.data.value.packageAvailability, 460 + ], 461 + ([algoliaPackageAvailability, npmPackageAvailability]) => { 462 + if (algoliaPackageAvailability || npmPackageAvailability) { 463 + packageAvailability.value = algoliaPackageAvailability || npmPackageAvailability 464 + } 465 + }, 466 + { immediate: true }, 467 + ) 468 + 469 + if (import.meta.client && asyncData.data.value?.searchResponse.isStale) { 470 + onMounted(() => { 471 + asyncData.refresh() 424 472 }) 425 473 } 426 474
+19 -10
app/composables/npm/useUserPackages.ts
··· 22 22 * ``` 23 23 */ 24 24 export function useUserPackages(username: MaybeRefOrGetter<string>) { 25 + const route = useRoute() 25 26 const { searchProvider } = useSearchProvider() 27 + const searchProviderValue = computed(() => { 28 + const p = normalizeSearchParam(route.query.p) 29 + if (p === 'npm' || searchProvider.value === 'npm') return 'npm' 30 + return 'algolia' 31 + }) 26 32 // this is only used in npm path, but we need to extract it when the composable runs 27 33 const { $npmRegistry } = useNuxtApp() 28 34 const { searchByOwner } = useAlgoliaSearch() ··· 32 38 33 39 /** Tracks which provider actually served the current data (may differ from 34 40 * searchProvider when Algolia returns empty and we fall through to npm) */ 35 - const activeProvider = shallowRef<'npm' | 'algolia'>(searchProvider.value) 41 + const activeProvider = shallowRef<'npm' | 'algolia'>(searchProviderValue.value) 36 42 37 43 const cache = shallowRef<{ 38 44 username: string ··· 43 49 const isLoadingMore = shallowRef(false) 44 50 45 51 const asyncData = useLazyAsyncData( 46 - () => `user-packages:${searchProvider.value}:${toValue(username)}`, 52 + () => `user-packages:${searchProviderValue.value}:${toValue(username)}`, 47 53 async ({ $npmRegistry }, { signal }) => { 48 54 const user = toValue(username) 49 55 if (!user) { 50 56 return emptySearchResponse() 51 57 } 52 58 53 - const provider = searchProvider.value 59 + const provider = searchProviderValue.value 54 60 55 61 // --- Algolia: fetch all at once --- 56 62 if (provider === 'algolia') { ··· 58 64 const response = await searchByOwner(user) 59 65 60 66 // Guard against stale response (user/provider changed during await) 61 - if (user !== toValue(username) || provider !== searchProvider.value) { 67 + if (user !== toValue(username) || provider !== searchProviderValue.value) { 62 68 return emptySearchResponse() 63 69 } 64 70 ··· 95 101 ) 96 102 97 103 // Guard against stale response (user/provider changed during await) 98 - if (user !== toValue(username) || provider !== searchProvider.value) { 104 + if (user !== toValue(username) || provider !== searchProviderValue.value) { 99 105 return emptySearchResponse() 100 106 } 101 107 ··· 193 199 194 200 // asyncdata will automatically rerun due to key, but we need to reset cache/page 195 201 // when provider changes 196 - watch(searchProvider, newProvider => { 197 - cache.value = null 198 - currentPage.value = 1 199 - activeProvider.value = newProvider 200 - }) 202 + watch( 203 + () => searchProviderValue.value, 204 + newProvider => { 205 + cache.value = null 206 + currentPage.value = 1 207 + activeProvider.value = newProvider 208 + }, 209 + ) 201 210 202 211 // Computed data that uses cache (only if it belongs to the current username) 203 212 const data = computed<NpmSearchResponse | null>(() => {
+3 -3
app/pages/index.vue
··· 2 2 import { debounce } from 'perfect-debounce' 3 3 import { SHOWCASED_FRAMEWORKS } from '~/utils/frameworks' 4 4 5 - const { isAlgolia } = useSearchProvider() 5 + const { searchProvider } = useSearchProvider() 6 6 7 7 const searchQuery = shallowRef('') 8 8 const isSearchFocused = shallowRef(false) ··· 12 12 if (!query) return 13 13 await navigateTo({ 14 14 path: '/search', 15 - query: query ? { q: query } : undefined, 15 + query: query ? { q: query, p: searchProvider.value === 'npm' ? 'npm' : undefined } : undefined, 16 16 }) 17 17 const newQuery = searchQuery.value.trim() 18 18 if (newQuery !== query) { ··· 26 26 function handleInput() { 27 27 if (isTouchDevice()) { 28 28 search() 29 - } else if (isAlgolia.value) { 29 + } else if (searchProvider.value === 'algolia') { 30 30 handleInputAlgolia() 31 31 } else { 32 32 handleInputNpm()
+12 -6
app/pages/search.vue
··· 10 10 const route = useRoute() 11 11 const router = useRouter() 12 12 13 - // Search provider 14 - const { isAlgolia } = useSearchProvider() 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 + }) 15 19 16 20 // Preferences (persisted to localStorage) 17 21 const { ··· 29 33 query: { 30 34 ...route.query, 31 35 page: page > 1 ? page : undefined, 36 + p: searchProviderValue.value === 'npm' ? 'npm' : undefined, 32 37 }, 33 38 }) 34 39 }, 500) ··· 126 131 127 132 // Disable sort keys the current provider can't meaningfully sort by 128 133 const disabledSortKeys = computed<SortKey[]>(() => { 129 - const supported = PROVIDER_SORT_KEYS[isAlgolia.value ? 'algolia' : 'npm'] 134 + const supported = PROVIDER_SORT_KEYS[searchProviderValue.value] 130 135 return ALL_SORT_KEYS.filter(k => !supported.has(k)) 131 136 }) 132 137 ··· 168 173 // When sorting by something other than relevance, fetch a large batch 169 174 // so client-side sorting operates on a meaningful pool of matching results 170 175 if (!isRelevanceSort.value) { 171 - const cap = isAlgolia.value ? EAGER_LOAD_SIZE.algolia : EAGER_LOAD_SIZE.npm 176 + const cap = EAGER_LOAD_SIZE[searchProviderValue.value] 172 177 return Math.max(base, cap) 173 178 } 174 179 return base 175 180 }) 176 181 177 182 // Reset to relevance sort when switching to a provider that doesn't support the current sort key 178 - watch(isAlgolia, algolia => { 183 + watch(searchProviderValue, provider => { 179 184 const { key } = parseSortOption(sortOption.value) 180 - const supported = PROVIDER_SORT_KEYS[algolia ? 'algolia' : 'npm'] 185 + const supported = PROVIDER_SORT_KEYS[provider] 181 186 if (!supported.has(key)) { 182 187 sortOption.value = 'relevance-desc' 183 188 } ··· 195 200 packageAvailability, 196 201 } = useSearch( 197 202 query, 203 + searchProviderValue, 198 204 () => ({ 199 205 size: requestedSize.value, 200 206 }),
+2 -2
test/e2e/interactions.spec.ts
··· 68 68 await page.keyboard.press('ArrowUp') 69 69 70 70 // Enter navigates to the selected result 71 - // URL is /package/vue not /vue 71 + // URL is /package/vue or /org/vue or /user/vue. Not /vue 72 72 await page.keyboard.press('Enter') 73 - await expect(page).toHaveURL(/\/package\/vue/) 73 + await expect(page).toHaveURL(/\/(package|org|user)\/vue/) 74 74 }) 75 75 76 76 test('/search?q=vue → "/" focuses the search input from results', async ({ page, goto }) => {