Skip to content
Open
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
19 changes: 7 additions & 12 deletions app/components/SearchProviderToggle.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,6 @@
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'
})

const isOpen = shallowRef(false)
const toggleRef = useTemplateRef('toggleRef')
Expand Down Expand Up @@ -54,7 +49,7 @@ useEventListener('keydown', event => {
type="button"
role="menuitem"
class="w-full flex items-start gap-3 px-3 py-2.5 rounded-md text-start transition-colors hover:bg-bg-muted"
:class="[searchProviderValue !== 'algolia' ? 'bg-bg-muted' : '']"
:class="[searchProvider !== 'algolia' ? 'bg-bg-muted' : '']"
@click="
() => {
searchProvider = 'npm'
Expand All @@ -65,13 +60,13 @@ useEventListener('keydown', event => {
>
<span
class="i-simple-icons:npm w-4 h-4 mt-0.5 shrink-0"
:class="searchProviderValue !== 'algolia' ? 'text-accent' : 'text-fg-muted'"
:class="searchProvider !== 'algolia' ? 'text-accent' : 'text-fg-muted'"
aria-hidden="true"
/>
<div class="min-w-0 flex-1">
<div
class="text-sm font-medium"
:class="searchProviderValue !== 'algolia' ? 'text-fg' : 'text-fg-muted'"
:class="searchProvider !== 'algolia' ? 'text-fg' : 'text-fg-muted'"
>
{{ $t('settings.data_source.npm') }}
</div>
Expand All @@ -86,7 +81,7 @@ useEventListener('keydown', event => {
type="button"
role="menuitem"
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"
:class="[searchProviderValue === 'algolia' ? 'bg-bg-muted' : '']"
:class="[searchProvider === 'algolia' ? 'bg-bg-muted' : '']"
@click="
() => {
searchProvider = 'algolia'
Expand All @@ -97,13 +92,13 @@ useEventListener('keydown', event => {
>
<span
class="i-simple-icons:algolia w-4 h-4 mt-0.5 shrink-0"
:class="searchProviderValue === 'algolia' ? 'text-accent' : 'text-fg-muted'"
:class="searchProvider === 'algolia' ? 'text-accent' : 'text-fg-muted'"
aria-hidden="true"
/>
<div class="min-w-0 flex-1">
<div
class="text-sm font-medium"
:class="searchProviderValue === 'algolia' ? 'text-fg' : 'text-fg-muted'"
:class="searchProvider === 'algolia' ? 'text-fg' : 'text-fg-muted'"
>
{{ $t('settings.data_source.algolia') }}
</div>
Expand All @@ -115,7 +110,7 @@ useEventListener('keydown', event => {

<!-- Algolia attribution -->
<div
v-if="searchProviderValue === 'algolia'"
v-if="searchProvider === 'algolia'"
class="border-t border-border mx-1 mt-1 pt-2 pb-1"
>
<a
Expand Down
27 changes: 27 additions & 0 deletions app/composables/npm/search-utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,30 @@
/**
* Bridge SSR payload when the resolved search provider on the client differs
* from the server default ('algolia'). Copies the SSR-cached data to the
* client's cache key so `useLazyAsyncData` hydrates without a refetch.
*
* Must be called at composable setup time (not inside an async callback).
*/
export function bridgeSearchSSRPayload(
prefix: string,
identifier: MaybeRefOrGetter<string>,
provider: MaybeRefOrGetter<string>,
): void {
if (import.meta.client) {
const nuxtApp = useNuxtApp()
const id = toValue(identifier)
const p = toValue(provider)

if (nuxtApp.isHydrating && id && p !== 'algolia') {
const ssrKey = `${prefix}:algolia:${id}`
const clientKey = `${prefix}:${p}:${id}`
if (nuxtApp.payload.data[ssrKey] && !nuxtApp.payload.data[clientKey]) {
nuxtApp.payload.data[clientKey] = nuxtApp.payload.data[ssrKey]
}
}
}
}

export function metaToSearchResult(meta: PackageMetaResponse): NpmSearchResult {
return {
package: {
Expand Down
14 changes: 6 additions & 8 deletions app/composables/npm/useOrgPackages.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { bridgeSearchSSRPayload } from './search-utils'

/**
* Fetch all packages for an npm organization.
*
Expand All @@ -6,17 +8,13 @@
* 3. Falls back to lightweight server-side package-meta lookups
*/
export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
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 { getPackagesByName } = useAlgoliaSearch()

bridgeSearchSSRPayload('org-packages', orgName, searchProvider)

const asyncData = useLazyAsyncData(
() => `org-packages:${searchProviderValue.value}:${toValue(orgName)}`,
() => `org-packages:${searchProvider.value}:${toValue(orgName)}`,
async ({ ssrContext }, { signal }) => {
const org = toValue(orgName)
if (!org) {
Expand Down Expand Up @@ -53,7 +51,7 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
}

// Fetch metadata + downloads from Algolia (single request via getObjects)
if (searchProviderValue.value === 'algolia') {
if (searchProvider.value === 'algolia') {
try {
const response = await getPackagesByName(packageNames)
if (response.objects.length > 0) {
Expand Down
8 changes: 7 additions & 1 deletion app/composables/npm/useSearch.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { bridgeSearchSSRPayload } from './search-utils'

function emptySearchPayload() {
return {
searchResponse: emptySearchResponse(),
Expand Down Expand Up @@ -136,6 +138,8 @@ export function useSearch(
suggestionsLoading.value = false
}

bridgeSearchSSRPayload('search', query, searchProvider)

const asyncData = useLazyAsyncData(
() => `search:${toValue(searchProvider)}:${toValue(query)}`,
async (_nuxtApp, { signal }) => {
Expand Down Expand Up @@ -466,8 +470,10 @@ export function useSearch(
})
}

const { data: _data, ...rest } = asyncData

return {
...asyncData,
...rest,
data,
isLoadingMore,
hasMore,
Expand Down
26 changes: 13 additions & 13 deletions app/composables/npm/useUserPackages.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { bridgeSearchSSRPayload } from './search-utils'

/** Default page size for incremental loading (npm registry path) */
const PAGE_SIZE = 50 as const

Expand All @@ -19,13 +21,7 @@ const MAX_RESULTS = 250
* ```
*/
export function useUserPackages(username: MaybeRefOrGetter<string>) {
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'
})
// this is only used in npm path, but we need to extract it when the composable runs
const { $npmRegistry } = useNuxtApp()
const { searchByOwner } = useAlgoliaSearch()
Expand All @@ -35,7 +31,9 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {

/** Tracks which provider actually served the current data (may differ from
* searchProvider when Algolia returns empty and we fall through to npm) */
const activeProvider = shallowRef<'npm' | 'algolia'>(searchProviderValue.value)
const activeProvider = shallowRef<'npm' | 'algolia'>(searchProvider.value)

bridgeSearchSSRPayload('user-packages', username, searchProvider)

const cache = shallowRef<{
username: string
Expand All @@ -46,22 +44,22 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {
const isLoadingMore = shallowRef(false)

const asyncData = useLazyAsyncData(
() => `user-packages:${searchProviderValue.value}:${toValue(username)}`,
() => `user-packages:${searchProvider.value}:${toValue(username)}`,
async (_nuxtApp, { signal }) => {
const user = toValue(username)
if (!user) {
return emptySearchResponse()
}

const provider = searchProviderValue.value
const provider = searchProvider.value

// --- Algolia: fetch all at once ---
if (provider === 'algolia') {
try {
const response = await searchByOwner(user)

// Guard against stale response (user/provider changed during await)
if (user !== toValue(username) || provider !== searchProviderValue.value) {
if (user !== toValue(username) || provider !== searchProvider.value) {
return emptySearchResponse()
}

Expand Down Expand Up @@ -98,7 +96,7 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {
)

// Guard against stale response (user/provider changed during await)
if (user !== toValue(username) || provider !== searchProviderValue.value) {
if (user !== toValue(username) || provider !== searchProvider.value) {
return emptySearchResponse()
}

Expand Down Expand Up @@ -197,7 +195,7 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {
// asyncdata will automatically rerun due to key, but we need to reset cache/page
// when provider changes
watch(
() => searchProviderValue.value,
() => searchProvider.value,
newProvider => {
cache.value = null
currentPage.value = 1
Expand Down Expand Up @@ -231,8 +229,10 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {
return fetched < available && fetched < MAX_RESULTS
})

const { data: _data, ...rest } = asyncData

return {
...asyncData,
...rest,
/** Reactive package results */
data,
/** Whether currently loading more results */
Expand Down
8 changes: 1 addition & 7 deletions app/composables/useGlobalSearch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { normalizeSearchParam } from '#shared/utils/url'
import { debounce } from 'perfect-debounce'

// Pages that have their own local filter using ?q
Expand All @@ -9,11 +8,6 @@ const SEARCH_DEBOUNCE_MS = 100
export function useGlobalSearch(place: 'header' | 'content' = 'content') {
const { settings } = useSettings()
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()
Expand Down Expand Up @@ -111,7 +105,7 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
return {
model: searchQueryValue,
committedModel: committedSearchQuery,
provider: searchProviderValue,
provider: searchProvider,
startSearch: flushUpdateUrlQuery,
}
}
8 changes: 7 additions & 1 deletion app/composables/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useLocalStorage } from '@vueuse/core'
import { ACCENT_COLORS, type AccentColorId } from '#shared/utils/constants'
import type { LocaleObject } from '@nuxtjs/i18n'
import { BACKGROUND_THEMES } from '#shared/utils/constants'
import { normalizeSearchParam } from '#shared/utils/url'

type BackgroundThemeId = keyof typeof BACKGROUND_THEMES

Expand Down Expand Up @@ -181,9 +182,14 @@ export function useAccentColor() {
*/
export function useSearchProvider() {
const { settings } = useSettings()
const route = useRoute()

const searchProvider = computed({
get: () => settings.value.searchProvider,
get: () => {
const p = normalizeSearchParam(route.query.p)
if (p === 'npm' || p === 'algolia') return p
return settings.value.searchProvider
},
set: (value: SearchProvider) => {
settings.value.searchProvider = value
},
Expand Down
41 changes: 32 additions & 9 deletions test/e2e/interactions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,19 @@ test.describe('Search Pages', () => {
const firstResult = page.locator('[data-result-index="0"]').first()
await expect(firstResult).toBeVisible()

// Global keyboard navigation works regardless of focus
// ArrowDown selects the next result
// Wait for the @vue org suggestion card to appear
const orgSuggestion = page.locator('[data-suggestion-index="0"]')
await expect(orgSuggestion).toBeVisible({ timeout: 10000 })

// ArrowDown focuses the org suggestion card
await page.keyboard.press('ArrowDown')

// ArrowUp selects the previous result
// ArrowUp returns to the search input
await page.keyboard.press('ArrowUp')

// Enter navigates to the selected result
// ArrowDown again, then Enter navigates to the suggestion
// URL is /package/vue or /org/vue or /user/vue. Not /vue
await page.keyboard.press('ArrowDown')
await page.keyboard.press('Enter')
await expect(page).toHaveURL(/\/(package|org|user)\/vue/)
})
Expand All @@ -130,16 +134,24 @@ test.describe('Search Pages', () => {
await expect(firstResult).toBeVisible()
await expect(secondResult).toBeVisible()

// ArrowDown from input focuses the first result
// Wait for the @vue org suggestion card to appear
const orgSuggestion = page.locator('[data-suggestion-index="0"]')
await expect(orgSuggestion).toBeVisible({ timeout: 10000 })

// ArrowDown focuses the org suggestion first
await page.keyboard.press('ArrowDown')
await expect(orgSuggestion).toBeFocused()

// Next ArrowDown focuses the first package result
await page.keyboard.press('ArrowDown')
await expect(firstResult).toBeFocused()

// Second ArrowDown focuses the second result (not a keyword button within the first)
// Next ArrowDown focuses the second result (not a keyword button within the first)
await page.keyboard.press('ArrowDown')
await expect(secondResult).toBeFocused()
})

test('/search?q=vue → ArrowUp from first result returns focus to search input', async ({
test('/search?q=vue → ArrowUp from first result navigates back through suggestions to input', async ({
page,
goto,
}) => {
Expand All @@ -149,11 +161,22 @@ test.describe('Search Pages', () => {
timeout: 15000,
})

// Navigate to first result
// Wait for the @vue org suggestion card to appear
const orgSuggestion = page.locator('[data-suggestion-index="0"]')
await expect(orgSuggestion).toBeVisible({ timeout: 10000 })

// Navigate: suggestion → first package result
await page.keyboard.press('ArrowDown')
await expect(orgSuggestion).toBeFocused()

await page.keyboard.press('ArrowDown')
await expect(page.locator('[data-result-index="0"]').first()).toBeFocused()

// ArrowUp returns to the search input
// ArrowUp goes back to the org suggestion
await page.keyboard.press('ArrowUp')
await expect(orgSuggestion).toBeFocused()

// ArrowUp from suggestion returns to the search input
await page.keyboard.press('ArrowUp')
await expect(page.locator('input[type="search"]')).toBeFocused()
})
Expand Down
Loading
Loading