Skip to content

Commit 7a77f97

Browse files
committed
fix: bridge SSR payload mismatch between search providers
- Centralize `?p` query param merge into `useSearchProvider()` - Remove duplicated `searchProviderValue` computed from all consumers - Add `bridgeSearchSSRPayload()` to copy SSR cache to client's provider key during hydration - Destructure asyncData before spreading to avoid shadowing custom `data` ref - Update e2e tests for org suggestion cards in keyboard navigation
1 parent 5324b96 commit 7a77f97

File tree

9 files changed

+376
-51
lines changed

9 files changed

+376
-51
lines changed

app/components/SearchProviderToggle.client.vue

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,6 @@
22
const route = useRoute()
33
const router = useRouter()
44
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-
})
105
116
const isOpen = shallowRef(false)
127
const toggleRef = useTemplateRef('toggleRef')
@@ -54,7 +49,7 @@ useEventListener('keydown', event => {
5449
type="button"
5550
role="menuitem"
5651
class="w-full flex items-start gap-3 px-3 py-2.5 rounded-md text-start transition-colors hover:bg-bg-muted"
57-
:class="[searchProviderValue !== 'algolia' ? 'bg-bg-muted' : '']"
52+
:class="[searchProvider !== 'algolia' ? 'bg-bg-muted' : '']"
5853
@click="
5954
() => {
6055
searchProvider = 'npm'
@@ -65,13 +60,13 @@ useEventListener('keydown', event => {
6560
>
6661
<span
6762
class="i-simple-icons:npm w-4 h-4 mt-0.5 shrink-0"
68-
:class="searchProviderValue !== 'algolia' ? 'text-accent' : 'text-fg-muted'"
63+
:class="searchProvider !== 'algolia' ? 'text-accent' : 'text-fg-muted'"
6964
aria-hidden="true"
7065
/>
7166
<div class="min-w-0 flex-1">
7267
<div
7368
class="text-sm font-medium"
74-
:class="searchProviderValue !== 'algolia' ? 'text-fg' : 'text-fg-muted'"
69+
:class="searchProvider !== 'algolia' ? 'text-fg' : 'text-fg-muted'"
7570
>
7671
{{ $t('settings.data_source.npm') }}
7772
</div>
@@ -86,7 +81,7 @@ useEventListener('keydown', event => {
8681
type="button"
8782
role="menuitem"
8883
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"
89-
:class="[searchProviderValue === 'algolia' ? 'bg-bg-muted' : '']"
84+
:class="[searchProvider === 'algolia' ? 'bg-bg-muted' : '']"
9085
@click="
9186
() => {
9287
searchProvider = 'algolia'
@@ -97,13 +92,13 @@ useEventListener('keydown', event => {
9792
>
9893
<span
9994
class="i-simple-icons:algolia w-4 h-4 mt-0.5 shrink-0"
100-
:class="searchProviderValue === 'algolia' ? 'text-accent' : 'text-fg-muted'"
95+
:class="searchProvider === 'algolia' ? 'text-accent' : 'text-fg-muted'"
10196
aria-hidden="true"
10297
/>
10398
<div class="min-w-0 flex-1">
10499
<div
105100
class="text-sm font-medium"
106-
:class="searchProviderValue === 'algolia' ? 'text-fg' : 'text-fg-muted'"
101+
:class="searchProvider === 'algolia' ? 'text-fg' : 'text-fg-muted'"
107102
>
108103
{{ $t('settings.data_source.algolia') }}
109104
</div>
@@ -115,7 +110,7 @@ useEventListener('keydown', event => {
115110

116111
<!-- Algolia attribution -->
117112
<div
118-
v-if="searchProviderValue === 'algolia'"
113+
v-if="searchProvider === 'algolia'"
119114
class="border-t border-border mx-1 mt-1 pt-2 pb-1"
120115
>
121116
<a

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 bridgeSearchSSRPayload(
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: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { bridgeSearchSSRPayload } from './search-utils'
2+
13
/**
24
* Fetch all packages for an npm organization.
35
*
@@ -6,17 +8,13 @@
68
* 3. Falls back to lightweight server-side package-meta lookups
79
*/
810
export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
9-
const route = useRoute()
1011
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-
})
1612
const { getPackagesByName } = useAlgoliaSearch()
1713

14+
bridgeSearchSSRPayload('org-packages', orgName, searchProvider)
15+
1816
const asyncData = useLazyAsyncData(
19-
() => `org-packages:${searchProviderValue.value}:${toValue(orgName)}`,
17+
() => `org-packages:${searchProvider.value}:${toValue(orgName)}`,
2018
async ({ ssrContext }, { signal }) => {
2119
const org = toValue(orgName)
2220
if (!org) {
@@ -53,7 +51,7 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
5351
}
5452

5553
// Fetch metadata + downloads from Algolia (single request via getObjects)
56-
if (searchProviderValue.value === 'algolia') {
54+
if (searchProvider.value === 'algolia') {
5755
try {
5856
const response = await getPackagesByName(packageNames)
5957
if (response.objects.length > 0) {

app/composables/npm/useSearch.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { bridgeSearchSSRPayload } from './search-utils'
2+
13
function emptySearchPayload() {
24
return {
35
searchResponse: emptySearchResponse(),
@@ -136,6 +138,8 @@ export function useSearch(
136138
suggestionsLoading.value = false
137139
}
138140

141+
bridgeSearchSSRPayload('search', query, searchProvider)
142+
139143
const asyncData = useLazyAsyncData(
140144
() => `search:${toValue(searchProvider)}:${toValue(query)}`,
141145
async (_nuxtApp, { signal }) => {
@@ -466,8 +470,10 @@ export function useSearch(
466470
})
467471
}
468472

473+
const { data: _data, ...rest } = asyncData
474+
469475
return {
470-
...asyncData,
476+
...rest,
471477
data,
472478
isLoadingMore,
473479
hasMore,

app/composables/npm/useUserPackages.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { bridgeSearchSSRPayload } 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()
2324
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-
})
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()
@@ -35,7 +31,9 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {
3531

3632
/** Tracks which provider actually served the current data (may differ from
3733
* searchProvider when Algolia returns empty and we fall through to npm) */
38-
const activeProvider = shallowRef<'npm' | 'algolia'>(searchProviderValue.value)
34+
const activeProvider = shallowRef<'npm' | 'algolia'>(searchProvider.value)
35+
36+
bridgeSearchSSRPayload('user-packages', username, searchProvider)
3937

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

4846
const asyncData = useLazyAsyncData(
49-
() => `user-packages:${searchProviderValue.value}:${toValue(username)}`,
47+
() => `user-packages:${searchProvider.value}:${toValue(username)}`,
5048
async (_nuxtApp, { signal }) => {
5149
const user = toValue(username)
5250
if (!user) {
5351
return emptySearchResponse()
5452
}
5553

56-
const provider = searchProviderValue.value
54+
const provider = searchProvider.value
5755

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

6361
// Guard against stale response (user/provider changed during await)
64-
if (user !== toValue(username) || provider !== searchProviderValue.value) {
62+
if (user !== toValue(username) || provider !== searchProvider.value) {
6563
return emptySearchResponse()
6664
}
6765

@@ -98,7 +96,7 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {
9896
)
9997

10098
// Guard against stale response (user/provider changed during await)
101-
if (user !== toValue(username) || provider !== searchProviderValue.value) {
99+
if (user !== toValue(username) || provider !== searchProvider.value) {
102100
return emptySearchResponse()
103101
}
104102

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

232+
const { data: _data, ...rest } = asyncData
233+
234234
return {
235-
...asyncData,
235+
...rest,
236236
/** Reactive package results */
237237
data,
238238
/** Whether currently loading more results */

app/composables/useGlobalSearch.ts

Lines changed: 1 addition & 7 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
@@ -9,11 +8,6 @@ const SEARCH_DEBOUNCE_MS = 100
98
export function useGlobalSearch(place: 'header' | 'content' = 'content') {
109
const { settings } = useSettings()
1110
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-
})
1711

1812
const router = useRouter()
1913
const route = useRoute()
@@ -111,7 +105,7 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
111105
return {
112106
model: searchQueryValue,
113107
committedModel: committedSearchQuery,
114-
provider: searchProviderValue,
108+
provider: searchProvider,
115109
startSearch: flushUpdateUrlQuery,
116110
}
117111
}

app/composables/useSettings.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useLocalStorage } from '@vueuse/core'
33
import { ACCENT_COLORS, type AccentColorId } from '#shared/utils/constants'
44
import type { LocaleObject } from '@nuxtjs/i18n'
55
import { BACKGROUND_THEMES } from '#shared/utils/constants'
6+
import { normalizeSearchParam } from '#shared/utils/url'
67

78
type BackgroundThemeId = keyof typeof BACKGROUND_THEMES
89

@@ -181,9 +182,14 @@ export function useAccentColor() {
181182
*/
182183
export function useSearchProvider() {
183184
const { settings } = useSettings()
185+
const route = useRoute()
184186

185187
const searchProvider = computed({
186-
get: () => settings.value.searchProvider,
188+
get: () => {
189+
const p = normalizeSearchParam(route.query.p)
190+
if (p === 'npm' || p === 'algolia') return p
191+
return settings.value.searchProvider
192+
},
187193
set: (value: SearchProvider) => {
188194
settings.value.searchProvider = value
189195
},

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)