Skip to content

Commit 6e4345c

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 298ce5c commit 6e4345c

File tree

12 files changed

+400
-76
lines changed

12 files changed

+400
-76
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: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { SearchProvider } from '#shared/schemas/userPreferences'
2+
import { bridgeSearchSSRPayload } 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+
bridgeSearchSSRPayload('search', query, searchProvider)
157143

158144
const asyncData = useLazyAsyncData(
159145
() => `search:${toValue(searchProvider)}:${toValue(query)}`,
@@ -481,12 +467,14 @@ export function useSearch(
481467

482468
if (import.meta.client && asyncData.data.value?.searchResponse.isStale) {
483469
onMounted(() => {
484-
asyncData.refresh()
470+
void asyncData.refresh()
485471
})
486472
}
487473

474+
const { data: _data, ...rest } = asyncData
475+
488476
return {
489-
...asyncData,
477+
...rest,
490478
data,
491479
isLoadingMore,
492480
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 */
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export function useCodeContainer() {
2+
const { localSettings } = useUserLocalSettings()
3+
4+
const codeContainerFull = computed(() => localSettings.value.codeContainerFull)
5+
6+
function toggleCodeContainer() {
7+
localSettings.value.codeContainerFull = !localSettings.value.codeContainerFull
8+
}
9+
10+
return {
11+
codeContainerFull,
12+
toggleCodeContainer,
13+
}
14+
}

app/composables/useGlobalSearch.ts

Lines changed: 8 additions & 13 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 instantSearch = useInstantSearchPreference()
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()
@@ -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,
@@ -87,7 +82,7 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
8782
if (!instantSearch.value) {
8883
updateUrlQueryImpl(searchQuery.value, searchProvider.value)
8984
} else {
90-
updateUrlQuery.flush()
85+
void updateUrlQuery.flush()
9186
}
9287
}
9388

@@ -104,14 +99,14 @@ 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

111106
return {
112107
model: searchQueryValue,
113108
committedModel: committedSearchQuery,
114-
provider: searchProviderValue,
109+
provider: searchProvider,
115110
startSearch: flushUpdateUrlQuery,
116111
}
117112
}

app/composables/useUserLocalSettings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface UserLocalSettings {
1212
anomaliesFixed: boolean
1313
predictionPoints: number
1414
}
15+
codeContainerFull: boolean
1516
}
1617

1718
const STORAGE_KEY = 'npmx-settings'
@@ -29,6 +30,7 @@ const DEFAULT_USER_LOCAL_SETTINGS: UserLocalSettings = {
2930
anomaliesFixed: true,
3031
predictionPoints: 4,
3132
},
33+
codeContainerFull: false,
3234
}
3335

3436
let localSettingsRef: Ref<UserLocalSettings> | null = null

app/composables/userPreferences/useSearchProvider.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
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({
7-
get: () => preferences.value.searchProvider ?? 'algolia',
9+
get: () => {
10+
const p = normalizeSearchParam(route.query.p)
11+
if (p === 'npm' || p === 'algolia') return p
12+
return preferences.value.searchProvider ?? 'algolia'
13+
},
814
set: (value: SearchProvider) => {
915
preferences.value.searchProvider = value
1016
},

0 commit comments

Comments
 (0)