Skip to content

Commit be10893

Browse files
committed
refactor: centralize search provider resolution and SSR payload bridging
- Deduplicate provider resolution into single `resolvedSearchProvider` computed - Extract shared `bridgeSSRPayload()` utility to prevent hydration mismatches - Update e2e tests to cover org suggestion keyboard navigation
1 parent 93853a8 commit be10893

File tree

9 files changed

+367
-58
lines changed

9 files changed

+367
-58
lines changed

app/components/SearchProviderToggle.client.vue

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
<script setup lang="ts">
22
const route = useRoute()
33
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-
})
4+
const { searchProvider, resolvedSearchProvider: searchProviderValue } = useSearchProvider()
105
116
const isOpen = shallowRef(false)
127
const toggleRef = useTemplateRef('toggleRef')

app/composables/npm/search-utils.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,30 @@
1+
/**
2+
* Bridge SSR payload when the resolved search provider on the client differs
3+
* from the server default ('algolia'). Copies the SSR-cached data to the
4+
* client's cache key so `useLazyAsyncData` hydrates without a refetch.
5+
*
6+
* Must be called at composable setup time (not inside an async callback).
7+
*/
8+
export function bridgeSSRPayload(
9+
prefix: string,
10+
identifier: MaybeRefOrGetter<string>,
11+
provider: MaybeRefOrGetter<string>,
12+
): void {
13+
if (import.meta.client) {
14+
const nuxtApp = useNuxtApp()
15+
const id = toValue(identifier)
16+
const p = toValue(provider)
17+
18+
if (nuxtApp.isHydrating && id && p !== 'algolia') {
19+
const ssrKey = `${prefix}:algolia:${id}`
20+
const clientKey = `${prefix}:${p}:${id}`
21+
if (nuxtApp.payload.data[ssrKey] && !nuxtApp.payload.data[clientKey]) {
22+
nuxtApp.payload.data[clientKey] = nuxtApp.payload.data[ssrKey]
23+
}
24+
}
25+
}
26+
}
27+
128
export function metaToSearchResult(meta: PackageMetaResponse): NpmSearchResult {
229
return {
330
package: {

app/composables/npm/useOrgPackages.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { bridgeSSRPayload } from './search-utils'
2+
13
/**
24
* Fetch all packages for an npm organization.
35
*
@@ -6,15 +8,11 @@
68
* 3. Falls back to lightweight server-side package-meta lookups
79
*/
810
export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
9-
const route = useRoute()
10-
const { searchProvider } = useSearchProvider()
11-
const searchProviderValue = computed(() => {
12-
const p = normalizeSearchParam(route.query.p)
13-
if (p === 'npm' || searchProvider.value === 'npm') return 'npm'
14-
return 'algolia'
15-
})
11+
const { resolvedSearchProvider: searchProviderValue } = useSearchProvider()
1612
const { getPackagesByName } = useAlgoliaSearch()
1713

14+
bridgeSSRPayload('org-packages', orgName, searchProviderValue)
15+
1816
const asyncData = useLazyAsyncData(
1917
() => `org-packages:${searchProviderValue.value}:${toValue(orgName)}`,
2018
async ({ ssrContext }, { signal }) => {

app/composables/npm/useSearch.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { SearchProvider } from '#shared/schemas/userPreferences'
2+
import { bridgeSSRPayload } from './search-utils'
23

34
function emptySearchPayload() {
45
return {
@@ -138,22 +139,7 @@ export function useSearch(
138139
suggestionsLoading.value = false
139140
}
140141

141-
// Bridge SSR payload when provider differs between server and client.
142-
// SSR always uses the default provider ('algolia'), but the client may
143-
// read a different provider from localStorage. Copy the SSR data to the
144-
// client's cache key so useLazyAsyncData can hydrate without a mismatch.
145-
if (import.meta.client) {
146-
const nuxtApp = useNuxtApp()
147-
const q = toValue(query)
148-
const provider = toValue(searchProvider)
149-
if (nuxtApp.isHydrating && q && provider !== 'algolia') {
150-
const ssrKey = `search:algolia:${q}`
151-
const clientKey = `search:${provider}:${q}`
152-
if (nuxtApp.payload.data[ssrKey] && !nuxtApp.payload.data[clientKey]) {
153-
nuxtApp.payload.data[clientKey] = nuxtApp.payload.data[ssrKey]
154-
}
155-
}
156-
}
142+
bridgeSSRPayload('search', query, searchProvider)
157143

158144
const asyncData = useLazyAsyncData(
159145
() => `search:${toValue(searchProvider)}:${toValue(query)}`,

app/composables/npm/useUserPackages.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { bridgeSSRPayload } from './search-utils'
2+
13
/** Default page size for incremental loading (npm registry path) */
24
const PAGE_SIZE = 50 as const
35

@@ -19,13 +21,7 @@ const MAX_RESULTS = 250
1921
* ```
2022
*/
2123
export function useUserPackages(username: MaybeRefOrGetter<string>) {
22-
const route = useRoute()
23-
const { searchProvider } = useSearchProvider()
24-
const searchProviderValue = computed(() => {
25-
const p = normalizeSearchParam(route.query.p)
26-
if (p === 'npm' || searchProvider.value === 'npm') return 'npm'
27-
return 'algolia'
28-
})
24+
const { resolvedSearchProvider: searchProviderValue } = useSearchProvider()
2925
// this is only used in npm path, but we need to extract it when the composable runs
3026
const { $npmRegistry } = useNuxtApp()
3127
const { searchByOwner } = useAlgoliaSearch()
@@ -37,6 +33,8 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {
3733
* searchProvider when Algolia returns empty and we fall through to npm) */
3834
const activeProvider = shallowRef<'npm' | 'algolia'>(searchProviderValue.value)
3935

36+
bridgeSSRPayload('user-packages', username, searchProviderValue)
37+
4038
const cache = shallowRef<{
4139
username: string
4240
objects: NpmSearchResult[]

app/composables/useGlobalSearch.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { normalizeSearchParam } from '#shared/utils/url'
21
import { debounce } from 'perfect-debounce'
32

43
// Pages that have their own local filter using ?q
@@ -8,12 +7,7 @@ const SEARCH_DEBOUNCE_MS = 100
87

98
export function useGlobalSearch(place: 'header' | 'content' = 'content') {
109
const instantSearch = useInstantSearchPreference()
11-
const { searchProvider } = useSearchProvider()
12-
const searchProviderValue = computed(() => {
13-
const p = normalizeSearchParam(route.query.p)
14-
if (p === 'npm' || searchProvider.value === 'npm') return 'npm'
15-
return 'algolia'
16-
})
10+
const { searchProvider, resolvedSearchProvider: searchProviderValue } = useSearchProvider()
1711

1812
const router = useRouter()
1913
const route = useRoute()
@@ -36,7 +30,7 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
3630
// This is basically doing instant search as user types
3731
watch(searchQuery, val => {
3832
if (instantSearch.value) {
39-
commitSearchQuery(val)
33+
void commitSearchQuery(val)
4034
}
4135
})
4236

@@ -52,14 +46,15 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
5246

5347
// Updates URL when search query changes (immediately for instantSearch or after Enter hit otherwise)
5448
const updateUrlQueryImpl = (value: string, provider: 'npm' | 'algolia') => {
55-
const isSameQuery = route.query.q === value && route.query.p === provider
49+
const urlProvider = provider === 'npm' ? 'npm' : undefined
50+
const isSameQuery = route.query.q === value && route.query.p === urlProvider
5651
// Don't navigate away from pages that use ?q for local filtering
5752
if ((pagesWithLocalFilter.has(route.name as string) && place === 'content') || isSameQuery) {
5853
return
5954
}
6055

6156
if (route.name === 'search') {
62-
router.replace({
57+
void router.replace({
6358
query: {
6459
...route.query,
6560
q: value || undefined,
@@ -68,7 +63,7 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
6863
})
6964
return
7065
}
71-
router.push({
66+
void router.push({
7267
name: 'search',
7368
query: {
7469
q: value,
@@ -104,7 +99,7 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
10499
if (!updateUrlQuery.isPending()) {
105100
updateUrlQueryImpl(value, searchProvider.value)
106101
}
107-
updateUrlQuery(value, searchProvider.value)
102+
void updateUrlQuery(value, searchProvider.value)
108103
},
109104
})
110105

app/composables/userPreferences/useSearchProvider.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { SearchProvider } from '#shared/schemas/userPreferences'
2+
import { normalizeSearchParam } from '#shared/utils/url'
23

34
export function useSearchProvider() {
45
const { preferences } = useUserPreferencesState()
6+
const route = useRoute()
57

68
const searchProvider = computed({
79
get: () => preferences.value.searchProvider ?? 'algolia',
@@ -10,14 +12,23 @@ export function useSearchProvider() {
1012
},
1113
})
1214

13-
const isAlgolia = computed(() => searchProvider.value === 'algolia')
15+
// Resolved provider: merges URL query param override with persisted preference.
16+
// URL ?p=npm takes priority; otherwise falls back to the saved preference.
17+
const resolvedSearchProvider = computed<SearchProvider>(() => {
18+
const p = normalizeSearchParam(route.query.p)
19+
if (p === 'npm' || searchProvider.value === 'npm') return 'npm'
20+
return 'algolia'
21+
})
22+
23+
const isAlgolia = computed(() => resolvedSearchProvider.value === 'algolia')
1424

1525
function toggle() {
1626
searchProvider.value = searchProvider.value === 'npm' ? 'algolia' : 'npm'
1727
}
1828

1929
return {
2030
searchProvider,
31+
resolvedSearchProvider,
2132
isAlgolia,
2233
toggle,
2334
}

test/e2e/interactions.spec.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -102,15 +102,19 @@ test.describe('Search Pages', () => {
102102
const firstResult = page.locator('[data-result-index="0"]').first()
103103
await expect(firstResult).toBeVisible()
104104

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

109-
// ArrowUp selects the previous result
112+
// ArrowUp returns to the search input
110113
await page.keyboard.press('ArrowUp')
111114

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

133-
// ArrowDown from input focuses the first result
137+
// Wait for the @vue org suggestion card to appear
138+
const orgSuggestion = page.locator('[data-suggestion-index="0"]')
139+
await expect(orgSuggestion).toBeVisible({ timeout: 10000 })
140+
141+
// ArrowDown focuses the org suggestion first
142+
await page.keyboard.press('ArrowDown')
143+
await expect(orgSuggestion).toBeFocused()
144+
145+
// Next ArrowDown focuses the first package result
134146
await page.keyboard.press('ArrowDown')
135147
await expect(firstResult).toBeFocused()
136148

137-
// Second ArrowDown focuses the second result (not a keyword button within the first)
149+
// Next ArrowDown focuses the second result (not a keyword button within the first)
138150
await page.keyboard.press('ArrowDown')
139151
await expect(secondResult).toBeFocused()
140152
})
141153

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

152-
// Navigate to first result
164+
// Wait for the @vue org suggestion card to appear
165+
const orgSuggestion = page.locator('[data-suggestion-index="0"]')
166+
await expect(orgSuggestion).toBeVisible({ timeout: 10000 })
167+
168+
// Navigate: suggestion → first package result
169+
await page.keyboard.press('ArrowDown')
170+
await expect(orgSuggestion).toBeFocused()
171+
153172
await page.keyboard.press('ArrowDown')
154173
await expect(page.locator('[data-result-index="0"]').first()).toBeFocused()
155174

156-
// ArrowUp returns to the search input
175+
// ArrowUp goes back to the org suggestion
176+
await page.keyboard.press('ArrowUp')
177+
await expect(orgSuggestion).toBeFocused()
178+
179+
// ArrowUp from suggestion returns to the search input
157180
await page.keyboard.press('ArrowUp')
158181
await expect(page.locator('input[type="search"]')).toBeFocused()
159182
})

0 commit comments

Comments
 (0)