Skip to content

Commit e1466f8

Browse files
committed
fix: use SSR-safe provider in async-data keys to avoid hydration mismatch on org/user/search
Revert the useSettings/prehydrate refactor and instead fix the actual cause of the empty-state on /org/[name] when the user has 'npm registry' selected: the async-data cache key embedded the localStorage-backed search provider, so SSR keyed by 'algolia' while client hydration keyed by 'npm', missing the SSR payload and dropping to the empty default. Introduces useEffectiveSearchProvider which defers the stored preference until app:mounted, keeping SSR and initial hydration on the same key. After mount the reactive key flips and useLazyAsyncData refetches once with the user's preference. URL ?p= still wins everywhere and remains cacheable per-URL. Closes #2753
1 parent f751a9a commit e1466f8

5 files changed

Lines changed: 40 additions & 97 deletions

File tree

app/composables/npm/useOrgPackages.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,7 @@ import { mapWithConcurrency } from '#shared/utils/async'
1010
* 3. Falls back to lightweight server-side package-meta lookups
1111
*/
1212
export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
13-
const route = useRoute()
14-
const { searchProvider } = useSearchProvider()
15-
const searchProviderValue = computed(() => {
16-
const p = normalizeSearchParam(route.query.p)
17-
if (p === 'npm' || searchProvider.value === 'npm') return 'npm'
18-
return 'algolia'
19-
})
13+
const searchProviderValue = useEffectiveSearchProvider()
2014
const { getPackagesByName } = useAlgoliaSearch()
2115

