diff --git a/app/components/SearchProviderToggle.client.vue b/app/components/SearchProviderToggle.client.vue
index 4a9033dd24..a3d1589721 100644
--- a/app/components/SearchProviderToggle.client.vue
+++ b/app/components/SearchProviderToggle.client.vue
@@ -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')
@@ -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'
@@ -65,13 +60,13 @@ useEventListener('keydown', event => {
>
{{ $t('settings.data_source.npm') }}
@@ -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'
@@ -97,13 +92,13 @@ useEventListener('keydown', event => {
>
{{ $t('settings.data_source.algolia') }}
@@ -115,7 +110,7 @@ useEventListener('keydown', event => {
,
+ provider: MaybeRefOrGetter,
+): 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: {
diff --git a/app/composables/npm/useOrgPackages.ts b/app/composables/npm/useOrgPackages.ts
index a26ad4fe8c..0a4a77cbc6 100644
--- a/app/composables/npm/useOrgPackages.ts
+++ b/app/composables/npm/useOrgPackages.ts
@@ -1,3 +1,5 @@
+import { bridgeSearchSSRPayload } from './search-utils'
+
/**
* Fetch all packages for an npm organization.
*
@@ -6,17 +8,13 @@
* 3. Falls back to lightweight server-side package-meta lookups
*/
export function useOrgPackages(orgName: MaybeRefOrGetter) {
- 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) {
@@ -53,7 +51,7 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) {
}
// 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) {
diff --git a/app/composables/npm/useSearch.ts b/app/composables/npm/useSearch.ts
index 780800b60f..321785aa71 100644
--- a/app/composables/npm/useSearch.ts
+++ b/app/composables/npm/useSearch.ts
@@ -1,3 +1,5 @@
+import { bridgeSearchSSRPayload } from './search-utils'
+
function emptySearchPayload() {
return {
searchResponse: emptySearchResponse(),
@@ -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 }) => {
@@ -466,8 +470,10 @@ export function useSearch(
})
}
+ const { data: _data, ...rest } = asyncData
+
return {
- ...asyncData,
+ ...rest,
data,
isLoadingMore,
hasMore,
diff --git a/app/composables/npm/useUserPackages.ts b/app/composables/npm/useUserPackages.ts
index 59577cefaf..273cf0d997 100644
--- a/app/composables/npm/useUserPackages.ts
+++ b/app/composables/npm/useUserPackages.ts
@@ -1,3 +1,5 @@
+import { bridgeSearchSSRPayload } from './search-utils'
+
/** Default page size for incremental loading (npm registry path) */
const PAGE_SIZE = 50 as const
@@ -19,13 +21,7 @@ const MAX_RESULTS = 250
* ```
*/
export function useUserPackages(username: MaybeRefOrGetter) {
- 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()
@@ -35,7 +31,9 @@ export function useUserPackages(username: MaybeRefOrGetter) {
/** 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
@@ -46,14 +44,14 @@ export function useUserPackages(username: MaybeRefOrGetter) {
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') {
@@ -61,7 +59,7 @@ export function useUserPackages(username: MaybeRefOrGetter) {
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()
}
@@ -98,7 +96,7 @@ export function useUserPackages(username: MaybeRefOrGetter) {
)
// 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()
}
@@ -197,7 +195,7 @@ export function useUserPackages(username: MaybeRefOrGetter) {
// 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
@@ -231,8 +229,10 @@ export function useUserPackages(username: MaybeRefOrGetter) {
return fetched < available && fetched < MAX_RESULTS
})
+ const { data: _data, ...rest } = asyncData
+
return {
- ...asyncData,
+ ...rest,
/** Reactive package results */
data,
/** Whether currently loading more results */
diff --git a/app/composables/useGlobalSearch.ts b/app/composables/useGlobalSearch.ts
index 45bb478487..a9857b5868 100644
--- a/app/composables/useGlobalSearch.ts
+++ b/app/composables/useGlobalSearch.ts
@@ -1,4 +1,3 @@
-import { normalizeSearchParam } from '#shared/utils/url'
import { debounce } from 'perfect-debounce'
// Pages that have their own local filter using ?q
@@ -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()
@@ -111,7 +105,7 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
return {
model: searchQueryValue,
committedModel: committedSearchQuery,
- provider: searchProviderValue,
+ provider: searchProvider,
startSearch: flushUpdateUrlQuery,
}
}
diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts
index 533c03042b..6b28793f82 100644
--- a/app/composables/useSettings.ts
+++ b/app/composables/useSettings.ts
@@ -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
@@ -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
},
diff --git a/test/e2e/interactions.spec.ts b/test/e2e/interactions.spec.ts
index 4a1a582fb9..0a5d2f46a8 100644
--- a/test/e2e/interactions.spec.ts
+++ b/test/e2e/interactions.spec.ts
@@ -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/)
})
@@ -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,
}) => {
@@ -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()
})
diff --git a/test/fixtures/npm-registry/search/@vue.json b/test/fixtures/npm-registry/search/@vue.json
new file mode 100644
index 0000000000..f509c5488d
--- /dev/null
+++ b/test/fixtures/npm-registry/search/@vue.json
@@ -0,0 +1,276 @@
+{
+ "objects": [
+ {
+ "downloads": {
+ "monthly": 42528330,
+ "weekly": 10046250
+ },
+ "dependents": "353",
+ "updated": "2026-04-04T05:53:21.032Z",
+ "searchScore": 326.28113,
+ "package": {
+ "name": "@vue/reactivity",
+ "keywords": ["vue"],
+ "version": "3.5.32",
+ "description": "@vue/reactivity",
+ "sanitized_name": "@vue/reactivity",
+ "publisher": {
+ "email": "npm-oidc-no-reply@github.com",
+ "actor": {
+ "name": "yyx990803",
+ "type": "user",
+ "email": "user1@example.com"
+ },
+ "trustedPublisher": {
+ "oidcConfigId": "oidc:6c458fb2-e383-47c3-a8d5-9f222de480c7",
+ "id": "github"
+ },
+ "username": "GitHub Actions"
+ },
+ "maintainers": [
+ {
+ "email": "user1@example.com",
+ "username": "yyx990803"
+ }
+ ],
+ "license": "MIT",
+ "date": "2026-04-03T05:41:19.887Z",
+ "links": {
+ "homepage": "https://github.com/vuejs/core/tree/main/packages/reactivity#readme",
+ "repository": "git+https://github.com/vuejs/core.git",
+ "bugs": "https://github.com/vuejs/core/issues",
+ "npm": "https://www.npmjs.com/package/@vue/reactivity"
+ }
+ },
+ "score": {
+ "final": 326.28113,
+ "detail": {
+ "popularity": 1,
+ "quality": 1,
+ "maintenance": 1
+ }
+ },
+ "flags": {
+ "insecure": 0
+ }
+ },
+ {
+ "downloads": {
+ "monthly": 70415933,
+ "weekly": 16592714
+ },
+ "dependents": "1585",
+ "updated": "2026-04-04T05:54:16.908Z",
+ "searchScore": 323.52698,
+ "package": {
+ "name": "@vue/compiler-sfc",
+ "keywords": ["vue"],
+ "version": "3.5.32",
+ "description": "@vue/compiler-sfc",
+ "sanitized_name": "@vue/compiler-sfc",
+ "publisher": {
+ "email": "npm-oidc-no-reply@github.com",
+ "actor": {
+ "name": "yyx990803",
+ "type": "user",
+ "email": "user1@example.com"
+ },
+ "trustedPublisher": {
+ "oidcConfigId": "oidc:64385cf4-8269-4c1a-878c-1ac2242d2518",
+ "id": "github"
+ },
+ "username": "GitHub Actions"
+ },
+ "maintainers": [
+ {
+ "email": "user1@example.com",
+ "username": "yyx990803"
+ }
+ ],
+ "license": "MIT",
+ "date": "2026-04-03T05:41:12.043Z",
+ "links": {
+ "homepage": "https://github.com/vuejs/core/tree/main/packages/compiler-sfc#readme",
+ "repository": "git+https://github.com/vuejs/core.git",
+ "bugs": "https://github.com/vuejs/core/issues",
+ "npm": "https://www.npmjs.com/package/@vue/compiler-sfc"
+ }
+ },
+ "score": {
+ "final": 323.52698,
+ "detail": {
+ "popularity": 1,
+ "quality": 1,
+ "maintenance": 1
+ }
+ },
+ "flags": {
+ "insecure": 0
+ }
+ },
+ {
+ "downloads": {
+ "monthly": 76938866,
+ "weekly": 17911179
+ },
+ "dependents": "181",
+ "updated": "2026-04-04T05:53:29.454Z",
+ "searchScore": 319.66876,
+ "package": {
+ "name": "@vue/compiler-core",
+ "keywords": ["vue"],
+ "version": "3.5.32",
+ "description": "@vue/compiler-core",
+ "sanitized_name": "@vue/compiler-core",
+ "publisher": {
+ "email": "npm-oidc-no-reply@github.com",
+ "actor": {
+ "name": "yyx990803",
+ "type": "user",
+ "email": "user1@example.com"
+ },
+ "trustedPublisher": {
+ "oidcConfigId": "oidc:117f4cf5-a20b-4bd8-a187-4a12264b0950",
+ "id": "github"
+ },
+ "username": "GitHub Actions"
+ },
+ "maintainers": [
+ {
+ "email": "user1@example.com",
+ "username": "yyx990803"
+ }
+ ],
+ "license": "MIT",
+ "date": "2026-04-03T05:41:04.047Z",
+ "links": {
+ "homepage": "https://github.com/vuejs/core/tree/main/packages/compiler-core#readme",
+ "repository": "git+https://github.com/vuejs/core.git",
+ "bugs": "https://github.com/vuejs/core/issues",
+ "npm": "https://www.npmjs.com/package/@vue/compiler-core"
+ }
+ },
+ "score": {
+ "final": 319.66876,
+ "detail": {
+ "popularity": 1,
+ "quality": 1,
+ "maintenance": 1
+ }
+ },
+ "flags": {
+ "insecure": 0
+ }
+ },
+ {
+ "downloads": {
+ "monthly": 64990041,
+ "weekly": 15337637
+ },
+ "dependents": "18",
+ "updated": "2026-04-04T05:54:11.712Z",
+ "searchScore": 316.99716,
+ "package": {
+ "name": "@vue/compiler-ssr",
+ "keywords": ["vue"],
+ "version": "3.5.32",
+ "description": "@vue/compiler-ssr",
+ "sanitized_name": "@vue/compiler-ssr",
+ "publisher": {
+ "email": "npm-oidc-no-reply@github.com",
+ "actor": {
+ "name": "yyx990803",
+ "type": "user",
+ "email": "user1@example.com"
+ },
+ "trustedPublisher": {
+ "oidcConfigId": "oidc:ba968dda-7e8c-462c-bb5c-93dd5925b1fd",
+ "id": "github"
+ },
+ "username": "GitHub Actions"
+ },
+ "maintainers": [
+ {
+ "email": "user1@example.com",
+ "username": "yyx990803"
+ }
+ ],
+ "license": "MIT",
+ "date": "2026-04-03T05:41:16.007Z",
+ "links": {
+ "homepage": "https://github.com/vuejs/core/tree/main/packages/compiler-ssr#readme",
+ "repository": "git+https://github.com/vuejs/core.git",
+ "bugs": "https://github.com/vuejs/core/issues",
+ "npm": "https://www.npmjs.com/package/@vue/compiler-ssr"
+ }
+ },
+ "score": {
+ "final": 316.99716,
+ "detail": {
+ "popularity": 1,
+ "quality": 1,
+ "maintenance": 1
+ }
+ },
+ "flags": {
+ "insecure": 0
+ }
+ },
+ {
+ "downloads": {
+ "monthly": 76436400,
+ "weekly": 17802103
+ },
+ "dependents": "313",
+ "updated": "2026-04-04T05:53:13.980Z",
+ "searchScore": 316.62955,
+ "package": {
+ "name": "@vue/compiler-dom",
+ "keywords": ["vue"],
+ "version": "3.5.32",
+ "description": "@vue/compiler-dom",
+ "sanitized_name": "@vue/compiler-dom",
+ "publisher": {
+ "email": "npm-oidc-no-reply@github.com",
+ "actor": {
+ "name": "yyx990803",
+ "type": "user",
+ "email": "user1@example.com"
+ },
+ "trustedPublisher": {
+ "oidcConfigId": "oidc:0f300997-f0ce-43a2-9497-61c22541e3b0",
+ "id": "github"
+ },
+ "username": "GitHub Actions"
+ },
+ "maintainers": [
+ {
+ "email": "user1@example.com",
+ "username": "yyx990803"
+ }
+ ],
+ "license": "MIT",
+ "date": "2026-04-03T05:41:07.863Z",
+ "links": {
+ "homepage": "https://github.com/vuejs/core/tree/main/packages/compiler-dom#readme",
+ "repository": "git+https://github.com/vuejs/core.git",
+ "bugs": "https://github.com/vuejs/core/issues",
+ "npm": "https://www.npmjs.com/package/@vue/compiler-dom"
+ }
+ },
+ "score": {
+ "final": 316.62955,
+ "detail": {
+ "popularity": 1,
+ "quality": 1,
+ "maintenance": 1
+ }
+ },
+ "flags": {
+ "insecure": 0
+ }
+ }
+ ],
+ "total": 141849,
+ "time": "2026-04-04T09:30:28.192Z"
+}