Skip to content

Commit 0762e7a

Browse files
fix: avoid user package hydration mismatch (#2981)
Co-authored-by: Willow (GHOST) <git@willow.sh>
1 parent 356d93f commit 0762e7a

4 files changed

Lines changed: 46 additions & 23 deletions

File tree

app/composables/npm/useUserPackages.ts

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { NpmSearchResponse } from '#shared/types'
2+
13
/** Default page size for incremental loading (npm registry path) */
24
const PAGE_SIZE = 50 as const
35

@@ -128,8 +130,9 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {
128130
return
129131
}
130132

131-
const currentCount = cache.value?.objects.length ?? 0
132-
const total = Math.min(cache.value?.total ?? Infinity, MAX_RESULTS)
133+
const currentData = getCurrentUserPackages(user)
134+
const currentCount = currentData?.objects.length ?? 0
135+
const total = Math.min(currentData?.total ?? Infinity, MAX_RESULTS)
133136

134137
if (currentCount >= total) return
135138

@@ -153,20 +156,13 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {
153156
// Guard against stale response
154157
if (user !== toValue(username) || activeProvider.value !== 'npm') return
155158

156-
if (cache.value && cache.value.username === user) {
157-
const existingNames = new Set(cache.value.objects.map(obj => obj.package.name))
158-
const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name))
159-
cache.value = {
160-
username: user,
161-
objects: [...cache.value.objects, ...newObjects],
162-
total: response.total,
163-
}
164-
} else {
165-
cache.value = {
166-
username: user,
167-
objects: response.objects,
168-
total: response.total,
169-
}
159+
const existingObjects = getCurrentUserPackages(user)?.objects ?? []
160+
const existingNames = new Set(existingObjects.map(obj => obj.package.name))
161+
const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name))
162+
cache.value = {
163+
username: user,
164+
objects: [...existingObjects, ...newObjects],
165+
total: response.total,
170166
}
171167
} finally {
172168
if (manageLoadingState) isLoadingMore.value = false
@@ -205,6 +201,21 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {
205201
},
206202
)
207203

204+
function getCurrentUserPackages(user: string) {
205+
if (cache.value && cache.value.username === user) {
206+
return cache.value
207+
}
208+
209+
const response = asyncData.data.value
210+
if (!response) return null
211+
212+
return {
213+
username: user,
214+
objects: response.objects,
215+
total: response.total,
216+
}
217+
}
218+
208219
// Computed data that uses cache (only if it belongs to the current username)
209220
const data = computed<NpmSearchResponse | null>(() => {
210221
const user = toValue(username)
@@ -224,10 +235,11 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {
224235
if (!toValue(username)) return false
225236
// Algolia fetches everything in one request; only npm needs pagination
226237
if (activeProvider.value !== 'npm') return false
227-
if (!cache.value) return true
238+
const currentData = getCurrentUserPackages(toValue(username))
239+
if (!currentData) return false
228240
// npm path: more available if we haven't hit the server total or our cap
229-
const fetched = cache.value.objects.length
230-
const available = cache.value.total
241+
const fetched = currentData.objects.length
242+
const available = currentData.total
231243
return fetched < available && fetched < MAX_RESULTS
232244
})
233245

app/composables/useSettings.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { RemovableRef } from '@vueuse/core'
2-
import { useLocalStorage } from '@vueuse/core'
3-
import { ACCENT_COLORS, type AccentColorId } from '#shared/utils/constants'
42
import type { LocaleObject } from '@nuxtjs/i18n'
3+
import { useLocalStorage, useMounted } from '@vueuse/core'
4+
import { ACCENT_COLORS, type AccentColorId } from '#shared/utils/constants'
55
import { BACKGROUND_THEMES } from '#shared/utils/constants'
66

77
type BackgroundThemeId = keyof typeof BACKGROUND_THEMES
@@ -197,9 +197,10 @@ export function useAccentColor() {
197197
*/
198198
export function useSearchProvider() {
199199
const { settings } = useSettings()
200+
const isMounted = useMounted()
200201

201202
const searchProvider = computed({
202-
get: () => settings.value.searchProvider,
203+
get: () => (isMounted.value ? settings.value.searchProvider : DEFAULT_SETTINGS.searchProvider),
203204
set: (value: SearchProvider) => {
204205
settings.value.searchProvider = value
205206
},

app/pages/~[username]/index.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ defineOgImage(
180180

181181
<!-- Loading state (only on initial load, not when we already have data) -->
182182
<LoadingSpinner
183-
v-if="status === 'pending' && packages.length === 0 && !error"
183+
v-if="(status === 'idle' || status === 'pending') && packages.length === 0 && !error"
184184
:text="$t('common.loading_packages')"
185185
/>
186186

test/e2e/hydration.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,16 @@ test.describe('Hydration', () => {
113113
})
114114
}
115115
})
116+
117+
test('user packages page with npm search provider', async ({ page, goto, hydrationErrors }) => {
118+
await injectLocalStorage(page, {
119+
'npmx-settings': JSON.stringify({ searchProvider: 'npm' }),
120+
})
121+
122+
await goto('/~qwerzl', { waitUntil: 'hydration' })
123+
124+
expect(hydrationErrors).toEqual([])
125+
})
116126
})
117127

118128
async function injectLocalStorage(page: Page, entries: Record<string, string>) {

0 commit comments

Comments
 (0)