2216
const asyncData = useLazyAsyncData(

app/composables/npm/useUserPackages.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,7 @@ const MAX_RESULTS = 250
1919
* ```
2020
*/
2121
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-
})
22+
const searchProviderValue = useEffectiveSearchProvider()
2923
// this is only used in npm path, but we need to extract it when the composable runs
3024
const { $npmRegistry } = useNuxtApp()
3125
const { searchByMaintainer } = useAlgoliaSearch()

app/composables/useGlobalSearch.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,7 @@ const SEARCH_DEBOUNCE_MS = 100
99
export function useGlobalSearch(place: 'header' | 'content' = 'content') {
1010
const { settings } = useSettings()
1111
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-
})
12+
const searchProviderValue = useEffectiveSearchProvider()
1713

1814
const router = useRouter()
1915
const route = useRoute()

app/composables/useSettings.ts

Lines changed: 37 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import type { RemovableRef } from '@vueuse/core'
2+
import { useLocalStorage } from '@vueuse/core'
13
import { ACCENT_COLORS, type AccentColorId } from '#shared/utils/constants'
24
import type { LocaleObject } from '@nuxtjs/i18n'
35
import { BACKGROUND_THEMES } from '#shared/utils/constants'
6+
import { normalizeSearchParam } from '#shared/utils/url'
47

58
type BackgroundThemeId = keyof typeof BACKGROUND_THEMES
69

@@ -92,86 +95,22 @@ const DEFAULT_SETTINGS: AppSettings = {
9295

9396
const STORAGE_KEY = 'npmx-settings'
9497

95-
/**
96-
* Read settings from localStorage and merge with defaults.
97-
*/
98-
function normaliseSettings(input: AppSettings): AppSettings {
99-
return {
100-
...input,
101-
searchProvider: input.searchProvider === 'npm' ? 'npm' : 'algolia',
102-
sidebar: {
103-
...input.sidebar,
104-
collapsed: Array.isArray(input.sidebar?.collapsed)
105-
? input.sidebar.collapsed.filter((v): v is string => typeof v === 'string')
106-
: [],
107-
},
108-
}
109-
}
110-
111-
function readFromLocalStorage(): AppSettings {
112-
try {
113-
const raw = localStorage.getItem(STORAGE_KEY)
114-
if (raw) {
115-
const stored = JSON.parse(raw)
116-
return normaliseSettings({
117-
...DEFAULT_SETTINGS,
118-
...stored,
119-
connector: { ...DEFAULT_SETTINGS.connector, ...stored.connector },
120-
sidebar: { ...DEFAULT_SETTINGS.sidebar, ...stored.sidebar },
121-
chartFilter: { ...DEFAULT_SETTINGS.chartFilter, ...stored.chartFilter },
122-
})
123-
}
124-
} catch {}
125-
return { ...DEFAULT_SETTINGS }
126-
}
127-
128-
let syncInitialized = false
98+
// Shared settings instance (singleton per app)
99+
let settingsRef: RemovableRef<AppSettings> | null = null
129100

130101
/**
131-
* Composable for managing application settings.
132-
*
133-
* Uses useState for SSR-safe hydration (server and client agree on initial
134-
* values during hydration) and syncs with localStorage on the client.
135-
* The onPrehydrate script in prehydrate.ts handles DOM-level patches
136-
* (accent color, bg theme, collapsed sections, etc.) to prevent visual
137-
* flash before hydration.
102+
* Composable for managing application settings with localStorage persistence.
103+
* Settings are shared across all components that use this composable.
138104
*/
139105
export function useSettings() {
140-
const settings = useState<AppSettings>(STORAGE_KEY, () => ({ ...DEFAULT_SETTINGS }))
141-
142-
if (import.meta.client && !syncInitialized) {
143-
syncInitialized = true
144-
145-
// Read localStorage eagerly but apply after mount to prevent hydration
146-
// mismatch. During hydration, useState provides server-matching defaults.
147-
// After mount, we swap in the user's actual preferences from localStorage.
148-
// Uses nuxtApp.hook('app:mounted') instead of onMounted so it works even
149-
// when useSettings() is first called from a plugin (no component context).
150-
const stored = readFromLocalStorage()
151-
const nuxtApp = useNuxtApp()
152-
153-
if (nuxtApp.isHydrating) {
154-
nuxtApp.hook('app:mounted', () => {
155-
settings.value = stored
156-
})
157-
} else {
158-
settings.value = stored
159-
}
160-
161-
// Persist future changes back to localStorage
162-
watch(
163-
settings,
164-
value => {
165-
try {
166-
localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
167-
} catch {}
168-
},
169-
{ deep: true },
170-
)
106+
if (!settingsRef) {
107+
settingsRef = useLocalStorage<AppSettings>(STORAGE_KEY, DEFAULT_SETTINGS, {
108+
mergeDefaults: true,
109+
})
171110
}
172111

173112
return {
174-
settings,
113+
settings: settingsRef,
175114
}
176115
}
177116

@@ -280,6 +219,31 @@ export function useSearchProvider() {
280219
}
281220
}
282221

222+
/**
223+
* SSR/hydration-safe search provider for use in async-data cache keys.
224+
* Defers reading the localStorage-backed preference until after `app:mounted`
225+
* so the server-rendered key matches initial hydration.
226+
*/
227+
export function useEffectiveSearchProvider() {
228+
const route = useRoute()
229+
const { searchProvider } = useSearchProvider()
230+
231+
const storedProviderReady = ref(import.meta.server ? false : !useNuxtApp().isHydrating)
232+
if (import.meta.client && !storedProviderReady.value) {
233+
useNuxtApp().hook('app:mounted', () => {
234+
storedProviderReady.value = true
235+
})
236+
}
237+
238+
return computed<SearchProvider>(() => {
239+
const p = normalizeSearchParam(route.query.p)
240+
if (p === 'npm') return 'npm'
241+
if (p === 'algolia') return 'algolia'
242+
if (storedProviderReady.value && searchProvider.value === 'npm') return 'npm'
243+
return 'algolia'
244+
})
245+
}
246+
283247
export function useBackgroundTheme() {
284248
const { t } = useI18n()
285249

app/utils/prehydrate.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,6 @@ export function initPreferencesOnPrehydrate() {
7575
document.documentElement.dataset.kbdShortcuts = 'false'
7676
}
7777

78-
// Search provider (default: algolia)
79-
if (settings.searchProvider === 'npm') {
80-
document.documentElement.dataset.searchProvider = 'npm'
81-
}
82-
8378
// Code font ligatures (default: true)
8479
if (settings.codeLigatures === false) {
8580
document.documentElement.dataset.codeLigatures = 'false'

0 commit comments

Comments
 (0)