From 35fc0b42993aa4d2ced42f5652dccfdca2320976 Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Wed, 1 Jul 2026 19:08:54 +0800 Subject: [PATCH 1/9] fix: avoid user package hydration mismatch --- app/composables/npm/useUserPackages.ts | 2 +- app/composables/useSettings.ts | 10 ++++++++-- app/pages/~[username]/index.vue | 2 +- test/e2e/hydration.spec.ts | 17 +++++++++++++++++ 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/app/composables/npm/useUserPackages.ts b/app/composables/npm/useUserPackages.ts index ed9f859382..1227461f76 100644 --- a/app/composables/npm/useUserPackages.ts +++ b/app/composables/npm/useUserPackages.ts @@ -110,7 +110,7 @@ export function useUserPackages(username: MaybeRefOrGetter) { return { ...response, isStale } }, - { default: emptySearchResponse }, + { default: emptySearchResponse, server: false }, ) // --- Fetch more (npm path only) --- /** diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts index c1454355f3..7cfa5d12a0 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -1,7 +1,8 @@ import type { RemovableRef } from '@vueuse/core' +import type { LocaleObject } from '@nuxtjs/i18n' import { useLocalStorage } from '@vueuse/core' +import { onMounted, shallowRef } from 'vue' import { ACCENT_COLORS, type AccentColorId } from '#shared/utils/constants' -import type { LocaleObject } from '@nuxtjs/i18n' import { BACKGROUND_THEMES } from '#shared/utils/constants' type BackgroundThemeId = keyof typeof BACKGROUND_THEMES @@ -197,9 +198,14 @@ export function useAccentColor() { */ export function useSearchProvider() { const { settings } = useSettings() + const isMounted = shallowRef(false) + + onMounted(() => { + isMounted.value = true + }) const searchProvider = computed({ - get: () => settings.value.searchProvider, + get: () => (isMounted.value ? settings.value.searchProvider : DEFAULT_SETTINGS.searchProvider), set: (value: SearchProvider) => { settings.value.searchProvider = value }, diff --git a/app/pages/~[username]/index.vue b/app/pages/~[username]/index.vue index 5c4bb941ae..e56d7efc4e 100644 --- a/app/pages/~[username]/index.vue +++ b/app/pages/~[username]/index.vue @@ -180,7 +180,7 @@ defineOgImage( diff --git a/test/e2e/hydration.spec.ts b/test/e2e/hydration.spec.ts index 0f1c6ae9e4..528fa3aa9c 100644 --- a/test/e2e/hydration.spec.ts +++ b/test/e2e/hydration.spec.ts @@ -10,8 +10,11 @@ const PAGES = [ '/search', '/package/nuxt', '/search?q=vue', + '/~qwerzl', ] as const +const SEARCH_PROVIDER_PAGES = ['/search?q=vue', '/~qwerzl'] as const + // --------------------------------------------------------------------------- // Test matrix // @@ -113,6 +116,20 @@ test.describe('Hydration', () => { }) } }) + + // Default: "algolia" in production -> test persisted "npm" + test.describe('search provider: npm', () => { + for (const page of SEARCH_PROVIDER_PAGES) { + test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => { + await injectLocalStorage(pw, { + 'npmx-settings': JSON.stringify({ searchProvider: 'npm' }), + }) + await goto(page, { waitUntil: 'hydration' }) + + expect(hydrationErrors).toEqual([]) + }) + } + }) }) async function injectLocalStorage(page: Page, entries: Record) { From fcb16e51d888ad742dae1bdf86111c108c5fd330 Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Wed, 1 Jul 2026 21:53:00 +0800 Subject: [PATCH 2/9] refactor: use mounted helper for search provider --- app/composables/useSettings.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts index 7cfa5d12a0..55c9caadd0 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -1,7 +1,6 @@ import type { RemovableRef } from '@vueuse/core' import type { LocaleObject } from '@nuxtjs/i18n' -import { useLocalStorage } from '@vueuse/core' -import { onMounted, shallowRef } from 'vue' +import { useLocalStorage, useMounted } from '@vueuse/core' import { ACCENT_COLORS, type AccentColorId } from '#shared/utils/constants' import { BACKGROUND_THEMES } from '#shared/utils/constants' @@ -198,11 +197,7 @@ export function useAccentColor() { */ export function useSearchProvider() { const { settings } = useSettings() - const isMounted = shallowRef(false) - - onMounted(() => { - isMounted.value = true - }) + const isMounted = useMounted() const searchProvider = computed({ get: () => (isMounted.value ? settings.value.searchProvider : DEFAULT_SETTINGS.searchProvider), From 4287915c46adc0ab52ff76e1fe78faa475554a9a Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Sat, 4 Jul 2026 13:07:52 +0800 Subject: [PATCH 3/9] fix: prerender user packages with algolia --- app/composables/npm/useUserPackages.ts | 83 +++++++++++++++++++++++++- app/pages/~[username]/index.vue | 5 +- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/app/composables/npm/useUserPackages.ts b/app/composables/npm/useUserPackages.ts index 1227461f76..5b597bf30d 100644 --- a/app/composables/npm/useUserPackages.ts +++ b/app/composables/npm/useUserPackages.ts @@ -1,9 +1,36 @@ +import type { NpmSearchResponse } from '#shared/types' + /** Default page size for incremental loading (npm registry path) */ const PAGE_SIZE = 50 as const /** npm search API practical limit for maintainer queries */ const MAX_RESULTS = 250 +type RawNpmSearchResponse = Omit & + Partial> + +type UserPackagesPrefetchWindow = Window & { + __NPMX_USER_PACKAGES_PREFETCH__?: Record | undefined> +} + +function normalizeNpmSearchResponse(response: RawNpmSearchResponse): NpmSearchResponse { + return { + ...response, + isStale: response.isStale ?? false, + } +} + +async function getPrehydratedNpmUserPackages(username: string): Promise { + if (!import.meta.client) return null + + const prefetches = (window as UserPackagesPrefetchWindow).__NPMX_USER_PACKAGES_PREFETCH__ + const response = await prefetches?.[username.toLowerCase()] + if (!response) return null + + delete prefetches?.[username.toLowerCase()] + return normalizeNpmSearchResponse(response) +} + /** * Fetch packages for a given npm user/maintainer. * @@ -30,6 +57,46 @@ export function useUserPackages(username: MaybeRefOrGetter) { const { $npmRegistry } = useNuxtApp() const { searchByMaintainer } = useAlgoliaSearch() + onPrehydrate(el => { + let settings + try { + settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}') + } catch { + settings = {} + } + if (settings.searchProvider !== 'npm') return + + const params = new URLSearchParams(window.location.search) + if (params.get('p') === 'npm') return + + const prehydrateUsername = + el?.getAttribute('data-user-packages-username') || + decodeURIComponent(window.location.pathname.split('/')[1] || '').replace(/^~/, '') + if (!prehydrateUsername) return + + const prefetchWindow = window as typeof window & { + __NPMX_USER_PACKAGES_PREFETCH__?: Record | undefined> + } + const prefetches = (prefetchWindow.__NPMX_USER_PACKAGES_PREFETCH__ ||= {}) + if (prefetches[prehydrateUsername]) return + + const searchParams = new URLSearchParams() + searchParams.set('text', `maintainer:${prehydrateUsername}`) + searchParams.set('size', '50') + + prefetches[prehydrateUsername] = fetch( + `https://registry.npmjs.org/-/v1/search?${searchParams}`, + { + cache: 'force-cache', + }, + ) + .then(response => { + if (!response.ok) return null + return response.json() + }) + .catch(() => null) + }) + // --- Incremental loading state (npm path) --- const currentPage = shallowRef(1) @@ -87,6 +154,20 @@ export function useUserPackages(username: MaybeRefOrGetter) { cache.value = null currentPage.value = 1 + const prehydrated = await getPrehydratedNpmUserPackages(user) + if (prehydrated) { + if (user !== toValue(username) || provider !== searchProviderValue.value) { + return emptySearchResponse() + } + + cache.value = { + username: user, + objects: prehydrated.objects, + total: prehydrated.total, + } + return prehydrated + } + const params = new URLSearchParams() params.set('text', `maintainer:${user}`) params.set('size', String(PAGE_SIZE)) @@ -110,7 +191,7 @@ export function useUserPackages(username: MaybeRefOrGetter) { return { ...response, isStale } }, - { default: emptySearchResponse, server: false }, + { default: emptySearchResponse }, ) // --- Fetch more (npm path only) --- /** diff --git a/app/pages/~[username]/index.vue b/app/pages/~[username]/index.vue index e56d7efc4e..f2b26ef49a 100644 --- a/app/pages/~[username]/index.vue +++ b/app/pages/~[username]/index.vue @@ -139,7 +139,10 @@ defineOgImage(