From 4698ec0945cfd9e893e21f9bd212f94375e0b0c6 Mon Sep 17 00:00:00 2001
From: Yevhen Husak
Date: Sun, 8 Feb 2026 01:10:44 +0000
Subject: [PATCH 01/12] feat: add server-synced user preferences infrastructure
(#484)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- introduce the foundational layer for persisting user preferences
to the server
- add UserPreferencesSchema and shared types for user preferences
- add client-only sync composable with debounced saves, route guard flush, and sendBeacon fallback
- integrate server sync into useSettings and migrate to shared UserPreferences type
- extract generic localStorage helpers, migrate consumers, remove usePreferencesProvider
---
.../useLocalStorageHashProvider.ts | 42 +++
app/composables/usePackageListPreferences.ts | 7 +-
app/composables/usePreferencesProvider.ts | 109 --------
app/composables/useSettings.ts | 255 ------------------
.../useUserPreferencesSync.client.ts | 154 +++++++++++
app/utils/storage.ts | 34 +++
modules/cache.ts | 3 +
nuxt.config.ts | 5 +
server/api/user/preferences.get.ts | 15 ++
server/api/user/preferences.put.ts | 20 ++
.../preferences/user-preferences-store.ts | 49 ++++
shared/schemas/userPreferences.ts | 43 +++
12 files changed, 368 insertions(+), 368 deletions(-)
create mode 100644 app/composables/useLocalStorageHashProvider.ts
delete mode 100644 app/composables/usePreferencesProvider.ts
delete mode 100644 app/composables/useSettings.ts
create mode 100644 app/composables/useUserPreferencesSync.client.ts
create mode 100644 app/utils/storage.ts
create mode 100644 server/api/user/preferences.get.ts
create mode 100644 server/api/user/preferences.put.ts
create mode 100644 server/utils/preferences/user-preferences-store.ts
create mode 100644 shared/schemas/userPreferences.ts
diff --git a/app/composables/useLocalStorageHashProvider.ts b/app/composables/useLocalStorageHashProvider.ts
new file mode 100644
index 0000000000..f86281897e
--- /dev/null
+++ b/app/composables/useLocalStorageHashProvider.ts
@@ -0,0 +1,42 @@
+import { createDefu } from 'defu'
+import { createLocalStorageProvider } from '~/utils/storage'
+
+const defu = createDefu((object, key, value) => {
+ if (Array.isArray(object[key]) && Array.isArray(value)) {
+ object[key] = value
+ return true
+ }
+})
+
+export function useLocalStorageHashProvider(key: string, defaultValue: T) {
+ const provider = createLocalStorageProvider(key)
+ const data = ref(defaultValue)
+
+ onMounted(() => {
+ const stored = provider.get()
+ if (stored) {
+ data.value = defu(stored, defaultValue)
+ }
+ })
+
+ function save() {
+ provider.set(data.value)
+ }
+
+ function reset() {
+ data.value = { ...defaultValue }
+ provider.remove()
+ }
+
+ function update(key: K, value: T[K]) {
+ data.value[key] = value
+ save()
+ }
+
+ return {
+ data,
+ save,
+ reset,
+ update,
+ }
+}
diff --git a/app/composables/usePackageListPreferences.ts b/app/composables/usePackageListPreferences.ts
index 9d6540e0c0..ae8eaca599 100644
--- a/app/composables/usePackageListPreferences.ts
+++ b/app/composables/usePackageListPreferences.ts
@@ -11,18 +11,18 @@ import type {
} from '#shared/types/preferences'
import { DEFAULT_COLUMNS, DEFAULT_PREFERENCES } from '#shared/types/preferences'
+const STORAGE_KEY = 'npmx-list-prefs'
+
/**
* Composable for managing package list display preferences
* Persists to localStorage and provides reactive state
- *
*/
export function usePackageListPreferences() {
const {
data: preferences,
- isHydrated,
save,
reset,
- } = usePreferencesProvider(DEFAULT_PREFERENCES)
+ } = useLocalStorageHashProvider(STORAGE_KEY, DEFAULT_PREFERENCES)
// Computed accessors for common properties
const viewMode = computed({
@@ -106,7 +106,6 @@ export function usePackageListPreferences() {
return {
// Raw preferences
preferences,
- isHydrated,
// Individual properties with setters
viewMode,
diff --git a/app/composables/usePreferencesProvider.ts b/app/composables/usePreferencesProvider.ts
deleted file mode 100644
index 06955aeabd..0000000000
--- a/app/composables/usePreferencesProvider.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { createDefu } from 'defu'
-
-/**
- * Abstraction for preferences storage
- * Currently uses localStorage, designed for future user prefs API
- */
-
-const STORAGE_KEY = 'npmx-list-prefs'
-
-interface StorageProvider {
- get: () => T | null
- set: (value: T) => void
- remove: () => void
-}
-
-const defu = createDefu((object, key, value) => {
- if (Array.isArray(object[key]) && Array.isArray(value)) {
- object[key] = value
- return true
- }
-})
-
-/**
- * Creates a localStorage-based storage provider
- */
-function createLocalStorageProvider(key: string): StorageProvider {
- return {
- get: () => {
- if (import.meta.server) return null
- try {
- const stored = localStorage.getItem(key)
- if (stored) {
- return JSON.parse(stored) as T
- }
- } catch {
- // Corrupted data, remove it
- localStorage.removeItem(key)
- }
- return null
- },
- set: (value: T) => {
- if (import.meta.server) return
- try {
- localStorage.setItem(key, JSON.stringify(value))
- } catch {
- // Storage full or other error, fail silently
- }
- },
- remove: () => {
- if (import.meta.server) return
- localStorage.removeItem(key)
- },
- }
-}
-
-// Future: API-based provider would look like this:
-// function createApiStorageProvider(endpoint: string): StorageProvider {
-// return {
-// get: async () => { /* fetch from API */ },
-// set: async (value) => { /* POST to API */ },
-// remove: async () => { /* DELETE from API */ },
-// }
-// }
-
-/**
- * Composable for managing preferences storage
- * Abstracts the storage mechanism to allow future migration to API-based storage
- *
- */
-export function usePreferencesProvider(defaultValue: T) {
- const provider = createLocalStorageProvider(STORAGE_KEY)
- const data = ref(defaultValue)
- const isHydrated = shallowRef(false)
-
- // Load from storage on client
- onMounted(() => {
- const stored = provider.get()
- if (stored) {
- // Merge stored values with defaults to handle schema evolution
- data.value = defu(stored, defaultValue)
- }
- isHydrated.value = true
- })
-
- // Persist changes
- function save() {
- provider.set(data.value)
- }
-
- // Reset to defaults
- function reset() {
- data.value = { ...defaultValue }
- provider.remove()
- }
-
- // Update specific keys
- function update(key: K, value: T[K]) {
- data.value[key] = value
- save()
- }
-
- return {
- data,
- isHydrated,
- save,
- reset,
- update,
- }
-}
diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts
deleted file mode 100644
index 533c03042b..0000000000
--- a/app/composables/useSettings.ts
+++ /dev/null
@@ -1,255 +0,0 @@
-import type { RemovableRef } from '@vueuse/core'
-import { useLocalStorage } from '@vueuse/core'
-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
-
-/** Available search providers */
-export type SearchProvider = 'npm' | 'algolia'
-
-/**
- * Application settings stored in localStorage
- */
-export interface AppSettings {
- /** Display dates as relative (e.g., "3 days ago") instead of absolute */
- relativeDates: boolean
- /** Include @types/* package in install command for packages without built-in types */
- includeTypesInInstall: boolean
- /** Accent color theme */
- accentColorId: AccentColorId | null
- /** Preferred background shade */
- preferredBackgroundTheme: BackgroundThemeId | null
- /** Hide platform-specific packages (e.g., @scope/pkg-linux-x64) from search results */
- hidePlatformPackages: boolean
- /** Enable weekly download graph pulse looping animation */
- enableGraphPulseLooping: boolean
- /** User-selected locale */
- selectedLocale: LocaleObject['code'] | null
- /** Search provider for package search */
- searchProvider: SearchProvider
- /** Show search results as you type */
- instantSearch: boolean
- /** Enable/disable keyboard shortcuts */
- keyboardShortcuts: boolean
- /** Connector preferences */
- connector: {
- /** Automatically open the web auth page in the browser */
- autoOpenURL: boolean
- }
- codeContainerFull: boolean
- sidebar: {
- collapsed: string[]
- }
- chartFilter: {
- averageWindow: number
- smoothingTau: number
- anomaliesFixed: boolean
- predictionPoints: number
- }
-}
-
-const DEFAULT_SETTINGS: AppSettings = {
- relativeDates: false,
- includeTypesInInstall: true,
- accentColorId: null,
- hidePlatformPackages: true,
- enableGraphPulseLooping: false,
- selectedLocale: null,
- preferredBackgroundTheme: null,
- searchProvider: import.meta.test ? 'npm' : 'algolia',
- instantSearch: true,
- keyboardShortcuts: true,
- connector: {
- autoOpenURL: false,
- },
- codeContainerFull: false,
- sidebar: {
- collapsed: [],
- },
- chartFilter: {
- averageWindow: 0,
- smoothingTau: 1,
- anomaliesFixed: true,
- predictionPoints: 4,
- },
-}
-
-const STORAGE_KEY = 'npmx-settings'
-
-// Shared settings instance (singleton per app)
-let settingsRef: RemovableRef | null = null
-
-/**
- * Composable for managing application settings with localStorage persistence.
- * Settings are shared across all components that use this composable.
- */
-export function useSettings() {
- if (!settingsRef) {
- settingsRef = useLocalStorage(STORAGE_KEY, DEFAULT_SETTINGS, {
- mergeDefaults: true,
- })
- }
-
- return {
- settings: settingsRef,
- }
-}
-
-/**
- * Composable for accessing just the relative dates setting.
- * Useful for components that only need to read this specific setting.
- */
-export function useRelativeDates() {
- const { settings } = useSettings()
- return computed(() => settings.value.relativeDates)
-}
-
-/**
- * Composable for accessing just the keyboard shortcuts setting.
- * Useful for components that only need to read this specific setting.
- */
-export const useKeyboardShortcuts = createSharedComposable(function useKeyboardShortcuts() {
- const { settings } = useSettings()
- const enabled = computed(() => settings.value.keyboardShortcuts)
-
- if (import.meta.client) {
- watch(
- enabled,
- value => {
- if (value) {
- delete document.documentElement.dataset.kbdShortcuts
- } else {
- document.documentElement.dataset.kbdShortcuts = 'false'
- }
- },
- { immediate: true },
- )
- }
-
- return enabled
-})
-
-/**
- * Composable for managing accent color.
- */
-export function useAccentColor() {
- const { settings } = useSettings()
- const colorMode = useColorMode()
- const { t } = useI18n()
-
- const accentColorLabels = computed>(() => ({
- sky: t('settings.accent_colors.sky'),
- coral: t('settings.accent_colors.coral'),
- amber: t('settings.accent_colors.amber'),
- emerald: t('settings.accent_colors.emerald'),
- violet: t('settings.accent_colors.violet'),
- magenta: t('settings.accent_colors.magenta'),
- neutral: t('settings.clear_accent'),
- }))
-
- const accentColors = computed(() => {
- const isDark = colorMode.value === 'dark'
- const colors = isDark ? ACCENT_COLORS.dark : ACCENT_COLORS.light
-
- return Object.entries(colors).map(([id, value]) => ({
- id: id as AccentColorId,
- label: accentColorLabels.value[id as AccentColorId],
- value,
- }))
- })
-
- function setAccentColor(id: AccentColorId | null) {
- if (id) {
- document.documentElement.style.setProperty('--accent-color', `var(--swatch-${id})`)
- } else {
- document.documentElement.style.removeProperty('--accent-color')
- }
- settings.value.accentColorId = id
- }
-
- return {
- accentColors,
- selectedAccentColor: computed(() => settings.value.accentColorId),
- setAccentColor,
- }
-}
-
-/**
- * Composable for managing the search provider setting.
- */
-export function useSearchProvider() {
- const { settings } = useSettings()
-
- const searchProvider = computed({
- get: () => settings.value.searchProvider,
- set: (value: SearchProvider) => {
- settings.value.searchProvider = value
- },
- })
-
- const isAlgolia = computed(() => searchProvider.value === 'algolia')
-
- function toggle() {
- searchProvider.value = searchProvider.value === 'npm' ? 'algolia' : 'npm'
- }
-
- return {
- searchProvider,
- isAlgolia,
- toggle,
- }
-}
-
-export function useBackgroundTheme() {
- const { t } = useI18n()
-
- const bgThemeLabels = computed>(() => ({
- neutral: t('settings.background_themes.neutral'),
- stone: t('settings.background_themes.stone'),
- zinc: t('settings.background_themes.zinc'),
- slate: t('settings.background_themes.slate'),
- black: t('settings.background_themes.black'),
- }))
-
- const backgroundThemes = computed(() =>
- Object.entries(BACKGROUND_THEMES).map(([id, value]) => ({
- id: id as BackgroundThemeId,
- label: bgThemeLabels.value[id as BackgroundThemeId],
- value,
- })),
- )
-
- const { settings } = useSettings()
-
- function setBackgroundTheme(id: BackgroundThemeId | null) {
- if (id) {
- document.documentElement.dataset.bgTheme = id
- } else {
- document.documentElement.removeAttribute('data-bg-theme')
- }
- settings.value.preferredBackgroundTheme = id
- }
-
- return {
- backgroundThemes,
- selectedBackgroundTheme: computed(() => settings.value.preferredBackgroundTheme),
- setBackgroundTheme,
- }
-}
-
-export function useCodeContainer() {
- const { settings } = useSettings()
-
- const codeContainerFull = computed(() => settings.value.codeContainerFull)
-
- function toggleCodeContainer() {
- settings.value.codeContainerFull = !settings.value.codeContainerFull
- }
-
- return {
- codeContainerFull,
- toggleCodeContainer,
- }
-}
diff --git a/app/composables/useUserPreferencesSync.client.ts b/app/composables/useUserPreferencesSync.client.ts
new file mode 100644
index 0000000000..9b67711538
--- /dev/null
+++ b/app/composables/useUserPreferencesSync.client.ts
@@ -0,0 +1,154 @@
+import type { UserPreferences } from '#shared/schemas/userPreferences'
+import { DEFAULT_USER_PREFERENCES } from '#shared/schemas/userPreferences'
+
+const SYNC_DEBOUNCE_MS = 2000
+
+type SyncStatus = 'idle' | 'syncing' | 'synced' | 'error'
+
+interface PreferencesSyncState {
+ status: Ref
+ lastSyncedAt: Ref
+ error: Ref
+}
+
+let syncStateInstance: PreferencesSyncState | null = null
+let pendingSavePromise: Promise | null = null
+let hasPendingChanges = false
+let debounceTimeoutId: ReturnType | null = null
+
+function getSyncState(): PreferencesSyncState {
+ if (!syncStateInstance) {
+ syncStateInstance = {
+ status: ref('idle'),
+ lastSyncedAt: ref(null),
+ error: ref(null),
+ }
+ }
+ return syncStateInstance
+}
+
+async function fetchServerPreferences(): Promise {
+ try {
+ const response = await $fetch('/api/user/preferences', {
+ method: 'GET',
+ })
+ return response
+ } catch {
+ return null
+ }
+}
+
+async function saveToServer(preferences: UserPreferences): Promise {
+ const state = getSyncState()
+ state.status.value = 'syncing'
+ state.error.value = null
+
+ try {
+ await $fetch('/api/user/preferences', {
+ method: 'PUT',
+ body: preferences,
+ })
+ state.status.value = 'synced'
+ state.lastSyncedAt.value = new Date()
+ hasPendingChanges = false
+ return true
+ } catch (err) {
+ state.status.value = 'error'
+ state.error.value = err instanceof Error ? err.message : 'Failed to save preferences'
+ return false
+ }
+}
+
+function cancelPendingDebounce(): void {
+ if (debounceTimeoutId) {
+ clearTimeout(debounceTimeoutId)
+ debounceTimeoutId = null
+ }
+}
+
+export function useUserPreferencesSync() {
+ const { user } = useAtproto()
+ const state = getSyncState()
+ const router = useRouter()
+
+ const isAuthenticated = computed(() => !!user.value?.did)
+
+ function scheduleSync(preferences: UserPreferences): void {
+ if (!isAuthenticated.value) return
+
+ hasPendingChanges = true
+ cancelPendingDebounce()
+
+ debounceTimeoutId = setTimeout(async () => {
+ debounceTimeoutId = null
+ pendingSavePromise = saveToServer(preferences)
+ await pendingSavePromise
+ pendingSavePromise = null
+ }, SYNC_DEBOUNCE_MS)
+ }
+
+ async function loadFromServer(): Promise {
+ if (!isAuthenticated.value) {
+ return { ...DEFAULT_USER_PREFERENCES }
+ }
+
+ state.status.value = 'syncing'
+ const serverPreferences = await fetchServerPreferences()
+
+ if (serverPreferences) {
+ state.status.value = 'synced'
+ state.lastSyncedAt.value = new Date()
+ return serverPreferences
+ }
+
+ state.status.value = 'idle'
+ return { ...DEFAULT_USER_PREFERENCES }
+ }
+
+ async function flushPendingSync(preferences: UserPreferences): Promise {
+ if (!isAuthenticated.value || !hasPendingChanges) return
+
+ cancelPendingDebounce()
+
+ if (pendingSavePromise) {
+ await pendingSavePromise
+ } else {
+ await saveToServer(preferences)
+ }
+ }
+
+ function setupRouteGuard(getPreferences: () => UserPreferences): void {
+ router.beforeEach(async (_to, _from, next) => {
+ if (hasPendingChanges && isAuthenticated.value) {
+ await flushPendingSync(getPreferences())
+ }
+ next()
+ })
+ }
+
+ function setupBeforeUnload(getPreferences: () => UserPreferences): void {
+ if (import.meta.server) return
+
+ window.addEventListener('beforeunload', () => {
+ if (hasPendingChanges && isAuthenticated.value) {
+ const preferences = getPreferences()
+ navigator.sendBeacon(
+ '/api/user/preferences',
+ new Blob([JSON.stringify(preferences)], { type: 'application/json' }),
+ )
+ }
+ })
+ }
+
+ return {
+ isAuthenticated,
+ status: state.status,
+ lastSyncedAt: state.lastSyncedAt,
+ error: state.error,
+ loadFromServer,
+ scheduleSync,
+ flushPendingSync,
+ setupRouteGuard,
+ setupBeforeUnload,
+ }
+}
diff --git a/app/utils/storage.ts b/app/utils/storage.ts
new file mode 100644
index 0000000000..9706226a3f
--- /dev/null
+++ b/app/utils/storage.ts
@@ -0,0 +1,34 @@
+export interface StorageProvider {
+ get: () => T | null
+ set: (value: T) => void
+ remove: () => void
+}
+
+export function createLocalStorageProvider(key: string): StorageProvider {
+ return {
+ get: () => {
+ if (import.meta.server) return null
+ try {
+ const stored = localStorage.getItem(key)
+ if (stored) {
+ return JSON.parse(stored) as T
+ }
+ } catch {
+ localStorage.removeItem(key)
+ }
+ return null
+ },
+ set: (value: T) => {
+ if (import.meta.server) return
+ try {
+ localStorage.setItem(key, JSON.stringify(value))
+ } catch {
+ // Storage full or other error, fail silently
+ }
+ },
+ remove: () => {
+ if (import.meta.server) return
+ localStorage.removeItem(key)
+ },
+ }
+}
diff --git a/modules/cache.ts b/modules/cache.ts
index ab317475af..9a89eb1f6e 100644
--- a/modules/cache.ts
+++ b/modules/cache.ts
@@ -51,6 +51,9 @@ export default defineNuxtModule({
const env = process.env.VERCEL_ENV
nitroConfig.storage.atproto =
env === 'production' ? upstash : { driver: 'vercel-runtime-cache' }
+
+ nitroConfig.storage['user-preferences'] =
+ env === 'production' ? upstash : { driver: 'vercel-runtime-cache' }
})
},
})
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 6a317eed0d..1a7011f6ee 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -137,6 +137,7 @@ export default defineNuxtConfig({
// never cache
'/api/auth/**': { isr: false, cache: false },
'/api/social/**': { isr: false, cache: false },
+ '/api/user/**': { isr: false, cache: false },
'/api/atproto/bluesky-comments': {
isr: {
expiration: 60 * 60 /* one hour */,
@@ -249,6 +250,10 @@ export default defineNuxtConfig({
driver: 'fsLite',
base: './.cache/atproto',
},
+ 'user-preferences': {
+ driver: 'fsLite',
+ base: './.cache/user-preferences',
+ },
},
typescript: {
tsConfig: {
diff --git a/server/api/user/preferences.get.ts b/server/api/user/preferences.get.ts
new file mode 100644
index 0000000000..3f660ff571
--- /dev/null
+++ b/server/api/user/preferences.get.ts
@@ -0,0 +1,15 @@
+import { safeParse } from 'valibot'
+import { PublicUserSessionSchema } from '#shared/schemas/publicUserSession'
+import { DEFAULT_USER_PREFERENCES } from '#shared/schemas/userPreferences'
+import { useUserPreferencesStore } from '#server/utils/preferences/user-preferences-store'
+
+export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => {
+ const session = safeParse(PublicUserSessionSchema, serverSession.data.public)
+ if (!session.success) {
+ throw createError({ statusCode: 401, message: 'Unauthorized' })
+ }
+
+ const preferences = await useUserPreferencesStore().get(session.output.did)
+
+ return preferences ?? { ...DEFAULT_USER_PREFERENCES, updatedAt: new Date().toISOString() }
+})
diff --git a/server/api/user/preferences.put.ts b/server/api/user/preferences.put.ts
new file mode 100644
index 0000000000..9ab6e85582
--- /dev/null
+++ b/server/api/user/preferences.put.ts
@@ -0,0 +1,20 @@
+import { safeParse } from 'valibot'
+import { PublicUserSessionSchema } from '#shared/schemas/publicUserSession'
+import { UserPreferencesSchema } from '#shared/schemas/userPreferences'
+import { useUserPreferencesStore } from '#server/utils/preferences/user-preferences-store'
+
+export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => {
+ const session = safeParse(PublicUserSessionSchema, serverSession.data.public)
+ if (!session.success) {
+ throw createError({ statusCode: 401, message: 'Unauthorized' })
+ }
+
+ const settings = safeParse(UserPreferencesSchema, await readBody(event))
+ if (!settings.success) {
+ throw createError({ statusCode: 400, message: 'Invalid settings format' })
+ }
+
+ await useUserPreferencesStore().set(session.output.did, settings.output)
+
+ return { success: true }
+})
diff --git a/server/utils/preferences/user-preferences-store.ts b/server/utils/preferences/user-preferences-store.ts
new file mode 100644
index 0000000000..5fed6ff387
--- /dev/null
+++ b/server/utils/preferences/user-preferences-store.ts
@@ -0,0 +1,49 @@
+import type { UserPreferences } from '#shared/schemas/userPreferences'
+import {
+ USER_PREFERENCES_STORAGE_BASE,
+ DEFAULT_USER_PREFERENCES,
+} from '#shared/schemas/userPreferences'
+
+export class UserPreferencesStore {
+ private readonly storage = useStorage(USER_PREFERENCES_STORAGE_BASE)
+
+ async get(did: string): Promise {
+ const result = await this.storage.getItem(did)
+ return result ?? null
+ }
+
+ async set(did: string, preferences: UserPreferences): Promise {
+ const withTimestamp: UserPreferences = {
+ ...preferences,
+ updatedAt: new Date().toISOString(),
+ }
+ await this.storage.setItem(did, withTimestamp)
+ }
+
+ async merge(did: string, partial: Partial): Promise {
+ const existing = await this.get(did)
+ const base = existing ?? { ...DEFAULT_USER_PREFERENCES }
+
+ const merged: UserPreferences = {
+ ...base,
+ ...partial,
+ updatedAt: new Date().toISOString(),
+ }
+
+ await this.set(did, merged)
+ return merged
+ }
+
+ async delete(did: string): Promise {
+ await this.storage.removeItem(did)
+ }
+}
+
+let storeInstance: UserPreferencesStore | null = null
+
+export function useUserPreferencesStore(): UserPreferencesStore {
+ if (!storeInstance) {
+ storeInstance = new UserPreferencesStore()
+ }
+ return storeInstance
+}
diff --git a/shared/schemas/userPreferences.ts b/shared/schemas/userPreferences.ts
new file mode 100644
index 0000000000..241016fc8f
--- /dev/null
+++ b/shared/schemas/userPreferences.ts
@@ -0,0 +1,43 @@
+import { object, string, boolean, nullable, optional, picklist, type InferOutput } from 'valibot'
+import { ACCENT_COLORS, BACKGROUND_THEMES } from '#shared/utils/constants'
+
+const AccentColorIdSchema = picklist(Object.keys(ACCENT_COLORS.light) as [string, ...string[]])
+
+const BackgroundThemeIdSchema = picklist(Object.keys(BACKGROUND_THEMES) as [string, ...string[]])
+
+export const UserPreferencesSchema = object({
+ /** Display dates as relative (e.g., "3 days ago") instead of absolute */
+ relativeDates: optional(boolean()),
+ /** Include @types/* package in install command for packages without built-in types */
+ includeTypesInInstall: optional(boolean()),
+ /** Accent color theme ID (e.g., "rose", "amber", "emerald") */
+ accentColorId: optional(nullable(AccentColorIdSchema)),
+ /** Preferred background shade */
+ preferredBackgroundTheme: optional(nullable(BackgroundThemeIdSchema)),
+ /** Hide platform-specific packages (e.g., @scope/pkg-linux-x64) from search results */
+ hidePlatformPackages: optional(boolean()),
+ /** User-selected locale code (e.g., "en", "de", "ja") */
+ selectedLocale: optional(nullable(string())),
+ /** Timestamp of last update (ISO 8601) - managed by server */
+ updatedAt: optional(string()),
+})
+
+export type UserPreferences = InferOutput
+
+export type AccentColorId = keyof typeof ACCENT_COLORS.light
+export type BackgroundThemeId = keyof typeof BACKGROUND_THEMES
+
+/**
+ * Default user preferences.
+ * Used when creating new user preferences or merging with partial updates.
+ */
+export const DEFAULT_USER_PREFERENCES: Required> = {
+ relativeDates: false,
+ includeTypesInInstall: true,
+ accentColorId: null,
+ preferredBackgroundTheme: null,
+ hidePlatformPackages: true,
+ selectedLocale: null,
+}
+
+export const USER_PREFERENCES_STORAGE_BASE = 'npmx-kv-user-preferences'
From 726bd04db79dfd4b2bc97527ccb34424c48f196d Mon Sep 17 00:00:00 2001
From: Yevhen Husak
Date: Mon, 9 Feb 2026 01:03:45 +0000
Subject: [PATCH 02/12] feat: replace useSettings with useUserPreferences
- extract sidebar collapsed state into separate `usePackageSidebarPreferences` composable
- add `preferences-sync.client.ts` plugin for early color mode + server sync init
- wrap theme select in `` to prevent SSR hydration mismatch
- show sync status indicator on settings page for authenticated users
- add `useColorModePreference` composable to sync color mode with `@nuxtjs/color-mode`
---
app/components/CollapsibleSection.vue | 15 +-
app/components/Settings/AccentColorPicker.vue | 6 +-
app/components/Settings/BgThemePicker.vue | 4 +-
app/composables/useInstallCommand.ts | 4 +-
.../usePackageSidebarPreferences.ts | 24 +++
app/composables/useUserPreferences.ts | 132 +++++++++++++
app/composables/useUserPreferencesProvider.ts | 100 ++++++++++
app/pages/search.vue | 4 +-
app/pages/settings.vue | 177 +++++++++++-------
app/plugins/i18n-loader.client.ts | 18 +-
app/plugins/preferences-sync.client.ts | 13 ++
app/utils/prehydrate.ts | 16 +-
i18n/locales/en.json | 6 +-
shared/schemas/userPreferences.ts | 6 +
test/nuxt/components/DateTime.spec.ts | 6 +-
test/nuxt/components/compare/FacetRow.spec.ts | 6 +-
.../composables/use-install-command.spec.ts | 6 +-
17 files changed, 429 insertions(+), 114 deletions(-)
create mode 100644 app/composables/usePackageSidebarPreferences.ts
create mode 100644 app/composables/useUserPreferences.ts
create mode 100644 app/composables/useUserPreferencesProvider.ts
create mode 100644 app/plugins/preferences-sync.client.ts
diff --git a/app/components/CollapsibleSection.vue b/app/components/CollapsibleSection.vue
index 961e3502e5..42aae32f87 100644
--- a/app/components/CollapsibleSection.vue
+++ b/app/components/CollapsibleSection.vue
@@ -16,7 +16,7 @@ const props = withDefaults(defineProps(), {
headingLevel: 'h2',
})
-const appSettings = useSettings()
+const { sidebarPreferences } = usePackageSidebarPreferences()
const buttonId = `${props.id}-collapsible-button`
const contentId = `${props.id}-collapsible-content`
@@ -24,8 +24,8 @@ const contentId = `${props.id}-collapsible-content`
const isOpen = shallowRef(true)
onPrehydrate(() => {
- const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
- const collapsed: string[] = settings?.sidebar?.collapsed || []
+ const sidebar = JSON.parse(localStorage.getItem('npmx-sidebar-preferences') || '{}')
+ const collapsed: string[] = sidebar?.collapsed || []
for (const id of collapsed) {
if (!document.documentElement.dataset.collapsed?.split(' ').includes(id)) {
document.documentElement.dataset.collapsed = (
@@ -48,17 +48,16 @@ onMounted(() => {
function toggle() {
isOpen.value = !isOpen.value
- const removed = appSettings.settings.value.sidebar.collapsed.filter(c => c !== props.id)
+ const removed = sidebarPreferences.value.collapsed.filter(c => c !== props.id)
if (isOpen.value) {
- appSettings.settings.value.sidebar.collapsed = removed
+ sidebarPreferences.value.collapsed = removed
} else {
removed.push(props.id)
- appSettings.settings.value.sidebar.collapsed = removed
+ sidebarPreferences.value.collapsed = removed
}
- document.documentElement.dataset.collapsed =
- appSettings.settings.value.sidebar.collapsed.join(' ')
+ document.documentElement.dataset.collapsed = sidebarPreferences.value.collapsed.join(' ')
}
const ariaLabel = computed(() => {
diff --git a/app/components/Settings/AccentColorPicker.vue b/app/components/Settings/AccentColorPicker.vue
index 6d0d4ad217..af39588052 100644
--- a/app/components/Settings/AccentColorPicker.vue
+++ b/app/components/Settings/AccentColorPicker.vue
@@ -1,12 +1,12 @@
@@ -48,13 +48,37 @@ const setLocale: typeof setNuxti18nLocale = newLocale => {
- {{ $t('settings.title') }}
+ {{ $t("settings.title") }}
- {{ $t('settings.tagline') }}
+ {{ $t("settings.tagline") }}
+
+
+
+
+
+ {{ $t("settings.syncing") }}
+
+
+
+ {{ $t("settings.synced") }}
+
+
+
+ {{ $t("settings.sync_error") }}
+
+
+
+ {{ $t("settings.sync_enabled") }}
+
+
+
@@ -62,32 +86,47 @@ const setLocale: typeof setNuxti18nLocale = newLocale => {
- {{ $t('settings.sections.appearance') }}
+ {{ $t("settings.sections.appearance") }}
-
+
+
+
+
+
+
- {{ $t('settings.accent_colors.label') }}
+ {{ $t("settings.accent_colors.label") }}
@@ -95,7 +134,7 @@ const setLocale: typeof setNuxti18nLocale = newLocale => {
- {{ $t('settings.background_themes.label') }}
+ {{ $t("settings.background_themes.label") }}
@@ -105,13 +144,13 @@ const setLocale: typeof setNuxti18nLocale = newLocale => {
- {{ $t('settings.sections.display') }}
+ {{ $t("settings.sections.display") }}
@@ -121,7 +160,7 @@ const setLocale: typeof setNuxti18nLocale = newLocale => {
@@ -131,7 +170,7 @@ const setLocale: typeof setNuxti18nLocale = newLocale => {
@@ -141,7 +180,7 @@ const setLocale: typeof setNuxti18nLocale = newLocale => {
@@ -149,15 +188,15 @@ const setLocale: typeof setNuxti18nLocale = newLocale => {
- {{ $t('settings.sections.search') }}
+ {{ $t("settings.sections.search") }}
- {{ $t('settings.data_source.description') }}
+ {{ $t("settings.data_source.description") }}
@@ -167,7 +206,7 @@ const setLocale: typeof setNuxti18nLocale = newLocale => {
{ label: $t('settings.data_source.npm'), value: 'npm' },
{ label: $t('settings.data_source.algolia'), value: 'algolia' },
]"
- v-model="settings.searchProvider"
+ v-model="preferences.searchProvider"
block
size="sm"
class="max-w-48"
@@ -187,21 +226,21 @@ const setLocale: typeof setNuxti18nLocale = newLocale => {
{{
- settings.searchProvider === 'algolia'
- ? $t('settings.data_source.algolia_description')
- : $t('settings.data_source.npm_description')
+ preferences.searchProvider === "algolia"
+ ? $t("settings.data_source.algolia_description")
+ : $t("settings.data_source.npm_description")
}}
- {{ $t('search.algolia_disclaimer') }}
+ {{ $t("search.algolia_disclaimer") }}
@@ -212,7 +251,7 @@ const setLocale: typeof setNuxti18nLocale = newLocale => {
@@ -220,19 +259,19 @@ const setLocale: typeof setNuxti18nLocale = newLocale => {
- {{ $t('settings.sections.language') }}
+ {{ $t("settings.sections.language") }}
{
class="inline-flex items-center gap-2 text-sm text-fg-muted hover:text-fg transition-colors duration-200 focus-visible:outline-accent/70 rounded"
>
- {{ $t('settings.help_translate') }}
+ {{ $t("settings.help_translate") }}
@@ -277,7 +316,7 @@ const setLocale: typeof setNuxti18nLocale = newLocale => {
class="font-sans text-fg-muted text-sm"
>
- {{ $t('settings.translation_status') }}
+ {{ $t("settings.translation_status") }}
@@ -286,13 +325,13 @@ const setLocale: typeof setNuxti18nLocale = newLocale => {
- {{ $t('settings.sections.keyboard_shortcuts') }}
+ {{ $t("settings.sections.keyboard_shortcuts") }}
diff --git a/app/plugins/i18n-loader.client.ts b/app/plugins/i18n-loader.client.ts
index b34894396f..81f88ce724 100644
--- a/app/plugins/i18n-loader.client.ts
+++ b/app/plugins/i18n-loader.client.ts
@@ -1,20 +1,18 @@
export default defineNuxtPlugin({
+ name: 'i18n-loader',
+ dependsOn: ['preferences-sync'],
enforce: 'post',
env: { islands: false },
setup() {
const { $i18n } = useNuxtApp()
const { locale, locales, setLocale } = $i18n
- const { settings } = useSettings()
- const settingsLocale = settings.value.selectedLocale
+ const { preferences } = useUserPreferences()
+ const settingsLocale = preferences.value.selectedLocale
- if (
- settingsLocale &&
- // Check if the value is a supported locale
- locales.value.map(l => l.code).includes(settingsLocale) &&
- // Check if the value is not a current locale
- settingsLocale !== locale.value
- ) {
- setLocale(settingsLocale)
+ const matchedLocale = locales.value.map(l => l.code).find(code => code === settingsLocale)
+
+ if (matchedLocale && matchedLocale !== locale.value) {
+ setLocale(matchedLocale)
}
},
})
diff --git a/app/plugins/preferences-sync.client.ts b/app/plugins/preferences-sync.client.ts
new file mode 100644
index 0000000000..9c71b7c258
--- /dev/null
+++ b/app/plugins/preferences-sync.client.ts
@@ -0,0 +1,13 @@
+export default defineNuxtPlugin({
+ name: 'preferences-sync',
+ setup() {
+ const { initSync } = useUserPreferences()
+ const { applyStoredColorMode } = useColorModePreference()
+
+ // Apply stored color mode preference early (before components mount)
+ applyStoredColorMode()
+
+ // Initialize server sync for authenticated users
+ initSync()
+ },
+})
diff --git a/app/utils/prehydrate.ts b/app/utils/prehydrate.ts
index 075941a674..6b3b8b6db2 100644
--- a/app/utils/prehydrate.ts
+++ b/app/utils/prehydrate.ts
@@ -23,18 +23,16 @@ export function initPreferencesOnPrehydrate() {
// Valid package manager IDs
const validPMs = new Set(['npm', 'pnpm', 'yarn', 'bun', 'deno', 'vlt'])
- // Read settings from localStorage
- const settings = JSON.parse(
- localStorage.getItem('npmx-settings') || '{}',
- ) as Partial
+ // Read user preferences from localStorage
+ const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}')
- const accentColorId = settings.accentColorId
+ const accentColorId = preferences.accentColorId
if (accentColorId && accentColorIds.has(accentColorId)) {
document.documentElement.style.setProperty('--accent-color', `var(--swatch-${accentColorId})`)
}
// Apply background accent
- const preferredBackgroundTheme = settings.preferredBackgroundTheme
+ const preferredBackgroundTheme = preferences.preferredBackgroundTheme
if (preferredBackgroundTheme) {
document.documentElement.dataset.bgTheme = preferredBackgroundTheme
}
@@ -60,10 +58,12 @@ export function initPreferencesOnPrehydrate() {
// Set data attribute for CSS-based visibility
document.documentElement.dataset.pm = pm
- document.documentElement.dataset.collapsed = settings.sidebar?.collapsed?.join(' ') ?? ''
+ // Read sidebar preferences from separate localStorage key
+ const sidebar = JSON.parse(localStorage.getItem('npmx-sidebar-preferences') || '{}')
+ document.documentElement.dataset.collapsed = sidebar.collapsed?.join(' ') ?? ''
// Keyboard shortcuts (default: true)
- if (settings.keyboardShortcuts === false) {
+ if (preferences.keyboardShortcuts === false) {
document.documentElement.dataset.kbdShortcuts = 'false'
}
})
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index 66f0eb39b5..a04ebfea84 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -169,7 +169,11 @@
"black": "Black"
},
"keyboard_shortcuts_enabled": "Enable keyboard shortcuts",
- "keyboard_shortcuts_enabled_description": "Keyboard shortcuts can be disabled if they conflict with other browser or system shortcuts"
+ "keyboard_shortcuts_enabled_description": "Keyboard shortcuts can be disabled if they conflict with other browser or system shortcuts",
+ "syncing": "Syncing...",
+ "synced": "Settings synced",
+ "sync_error": "Sync failed",
+ "sync_enabled": "Cloud sync enabled"
},
"i18n": {
"missing_keys": "{count} missing translation | {count} missing translations",
diff --git a/shared/schemas/userPreferences.ts b/shared/schemas/userPreferences.ts
index 241016fc8f..ef615bff4a 100644
--- a/shared/schemas/userPreferences.ts
+++ b/shared/schemas/userPreferences.ts
@@ -5,6 +5,8 @@ const AccentColorIdSchema = picklist(Object.keys(ACCENT_COLORS.light) as [string
const BackgroundThemeIdSchema = picklist(Object.keys(BACKGROUND_THEMES) as [string, ...string[]])
+const ColorModePreferenceSchema = picklist(['light', 'dark', 'system'])
+
export const UserPreferencesSchema = object({
/** Display dates as relative (e.g., "3 days ago") instead of absolute */
relativeDates: optional(boolean()),
@@ -18,6 +20,8 @@ export const UserPreferencesSchema = object({
hidePlatformPackages: optional(boolean()),
/** User-selected locale code (e.g., "en", "de", "ja") */
selectedLocale: optional(nullable(string())),
+ /** Color mode preference: 'light', 'dark', or 'system' */
+ colorModePreference: optional(nullable(ColorModePreferenceSchema)),
/** Timestamp of last update (ISO 8601) - managed by server */
updatedAt: optional(string()),
})
@@ -26,6 +30,7 @@ export type UserPreferences = InferOutput
export type AccentColorId = keyof typeof ACCENT_COLORS.light
export type BackgroundThemeId = keyof typeof BACKGROUND_THEMES
+export type ColorModePreference = 'light' | 'dark' | 'system'
/**
* Default user preferences.
@@ -38,6 +43,7 @@ export const DEFAULT_USER_PREFERENCES: Required ({
+vi.mock('~/composables/useUserPreferences', () => ({
useRelativeDates: () => mockRelativeDates,
- useSettings: () => ({
- settings: ref({ relativeDates: mockRelativeDates.value }),
+ useUserPreferences: () => ({
+ preferences: ref({ relativeDates: mockRelativeDates.value }),
}),
useAccentColor: () => ({}),
}))
diff --git a/test/nuxt/components/compare/FacetRow.spec.ts b/test/nuxt/components/compare/FacetRow.spec.ts
index cfa30adc74..11467b5c08 100644
--- a/test/nuxt/components/compare/FacetRow.spec.ts
+++ b/test/nuxt/components/compare/FacetRow.spec.ts
@@ -3,10 +3,10 @@ import { mountSuspended } from '@nuxt/test-utils/runtime'
import FacetRow from '~/components/Compare/FacetRow.vue'
// Mock useRelativeDates for DateTime component
-vi.mock('~/composables/useSettings', () => ({
+vi.mock('~/composables/useUserPreferences', () => ({
useRelativeDates: () => ref(false),
- useSettings: () => ({
- settings: ref({ relativeDates: false }),
+ useUserPreferences: () => ({
+ preferences: ref({ relativeDates: false }),
}),
useAccentColor: () => ({}),
initAccentOnPrehydrate: () => {},
diff --git a/test/nuxt/composables/use-install-command.spec.ts b/test/nuxt/composables/use-install-command.spec.ts
index 5799427945..83fa40ba89 100644
--- a/test/nuxt/composables/use-install-command.spec.ts
+++ b/test/nuxt/composables/use-install-command.spec.ts
@@ -217,9 +217,9 @@ describe('useInstallCommand', () => {
})
it('should only include main command when @types disabled via settings', () => {
- // Get settings and disable includeTypesInInstall directly
- const { settings } = useSettings()
- settings.value.includeTypesInInstall = false
+ // Get preferences and disable includeTypesInInstall directly
+ const { preferences } = useUserPreferences()
+ preferences.value.includeTypesInInstall = false
const { fullInstallCommand, showTypesInInstall } = useInstallCommand(
'express',
From 6007c0b2fd050ed5740e765297927b6a9cb2c57d Mon Sep 17 00:00:00 2001
From: Yevhen Husak
Date: Sat, 14 Feb 2026 14:54:13 +0000
Subject: [PATCH 03/12] fix: add search provider to user-preferences
---
app/composables/useUserPreferences.ts | 27 +++++++++++++++++++++++++++
shared/schemas/userPreferences.ts | 6 ++++++
2 files changed, 33 insertions(+)
diff --git a/app/composables/useUserPreferences.ts b/app/composables/useUserPreferences.ts
index 0d5b2b412c..b8a44275f2 100644
--- a/app/composables/useUserPreferences.ts
+++ b/app/composables/useUserPreferences.ts
@@ -4,6 +4,7 @@ import {
type AccentColorId,
type BackgroundThemeId,
type ColorModePreference,
+ type SearchProvider,
} from '#shared/schemas/userPreferences'
/**
@@ -87,6 +88,32 @@ export function useBackgroundTheme() {
}
}
+/**
+ * Composable for managing the search provider preference.
+ */
+export function useSearchProvider() {
+ const { preferences } = useUserPreferences()
+
+ const searchProvider = computed({
+ get: () => preferences.value.searchProvider ?? 'algolia',
+ set: (value: SearchProvider) => {
+ preferences.value.searchProvider = value
+ },
+ })
+
+ const isAlgolia = computed(() => searchProvider.value === 'algolia')
+
+ function toggle() {
+ searchProvider.value = searchProvider.value === 'npm' ? 'algolia' : 'npm'
+ }
+
+ return {
+ searchProvider,
+ isAlgolia,
+ toggle,
+ }
+}
+
/**
* Composable for syncing color mode preference.
* Keeps the user preference in sync with @nuxtjs/color-mode's own LS key (`npmx-color-mode`)
diff --git a/shared/schemas/userPreferences.ts b/shared/schemas/userPreferences.ts
index ef615bff4a..cfbd520d93 100644
--- a/shared/schemas/userPreferences.ts
+++ b/shared/schemas/userPreferences.ts
@@ -7,6 +7,8 @@ const BackgroundThemeIdSchema = picklist(Object.keys(BACKGROUND_THEMES) as [stri
const ColorModePreferenceSchema = picklist(['light', 'dark', 'system'])
+const SearchProviderSchema = picklist(['npm', 'algolia'])
+
export const UserPreferencesSchema = object({
/** Display dates as relative (e.g., "3 days ago") instead of absolute */
relativeDates: optional(boolean()),
@@ -22,6 +24,8 @@ export const UserPreferencesSchema = object({
selectedLocale: optional(nullable(string())),
/** Color mode preference: 'light', 'dark', or 'system' */
colorModePreference: optional(nullable(ColorModePreferenceSchema)),
+ /** Search provider for package search: 'npm' or 'algolia' */
+ searchProvider: optional(SearchProviderSchema),
/** Timestamp of last update (ISO 8601) - managed by server */
updatedAt: optional(string()),
})
@@ -31,6 +35,7 @@ export type UserPreferences = InferOutput
export type AccentColorId = keyof typeof ACCENT_COLORS.light
export type BackgroundThemeId = keyof typeof BACKGROUND_THEMES
export type ColorModePreference = 'light' | 'dark' | 'system'
+export type SearchProvider = 'npm' | 'algolia'
/**
* Default user preferences.
@@ -44,6 +49,7 @@ export const DEFAULT_USER_PREFERENCES: Required
Date: Sat, 14 Feb 2026 15:38:32 +0000
Subject: [PATCH 04/12] fix: adapt connector settings
---
app/components/CollapsibleSection.vue | 14 +++----
app/components/Header/ConnectorModal.vue | 6 +--
app/composables/useConnector.ts | 4 +-
.../usePackageSidebarPreferences.ts | 24 -----------
app/composables/useUserLocalSettings.ts | 42 +++++++++++++++++++
app/utils/prehydrate.ts | 5 ---
.../components/HeaderConnectorModal.spec.ts | 21 ++++------
7 files changed, 61 insertions(+), 55 deletions(-)
delete mode 100644 app/composables/usePackageSidebarPreferences.ts
create mode 100644 app/composables/useUserLocalSettings.ts
diff --git a/app/components/CollapsibleSection.vue b/app/components/CollapsibleSection.vue
index 42aae32f87..4f9cf0622f 100644
--- a/app/components/CollapsibleSection.vue
+++ b/app/components/CollapsibleSection.vue
@@ -16,7 +16,7 @@ const props = withDefaults(defineProps(), {
headingLevel: 'h2',
})
-const { sidebarPreferences } = usePackageSidebarPreferences()
+const { userLocalSettings } = useUserLocalSettings()
const buttonId = `${props.id}-collapsible-button`
const contentId = `${props.id}-collapsible-content`
@@ -24,8 +24,8 @@ const contentId = `${props.id}-collapsible-content`
const isOpen = shallowRef(true)
onPrehydrate(() => {
- const sidebar = JSON.parse(localStorage.getItem('npmx-sidebar-preferences') || '{}')
- const collapsed: string[] = sidebar?.collapsed || []
+ const sidebar = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
+ const collapsed: string[] = sidebar?.sidebar?.collapsed || []
for (const id of collapsed) {
if (!document.documentElement.dataset.collapsed?.split(' ').includes(id)) {
document.documentElement.dataset.collapsed = (
@@ -48,16 +48,16 @@ onMounted(() => {
function toggle() {
isOpen.value = !isOpen.value
- const removed = sidebarPreferences.value.collapsed.filter(c => c !== props.id)
+ const removed = userLocalSettings.value.sidebar.collapsed.filter(c => c !== props.id)
if (isOpen.value) {
- sidebarPreferences.value.collapsed = removed
+ userLocalSettings.value.sidebar.collapsed = removed
} else {
removed.push(props.id)
- sidebarPreferences.value.collapsed = removed
+ userLocalSettings.value.sidebar.collapsed = removed
}
- document.documentElement.dataset.collapsed = sidebarPreferences.value.collapsed.join(' ')
+ document.documentElement.dataset.collapsed = userLocalSettings.value.sidebar.collapsed.join(' ')
}
const ariaLabel = computed(() => {
diff --git a/app/components/Header/ConnectorModal.vue b/app/components/Header/ConnectorModal.vue
index d157ea31cc..733d33eb84 100644
--- a/app/components/Header/ConnectorModal.vue
+++ b/app/components/Header/ConnectorModal.vue
@@ -2,7 +2,7 @@
const { isConnected, isConnecting, npmUser, error, hasOperations, connect, disconnect } =
useConnector()
-const { settings } = useSettings()
+const { userLocalSettings } = useUserLocalSettings()
const tokenInput = shallowRef('')
const portInput = shallowRef('31415')
@@ -68,7 +68,7 @@ const executeNpmxConnectorCommand = computed(() => {
@@ -157,7 +157,7 @@ const executeNpmxConnectorCommand = computed(() => {
diff --git a/app/composables/useConnector.ts b/app/composables/useConnector.ts
index d1ef4296ef..3ec5ed16b7 100644
--- a/app/composables/useConnector.ts
+++ b/app/composables/useConnector.ts
@@ -57,7 +57,7 @@ const STORAGE_KEY = 'npmx-connector'
const DEFAULT_PORT = 31415
export const useConnector = createSharedComposable(function useConnector() {
- const { settings } = useSettings()
+ const { userLocalSettings } = useUserLocalSettings()
// Persisted connection config
const config = useState<{ token: string; port: number } | null>('connector-config', () => null)
@@ -308,7 +308,7 @@ export const useConnector = createSharedComposable(function useConnector() {
body: {
otp,
interactive: !otp,
- openUrls: settings.value.connector.autoOpenURL,
+ openUrls: userLocalSettings.value.connector.autoOpenURL,
},
})
if (response?.success) {
diff --git a/app/composables/usePackageSidebarPreferences.ts b/app/composables/usePackageSidebarPreferences.ts
deleted file mode 100644
index 7edf3e551a..0000000000
--- a/app/composables/usePackageSidebarPreferences.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-interface SidebarPreferences {
- collapsed: string[]
-}
-
-const STORAGE_KEY = 'npmx-sidebar-preferences'
-const DEFAULT_SIDEBAR_PREFERENCES: SidebarPreferences = { collapsed: [] }
-
-let sidebarRef: Ref | null = null
-
-/**
- * Composable for managing sidebar section collapsed state.
- * This is local-only and uses its own LS key.
- */
-export function usePackageSidebarPreferences() {
- if (!sidebarRef) {
- sidebarRef = useLocalStorage(STORAGE_KEY, DEFAULT_SIDEBAR_PREFERENCES, {
- mergeDefaults: true,
- })
- }
-
- return {
- sidebarPreferences: sidebarRef,
- }
-}
diff --git a/app/composables/useUserLocalSettings.ts b/app/composables/useUserLocalSettings.ts
new file mode 100644
index 0000000000..3ef9cd55bd
--- /dev/null
+++ b/app/composables/useUserLocalSettings.ts
@@ -0,0 +1,42 @@
+interface UserLocalSettings {
+ sidebar: {
+ collapsed: string[]
+ }
+ connector: {
+ autoOpenURL: boolean
+ }
+}
+
+const STORAGE_KEY = 'npmx-settings'
+const DEFAULT_USER_LOCAL_SETTINGS: UserLocalSettings = {
+ sidebar: {
+ collapsed: [],
+ },
+ connector: {
+ autoOpenURL: false,
+ },
+}
+
+let userLocalSettingsRef: Ref | null = null
+
+/**
+ * Composable for managing local user settings.
+ * Uses its own LS key.
+ *
+ * This is for settings that are purely local and don't need to be synced
+ */
+export function useUserLocalSettings() {
+ if (!userLocalSettingsRef) {
+ userLocalSettingsRef = useLocalStorage(
+ STORAGE_KEY,
+ DEFAULT_USER_LOCAL_SETTINGS,
+ {
+ mergeDefaults: true,
+ },
+ )
+ }
+
+ return {
+ userLocalSettings: userLocalSettingsRef,
+ }
+}
diff --git a/app/utils/prehydrate.ts b/app/utils/prehydrate.ts
index 6b3b8b6db2..814bf899d8 100644
--- a/app/utils/prehydrate.ts
+++ b/app/utils/prehydrate.ts
@@ -61,10 +61,5 @@ export function initPreferencesOnPrehydrate() {
// Read sidebar preferences from separate localStorage key
const sidebar = JSON.parse(localStorage.getItem('npmx-sidebar-preferences') || '{}')
document.documentElement.dataset.collapsed = sidebar.collapsed?.join(' ') ?? ''
-
- // Keyboard shortcuts (default: true)
- if (preferences.keyboardShortcuts === false) {
- document.documentElement.dataset.kbdShortcuts = 'false'
- }
})
}
diff --git a/test/nuxt/components/HeaderConnectorModal.spec.ts b/test/nuxt/components/HeaderConnectorModal.spec.ts
index d152ec540b..9f60f1c08f 100644
--- a/test/nuxt/components/HeaderConnectorModal.spec.ts
+++ b/test/nuxt/components/HeaderConnectorModal.spec.ts
@@ -101,7 +101,7 @@ function resetMockState() {
error: null,
lastExecutionTime: null,
}
- mockSettings.value.connector = {
+ mockUserLocalSettings.value.connector = {
autoOpenURL: false,
}
}
@@ -112,28 +112,21 @@ function simulateConnect() {
mockState.value.avatar = 'https://example.com/avatar.png'
}
-const mockSettings = ref({
- relativeDates: false,
- includeTypesInInstall: true,
- accentColorId: null,
- hidePlatformPackages: true,
- selectedLocale: null,
- preferredBackgroundTheme: null,
- searchProvider: 'npm',
- connector: {
- autoOpenURL: false,
- },
+const mockUserLocalSettings = ref({
sidebar: {
collapsed: [],
},
+ connector: {
+ autoOpenURL: false,
+ },
})
mockNuxtImport('useConnector', () => {
return createMockUseConnector
})
-mockNuxtImport('useSettings', () => {
- return () => ({ settings: mockSettings })
+mockNuxtImport('useUserLocalSettings', () => {
+ return () => ({ userLocalSettings: mockUserLocalSettings })
})
mockNuxtImport('useSelectedPackageManager', () => {
From 86128b9068c73f26103ff9a32974bbda07d29ab5 Mon Sep 17 00:00:00 2001
From: Yevhen Husak
Date: Sun, 15 Feb 2026 22:44:47 +0000
Subject: [PATCH 05/12] refactor: split user-preferences composables and
streamline sync flow
---
app/components/CollapsibleSection.vue | 14 +-
app/components/Header/ConnectorModal.vue | 6 +-
app/components/Package/TrendsChart.vue | 1238 +++++++++--------
.../Package/WeeklyDownloadStats.vue | 6 +-
app/components/Settings/AccentColorPicker.vue | 7 +-
app/components/Settings/BgThemePicker.vue | 7 +-
app/composables/npm/useSearch.ts | 348 ++---
app/composables/useConnector.ts | 4 +-
app/composables/useInstallCommand.ts | 2 +-
app/composables/useUserLocalSettings.ts | 24 +-
app/composables/useUserPreferences.ts | 159 ---
app/composables/useUserPreferencesProvider.ts | 24 +-
.../useUserPreferencesSync.client.ts | 29 +-
.../userPreferences/useAccentColor.ts | 33 +
.../userPreferences/useBackgroundTheme.ts | 27 +
.../userPreferences/useColorModePreference.ts | 35 +
.../useInitUserPreferencesSync.ts | 10 +
.../userPreferences/useKeyboardShortcuts.ts | 20 +
.../userPreferences/useRelativeDates.ts | 4 +
.../userPreferences/useSearchProvider.ts | 24 +
.../useUserPreferencesState.ts | 10 +
.../useUserPreferencesSyncStatus.ts | 15 +
app/pages/search.vue | 2 +-
app/pages/settings.vue | 70 +-
app/plugins/i18n-loader.client.ts | 2 +-
app/plugins/preferences-sync.client.ts | 2 +-
app/utils/prehydrate.ts | 16 +-
i18n/schema.json | 12 +
server/api/user/preferences.post.ts | 1 +
shared/schemas/userPreferences.ts | 5 +-
test/e2e/interactions.spec.ts | 2 +-
test/nuxt/components/DateTime.spec.ts | 6 +-
.../components/HeaderConnectorModal.spec.ts | 2 +-
test/nuxt/components/compare/FacetRow.spec.ts | 7 +-
.../compare/PackageSelector.spec.ts | 70 +-
.../composables/use-install-command.spec.ts | 2 +-
.../use-keyboard-shortcuts.spec.ts | 56 +
.../use-package-list-preferences.spec.ts | 64 +
.../use-preferences-provider.spec.ts | 63 -
test/nuxt/composables/use-settings.spec.ts | 48 -
40 files changed, 1321 insertions(+), 1155 deletions(-)
delete mode 100644 app/composables/useUserPreferences.ts
create mode 100644 app/composables/userPreferences/useAccentColor.ts
create mode 100644 app/composables/userPreferences/useBackgroundTheme.ts
create mode 100644 app/composables/userPreferences/useColorModePreference.ts
create mode 100644 app/composables/userPreferences/useInitUserPreferencesSync.ts
create mode 100644 app/composables/userPreferences/useKeyboardShortcuts.ts
create mode 100644 app/composables/userPreferences/useRelativeDates.ts
create mode 100644 app/composables/userPreferences/useSearchProvider.ts
create mode 100644 app/composables/userPreferences/useUserPreferencesState.ts
create mode 100644 app/composables/userPreferences/useUserPreferencesSyncStatus.ts
create mode 100644 server/api/user/preferences.post.ts
create mode 100644 test/nuxt/composables/use-keyboard-shortcuts.spec.ts
create mode 100644 test/nuxt/composables/use-package-list-preferences.spec.ts
delete mode 100644 test/nuxt/composables/use-preferences-provider.spec.ts
delete mode 100644 test/nuxt/composables/use-settings.spec.ts
diff --git a/app/components/CollapsibleSection.vue b/app/components/CollapsibleSection.vue
index 4f9cf0622f..8509a4c353 100644
--- a/app/components/CollapsibleSection.vue
+++ b/app/components/CollapsibleSection.vue
@@ -16,7 +16,7 @@ const props = withDefaults(defineProps(), {
headingLevel: 'h2',
})
-const { userLocalSettings } = useUserLocalSettings()
+const { localSettings } = useUserLocalSettings()
const buttonId = `${props.id}-collapsible-button`
const contentId = `${props.id}-collapsible-content`
@@ -24,8 +24,8 @@ const contentId = `${props.id}-collapsible-content`
const isOpen = shallowRef(true)
onPrehydrate(() => {
- const sidebar = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
- const collapsed: string[] = sidebar?.sidebar?.collapsed || []
+ const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
+ const collapsed: string[] = settings?.sidebar?.collapsed || []
for (const id of collapsed) {
if (!document.documentElement.dataset.collapsed?.split(' ').includes(id)) {
document.documentElement.dataset.collapsed = (
@@ -48,16 +48,16 @@ onMounted(() => {
function toggle() {
isOpen.value = !isOpen.value
- const removed = userLocalSettings.value.sidebar.collapsed.filter(c => c !== props.id)
+ const removed = localSettings.value.sidebar.collapsed.filter(c => c !== props.id)
if (isOpen.value) {
- userLocalSettings.value.sidebar.collapsed = removed
+ localSettings.value.sidebar.collapsed = removed
} else {
removed.push(props.id)
- userLocalSettings.value.sidebar.collapsed = removed
+ localSettings.value.sidebar.collapsed = removed
}
- document.documentElement.dataset.collapsed = userLocalSettings.value.sidebar.collapsed.join(' ')
+ document.documentElement.dataset.collapsed = localSettings.value.sidebar.collapsed.join(' ')
}
const ariaLabel = computed(() => {
diff --git a/app/components/Header/ConnectorModal.vue b/app/components/Header/ConnectorModal.vue
index 733d33eb84..646242cc4c 100644
--- a/app/components/Header/ConnectorModal.vue
+++ b/app/components/Header/ConnectorModal.vue
@@ -2,7 +2,7 @@
const { isConnected, isConnecting, npmUser, error, hasOperations, connect, disconnect } =
useConnector()
-const { userLocalSettings } = useUserLocalSettings()
+const { localSettings } = useUserLocalSettings()
const tokenInput = shallowRef('')
const portInput = shallowRef('31415')
@@ -68,7 +68,7 @@ const executeNpmxConnectorCommand = computed(() => {
@@ -157,7 +157,7 @@ const executeNpmxConnectorCommand = computed(() => {
diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue
index e81a32d804..9929fecde7 100644
--- a/app/components/Package/TrendsChart.vue
+++ b/app/components/Package/TrendsChart.vue
@@ -1,12 +1,12 @@
@@ -1647,14 +1647,14 @@ const isSparklineLayout = computed({
>
- {{ $t('package.trends.chart_view_combined') }}
+ {{ $t("package.trends.chart_view_combined") }}
- {{ $t('package.trends.chart_view_split') }}
+ {{ $t("package.trends.chart_view_split") }}
@@ -1666,7 +1666,7 @@ const isSparklineLayout = computed({
id="trends-metric-select"
v-model="selectedMetric"
:disabled="activeMetricState.pending"
- :items="METRICS.map(m => ({ label: m.label, value: m.id }))"
+ :items="METRICS.map((m) => ({ label: m.label, value: m.id }))"
:label="$t('package.trends.facet')"
block
/>
@@ -1686,7 +1686,7 @@ const isSparklineLayout = computed({
for="startDate"
class="text-2xs font-mono text-fg-subtle tracking-wide uppercase"
>
- {{ $t('package.trends.start_date') }}
+ {{ $t("package.trends.start_date") }}
- {{ $t('package.trends.data_correction') }}
+ {{ $t("package.trends.data_correction") }}
@@ -1882,13 +1884,13 @@ const isSparklineLayout = computed({
- {{ $t('package.trends.contributors_skip', { count: skippedPackagesWithoutGitHub.length }) }}
- {{ skippedPackagesWithoutGitHub.join(', ') }}
+ {{ $t("package.trends.contributors_skip", { count: skippedPackagesWithoutGitHub.length }) }}
+ {{ skippedPackagesWithoutGitHub.join(", ") }}
- {{ $t('package.trends.title') }} — {{ activeMetricDef?.label }}
+ {{ $t("package.trends.title") }} — {{ activeMetricDef?.label }}
@@ -1942,7 +1944,7 @@ const isSparklineLayout = computed({
- {{ $t('compare.packages.line_chart_nav_hint') }}
+ {{ $t("compare.packages.line_chart_nav_hint") }}
@@ -2050,7 +2052,7 @@ const isSparklineLayout = computed({
stroke-linecap="round"
/>
- {{ $t('package.trends.legend_estimation') }}
+ {{ $t("package.trends.legend_estimation") }}
@@ -2183,7 +2185,7 @@ const isSparklineLayout = computed({
v-if="!chartData.dataset && !activeMetricState.pending"
class="min-h-[260px] flex items-center justify-center text-fg-subtle font-mono text-sm"
>
- {{ $t('package.trends.no_data') }}
+ {{ $t("package.trends.no_data") }}
@@ -2193,7 +2195,7 @@ const isSparklineLayout = computed({
aria-live="polite"
class="absolute top-1/2 inset-is-1/2 -translate-x-1/2 -translate-y-1/2 text-xs text-fg-subtle font-mono bg-bg/70 backdrop-blur px-3 py-2 rounded-md border border-border"
>
- {{ $t('package.trends.loading') }}
+ {{ $t("package.trends.loading") }}
@@ -2234,11 +2236,11 @@ const isSparklineLayout = computed({
}
}
-[data-pending='true'] .vue-data-ui-zoom {
+[data-pending="true"] .vue-data-ui-zoom {
opacity: 0.1;
}
-[data-pending='true'] .vue-data-ui-time-label {
+[data-pending="true"] .vue-data-ui-time-label {
opacity: 0;
}
@@ -2247,7 +2249,7 @@ const isSparklineLayout = computed({
top: unset !important;
}
-[data-minimap-visible='false'] .vue-data-ui-watermark {
+[data-minimap-visible="false"] .vue-data-ui-watermark {
top: calc(100% - 2rem) !important;
}
diff --git a/app/components/Package/WeeklyDownloadStats.vue b/app/components/Package/WeeklyDownloadStats.vue
index eed8954196..417dfb40ed 100644
--- a/app/components/Package/WeeklyDownloadStats.vue
+++ b/app/components/Package/WeeklyDownloadStats.vue
@@ -19,7 +19,7 @@ const props = defineProps<{
const router = useRouter()
const route = useRoute()
-const { settings } = useSettings()
+const { localSettings } = useUserLocalSettings()
const chartModal = useModal('chart-modal')
const hasChartModalTransitioned = shallowRef(false)
@@ -185,14 +185,14 @@ watch(
const correctedDownloads = computed(() => {
let data = weeklyDownloads.value as WeeklyDataPoint[]
if (!data.length) return data
- if (settings.value.chartFilter.anomaliesFixed) {
+ if (localSettings.value.chartFilter.anomaliesFixed) {
data = applyBlocklistCorrection({
data,
packageName: props.packageName,
granularity: 'weekly',
}) as WeeklyDataPoint[]
}
- data = applyDataCorrection(data, settings.value.chartFilter) as WeeklyDataPoint[]
+ data = applyDataCorrection(data, localSettings.value.chartFilter) as WeeklyDataPoint[]
return data
})
diff --git a/app/components/Settings/AccentColorPicker.vue b/app/components/Settings/AccentColorPicker.vue
index af39588052..7fca0f0750 100644
--- a/app/components/Settings/AccentColorPicker.vue
+++ b/app/components/Settings/AccentColorPicker.vue
@@ -1,11 +1,14 @@
{
const p = normalizeSearchParam(route.query.p);
diff --git a/app/composables/useLocalStorageHashProvider.ts b/app/composables/useLocalStorageHashProvider.ts
index f86281897e..53a6cac03d 100644
--- a/app/composables/useLocalStorageHashProvider.ts
+++ b/app/composables/useLocalStorageHashProvider.ts
@@ -10,12 +10,12 @@ const defu = createDefu((object, key, value) => {
export function useLocalStorageHashProvider(key: string, defaultValue: T) {
const provider = createLocalStorageProvider(key)
- const data = ref(defaultValue)
+ const data = ref(structuredClone(defaultValue))
onMounted(() => {
const stored = provider.get()
if (stored) {
- data.value = defu(stored, defaultValue)
+ data.value = defu(stored, structuredClone(defaultValue))
}
})
@@ -24,7 +24,7 @@ export function useLocalStorageHashProvider(key: string, defau
}
function reset() {
- data.value = { ...defaultValue }
+ data.value = structuredClone(defaultValue)
provider.remove()
}
diff --git a/app/composables/usePackageListPreferences.ts b/app/composables/usePackageListPreferences.ts
index ae8eaca599..0eec9260ec 100644
--- a/app/composables/usePackageListPreferences.ts
+++ b/app/composables/usePackageListPreferences.ts
@@ -41,22 +41,6 @@ export function usePackageListPreferences() {
},
})
- // One-time migration: replace legacy 'all' with the current maximum page size
- watch(
- isHydrated,
- hydrated => {
- if (!hydrated) {
- return
- }
-
- if ((preferences.value.pageSize as unknown) === 'all') {
- preferences.value.pageSize = Math.max(...PAGE_SIZE_OPTIONS) as PageSize
- save()
- }
- },
- { immediate: true },
- )
-
const pageSize = computed({
get: () => preferences.value.pageSize,
set: (value: PageSize) => {
diff --git a/app/composables/useUserPreferencesProvider.ts b/app/composables/useUserPreferencesProvider.ts
index 1e6e1c6449..433be3ee9e 100644
--- a/app/composables/useUserPreferencesProvider.ts
+++ b/app/composables/useUserPreferencesProvider.ts
@@ -54,6 +54,20 @@ export function useUserPreferencesProvider(
const isSynced = computed(() => status.value === 'synced')
const hasError = computed(() => status.value === 'error')
+ async function syncWithServer(): Promise {
+ const serverResult = await loadFromServer()
+
+ // If the server load failed, keep current local preferences untouched
+ if (hasError.value) return
+
+ const { merged, shouldPushToServer } = mergePreferences(preferences.value, serverResult)
+ if (shouldPushToServer) {
+ scheduleSync(preferences.value)
+ } else if (!arePreferencesEqual(preferences.value, merged)) {
+ preferences.value = merged
+ }
+ }
+
async function initSync(): Promise {
if (syncInitialized || import.meta.server) return
syncInitialized = true
@@ -62,13 +76,7 @@ export function useUserPreferencesProvider(
setupBeforeUnload(() => preferences.value)
if (isAuthenticated.value) {
- const serverResult = await loadFromServer()
- const { merged, shouldPushToServer } = mergePreferences(preferences.value, serverResult)
- if (shouldPushToServer) {
- scheduleSync(preferences.value)
- } else if (!arePreferencesEqual(preferences.value, merged)) {
- preferences.value = merged
- }
+ await syncWithServer()
}
watch(
@@ -83,13 +91,7 @@ export function useUserPreferencesProvider(
watch(isAuthenticated, async newIsAuth => {
if (newIsAuth) {
- const serverResult = await loadFromServer()
- const { merged, shouldPushToServer } = mergePreferences(preferences.value, serverResult)
- if (shouldPushToServer) {
- scheduleSync(preferences.value)
- } else if (!arePreferencesEqual(preferences.value, merged)) {
- preferences.value = merged
- }
+ await syncWithServer()
}
})
}
@@ -105,3 +107,13 @@ export function useUserPreferencesProvider(
initSync,
}
}
+
+/**
+ * Reset module-level singleton state. Test-only — do not use in production code.
+ */
+export function __resetPreferencesForTest(): void {
+ if (import.meta.test) {
+ dataRef = null
+ syncInitialized = false
+ }
+}
diff --git a/app/composables/useUserPreferencesSync.client.ts b/app/composables/useUserPreferencesSync.client.ts
index ee4ada99ea..105753a098 100644
--- a/app/composables/useUserPreferencesSync.client.ts
+++ b/app/composables/useUserPreferencesSync.client.ts
@@ -24,6 +24,8 @@ let pendingSavePromise: Promise | null = null
let hasPendingChanges = false
let debounceTimeoutId: ReturnType | null = null
let syncedResetTimeoutId: ReturnType | null = null
+let beforeUnloadRegistered = false
+let routeGuardRegistered = false
function getSyncState(): PreferencesSyncState {
if (!syncStateInstance) {
@@ -142,7 +144,8 @@ export function useUserPreferencesSync() {
}
// Network error — fall back to defaults, don't flag as new user
- state.status.value = 'idle'
+ state.status.value = 'error'
+ state.error.value = 'Failed to load preferences from server'
return { preferences: { ...DEFAULT_USER_PREFERENCES }, isNewUser: false }
}
@@ -159,6 +162,9 @@ export function useUserPreferencesSync() {
}
function setupRouteGuard(getPreferences: () => UserPreferences): void {
+ if (routeGuardRegistered) return
+ routeGuardRegistered = true
+
router.beforeEach(async (_to, _from, next) => {
if (hasPendingChanges && isAuthenticated.value) {
void flushPendingSync(getPreferences())
@@ -168,7 +174,8 @@ export function useUserPreferencesSync() {
}
function setupBeforeUnload(getPreferences: () => UserPreferences): void {
- if (import.meta.server) return
+ if (import.meta.server || beforeUnloadRegistered) return
+ beforeUnloadRegistered = true
window.addEventListener('beforeunload', () => {
if (hasPendingChanges && isAuthenticated.value) {
diff --git a/app/composables/userPreferences/useAccentColor.ts b/app/composables/userPreferences/useAccentColor.ts
index 3025e34ddf..7b57b26ea3 100644
--- a/app/composables/userPreferences/useAccentColor.ts
+++ b/app/composables/userPreferences/useAccentColor.ts
@@ -17,6 +17,10 @@ export function useAccentColor() {
})
function setAccentColor(id: AccentColorId | null) {
+ if (import.meta.server) {
+ preferences.value.accentColorId = id
+ return
+ }
if (id) {
document.documentElement.style.setProperty('--accent-color', `var(--swatch-${id})`)
} else {
diff --git a/app/composables/userPreferences/useBackgroundTheme.ts b/app/composables/userPreferences/useBackgroundTheme.ts
index 42eb91ae05..123297970d 100644
--- a/app/composables/userPreferences/useBackgroundTheme.ts
+++ b/app/composables/userPreferences/useBackgroundTheme.ts
@@ -11,6 +11,10 @@ export function useBackgroundTheme() {
const { preferences } = useUserPreferencesState()
function setBackgroundTheme(id: BackgroundThemeId | null) {
+ if (import.meta.server) {
+ preferences.value.preferredBackgroundTheme = id
+ return
+ }
if (id) {
document.documentElement.dataset.bgTheme = id
} else {
diff --git a/app/composables/userPreferences/useInstantSearch.ts b/app/composables/userPreferences/useInstantSearch.ts
index 5d75de3d46..ffb5a73d9e 100644
--- a/app/composables/userPreferences/useInstantSearch.ts
+++ b/app/composables/userPreferences/useInstantSearch.ts
@@ -1,10 +1,12 @@
-export const useInstantSearch = createSharedComposable(function useInstantSearch() {
- const { preferences } = useUserPreferencesState()
+export const useInstantSearchPreference = createSharedComposable(
+ function useInstantSearchPreference() {
+ const { preferences } = useUserPreferencesState()
- return computed({
- get: () => preferences.value.instantSearch ?? true,
- set: (value: boolean) => {
- preferences.value.instantSearch = value
- },
- })
-})
+ return computed({
+ get: () => preferences.value.instantSearch ?? true,
+ set: (value: boolean) => {
+ preferences.value.instantSearch = value
+ },
+ })
+ },
+)
diff --git a/app/composables/userPreferences/useKeyboardShortcuts.ts b/app/composables/userPreferences/useKeyboardShortcuts.ts
index 3138551563..a464fcaf0a 100644
--- a/app/composables/userPreferences/useKeyboardShortcuts.ts
+++ b/app/composables/userPreferences/useKeyboardShortcuts.ts
@@ -1,20 +1,22 @@
-export const useKeyboardShortcuts = createSharedComposable(function useKeyboardShortcuts() {
- const { preferences } = useUserPreferencesState()
- const enabled = computed(() => preferences.value.keyboardShortcuts ?? true)
+export const useKeyboardShortcutsPreference = createSharedComposable(
+ function useKeyboardShortcutsPreference() {
+ const { preferences } = useUserPreferencesState()
+ const enabled = computed(() => preferences.value.keyboardShortcuts ?? true)
- if (import.meta.client) {
- watch(
- enabled,
- value => {
- if (value) {
- delete document.documentElement.dataset.kbdShortcuts
- } else {
- document.documentElement.dataset.kbdShortcuts = 'false'
- }
- },
- { immediate: true },
- )
- }
+ if (import.meta.client) {
+ watch(
+ enabled,
+ value => {
+ if (value) {
+ delete document.documentElement.dataset.kbdShortcuts
+ } else {
+ document.documentElement.dataset.kbdShortcuts = 'false'
+ }
+ },
+ { immediate: true },
+ )
+ }
- return enabled
-})
+ return enabled
+ },
+)
diff --git a/app/composables/userPreferences/useRelativeDates.ts b/app/composables/userPreferences/useRelativeDates.ts
index c040aabc50..8282688487 100644
--- a/app/composables/userPreferences/useRelativeDates.ts
+++ b/app/composables/userPreferences/useRelativeDates.ts
@@ -1,4 +1,4 @@
-export function useRelativeDates() {
+export function useRelativeDatesPreference() {
const { preferences } = useUserPreferencesState()
return computed(() => preferences.value.relativeDates)
}
diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue
index 4bc4d85bca..1b756cc347 100644
--- a/app/pages/package/[[org]]/[name].vue
+++ b/app/pages/package/[[org]]/[name].vue
@@ -1,67 +1,67 @@
@@ -540,7 +588,7 @@ const showSkeleton = shallowRef(false)
- {{ $t('package.no_description') }}
+ {{ $t("package.no_description") }}
@@ -560,16 +608,16 @@ const showSkeleton = shallowRef(false)
>
{{
- deprecationNotice.type === 'package'
- ? $t('package.deprecation.package')
- : $t('package.deprecation.version')
+ deprecationNotice.type === "package"
+ ? $t("package.deprecation.package")
+ : $t("package.deprecation.version")
}}
- {{ $t('package.deprecation.no_reason') }}
+ {{ $t("package.deprecation.no_reason") }}
@@ -579,17 +627,17 @@ const showSkeleton = shallowRef(false)
>
- {{ $t('package.stats.license') }}
+ {{ $t("package.stats.license") }}
- {{ $t('package.license.none') }}
+ {{ $t("package.license.none") }}
- {{ $t('package.stats.deps') }}
+ {{ $t("package.stats.deps") }}
@@ -628,7 +676,7 @@ const showSkeleton = shallowRef(false)
:title="$t('package.stats.view_dependency_graph')"
classicon="i-lucide:network -rotate-90"
>
- {{ $t('package.stats.view_dependency_graph') }}
+ {{ $t("package.stats.view_dependency_graph") }}
- {{ $t('package.stats.inspect_dependency_tree') }}
+ {{ $t("package.stats.inspect_dependency_tree") }}
@@ -646,7 +694,7 @@ const showSkeleton = shallowRef(false)
- {{ $t('package.stats.install_size') }}
+ {{ $t("package.stats.install_size") }}
- {{ $t('package.stats.vulns') }}
+ {{ $t("package.stats.vulns") }}
- {{ $t('package.stats.published') }}
+ {{ $t("package.stats.published") }}
@@ -745,7 +793,7 @@ const showSkeleton = shallowRef(false)
@@ -1068,13 +1116,13 @@ const showSkeleton = shallowRef(false)
class="flex flex-col items-center py-20 text-center container w-full"
>
- {{ $t('package.not_found') }}
+ {{ $t("package.not_found") }}
- {{ error?.message ?? $t('package.not_found_message') }}
+ {{ error?.message ?? $t("package.not_found_message") }}
{{
- $t('common.go_back_home')
+ $t("common.go_back_home")
}}
@@ -1090,11 +1138,11 @@ const showSkeleton = shallowRef(false)
/* Mobile: single column, sidebar above readme */
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
- 'details'
- 'install'
- 'vulns'
- 'sidebar'
- 'readme';
+ "details"
+ "install"
+ "vulns"
+ "sidebar"
+ "readme";
}
/* Tablet/medium: install/vulns full width, readme+sidebar side by side */
@@ -1102,10 +1150,10 @@ const showSkeleton = shallowRef(false)
.packagePage {
grid-template-columns: 2fr 1fr;
grid-template-areas:
- 'details details'
- 'install sidebar'
- 'vulns sidebar'
- 'readme sidebar';
+ "details details"
+ "install sidebar"
+ "vulns sidebar"
+ "readme sidebar";
grid-template-rows: auto auto auto 1fr;
}
}
@@ -1115,10 +1163,10 @@ const showSkeleton = shallowRef(false)
.packagePage {
grid-template-columns: 1fr 20rem;
grid-template-areas:
- 'details sidebar'
- 'install sidebar'
- 'vulns sidebar'
- 'readme sidebar';
+ "details sidebar"
+ "install sidebar"
+ "vulns sidebar"
+ "readme sidebar";
grid-template-rows: auto auto auto 1fr;
}
}
diff --git a/app/pages/search.vue b/app/pages/search.vue
index bf1b5948ed..63c5efd177 100644
--- a/app/pages/search.vue
+++ b/app/pages/search.vue
@@ -482,7 +482,7 @@ function focusSearchInput() {
searchInput?.focus()
}
-const keyboardShortcuts = useKeyboardShortcuts()
+const keyboardShortcuts = useKeyboardShortcutsPreference()
function handleResultsKeydown(e: KeyboardEvent) {
if (!keyboardShortcuts.value) {
diff --git a/app/utils/prehydrate.ts b/app/utils/prehydrate.ts
index d56e6eed34..18557ef727 100644
--- a/app/utils/prehydrate.ts
+++ b/app/utils/prehydrate.ts
@@ -12,6 +12,61 @@ export function initPreferencesOnPrehydrate() {
// Callback is stringified by Nuxt - external variables won't be available.
// All constants must be hardcoded inside the callback.
onPrehydrate(() => {
+ // See comment above for oxlint-disable reason
+ // oxlint-disable-next-line eslint-plugin-unicorn(consistent-function-scoping)
+ function getValueFromLs(lsKey: string): T | undefined {
+ try {
+ const value = localStorage.getItem(lsKey)
+ if (value) {
+ const parsed = JSON.parse(value)
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ return parsed
+ }
+ }
+ } catch {
+ return undefined
+ }
+ }
+ // oxlint-disable-next-line eslint-plugin-unicorn(consistent-function-scoping)
+ function migrateLegacySettings() {
+ const migrationFlag = 'npmx-prefs-migrated'
+ if (localStorage.getItem(migrationFlag)) return
+
+ const legacySettings = getValueFromLs('npmx-settings') || {}
+ let userPreferences = getValueFromLs('npmx-user-preferences') || {}
+
+ const migratableKeys = [
+ 'accentColorId',
+ 'preferredBackgroundTheme',
+ 'selectedLocale',
+ 'relativeDates',
+ ] as const
+
+ const keysToMigrate = migratableKeys.filter(
+ key => key in legacySettings && !(key in userPreferences),
+ )
+
+ if (keysToMigrate.length > 0) {
+ const migrated = Object.fromEntries(keysToMigrate.map(key => [key, legacySettings[key]]))
+ userPreferences = { ...userPreferences, ...migrated }
+ localStorage.setItem('npmx-user-preferences', JSON.stringify(userPreferences))
+ }
+
+ // Clean migrated fields from legacy storage
+ const keysToRemove = migratableKeys.filter(key => key in legacySettings)
+ if (keysToRemove.length > 0) {
+ const cleaned = { ...legacySettings }
+ for (const key of keysToRemove) {
+ delete cleaned[key]
+ }
+ localStorage.setItem('npmx-settings', JSON.stringify(cleaned))
+ }
+
+ localStorage.setItem(migrationFlag, '1')
+ }
+
+ migrateLegacySettings()
+
// Valid accent color IDs (must match --swatch-* variables defined in main.css)
const accentColorIds = new Set([
'sky',
@@ -27,10 +82,7 @@ export function initPreferencesOnPrehydrate() {
const validPMs = new Set(['npm', 'pnpm', 'yarn', 'bun', 'deno', 'vlt'])
// Read user preferences from localStorage
- let preferences: UserPreferences = {}
- try {
- preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}')
- } catch {}
+ const preferences = getValueFromLs('npmx-user-preferences') || {}
const accentColorId = preferences.accentColorId
if (accentColorId && accentColorIds.has(accentColorId)) {
@@ -64,10 +116,7 @@ export function initPreferencesOnPrehydrate() {
// Set data attribute for CSS-based visibility
document.documentElement.dataset.pm = pm
- let localSettings: Partial = {}
- try {
- localSettings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
- } catch {}
+ const localSettings = getValueFromLs>('npmx-settings') || {}
document.documentElement.dataset.collapsed = localSettings.sidebar?.collapsed?.join(' ') ?? ''
})
}
diff --git a/server/utils/preferences/user-preferences-store.ts b/server/utils/preferences/user-preferences-store.ts
index 5fed6ff387..bde1d7cfad 100644
--- a/server/utils/preferences/user-preferences-store.ts
+++ b/server/utils/preferences/user-preferences-store.ts
@@ -12,26 +12,20 @@ export class UserPreferencesStore {
return result ?? null
}
- async set(did: string, preferences: UserPreferences): Promise {
+ async set(did: string, preferences: UserPreferences): Promise {
const withTimestamp: UserPreferences = {
...preferences,
updatedAt: new Date().toISOString(),
}
await this.storage.setItem(did, withTimestamp)
+ return withTimestamp
}
async merge(did: string, partial: Partial): Promise {
const existing = await this.get(did)
const base = existing ?? { ...DEFAULT_USER_PREFERENCES }
- const merged: UserPreferences = {
- ...base,
- ...partial,
- updatedAt: new Date().toISOString(),
- }
-
- await this.set(did, merged)
- return merged
+ return this.set(did, { ...base, ...partial })
}
async delete(did: string): Promise {
diff --git a/shared/schemas/userPreferences.ts b/shared/schemas/userPreferences.ts
index 216c26b261..6ebc8af231 100644
--- a/shared/schemas/userPreferences.ts
+++ b/shared/schemas/userPreferences.ts
@@ -14,13 +14,13 @@ export const UserPreferencesSchema = object({
relativeDates: optional(boolean()),
/** Include @types/* package in install command for packages without built-in types */
includeTypesInInstall: optional(boolean()),
- /** Accent color theme ID (e.g., "rose", "amber", "emerald") */
+ /** Accent color theme ID */
accentColorId: optional(nullable(AccentColorIdSchema)),
/** Preferred background shade */
preferredBackgroundTheme: optional(nullable(BackgroundThemeIdSchema)),
/** Hide platform-specific packages (e.g., @scope/pkg-linux-x64) from search results */
hidePlatformPackages: optional(boolean()),
- /** User-selected locale code (e.g., "en", "de", "ja") */
+ /** User-selected locale code */
selectedLocale: optional(nullable(string())),
/** Color mode preference: 'light', 'dark', or 'system' */
colorModePreference: optional(nullable(ColorModePreferenceSchema)),
diff --git a/test/e2e/hydration.spec.ts b/test/e2e/hydration.spec.ts
index 0f1c6ae9e4..bcd3c3fbab 100644
--- a/test/e2e/hydration.spec.ts
+++ b/test/e2e/hydration.spec.ts
@@ -49,7 +49,7 @@ test.describe('Hydration', () => {
for (const page of PAGES) {
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
await injectLocalStorage(pw, {
- 'npmx-settings': JSON.stringify({ accentColorId: 'violet' }),
+ 'npmx-user-preferences': JSON.stringify({ accentColorId: 'violet' }),
})
await goto(page, { waitUntil: 'hydration' })
@@ -63,7 +63,7 @@ test.describe('Hydration', () => {
for (const page of PAGES) {
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
await injectLocalStorage(pw, {
- 'npmx-settings': JSON.stringify({ preferredBackgroundTheme: 'slate' }),
+ 'npmx-user-preferences': JSON.stringify({ preferredBackgroundTheme: 'slate' }),
})
await goto(page, { waitUntil: 'hydration' })
@@ -91,7 +91,7 @@ test.describe('Hydration', () => {
for (const page of PAGES) {
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
await injectLocalStorage(pw, {
- 'npmx-settings': JSON.stringify({ selectedLocale: 'ar-EG' }),
+ 'npmx-user-preferences': JSON.stringify({ selectedLocale: 'ar-EG' }),
})
await goto(page, { waitUntil: 'hydration' })
@@ -105,7 +105,7 @@ test.describe('Hydration', () => {
for (const page of PAGES) {
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
await injectLocalStorage(pw, {
- 'npmx-settings': JSON.stringify({ relativeDates: true }),
+ 'npmx-user-preferences': JSON.stringify({ relativeDates: true }),
})
await goto(page, { waitUntil: 'hydration' })
diff --git a/test/e2e/legacy-settings-migration.spec.ts b/test/e2e/legacy-settings-migration.spec.ts
new file mode 100644
index 0000000000..e739d99730
--- /dev/null
+++ b/test/e2e/legacy-settings-migration.spec.ts
@@ -0,0 +1,205 @@
+import type { Page } from '@playwright/test'
+import { expect, test } from './test-utils'
+
+const LS_USER_PREFERENCES = 'npmx-user-preferences'
+const LS_LOCAL_SETTINGS = 'npmx-settings'
+const LS_MIGRATION_FLAG = 'npmx-prefs-migrated'
+
+const MIGRATABLE_KEYS = [
+ 'accentColorId',
+ 'preferredBackgroundTheme',
+ 'selectedLocale',
+ 'relativeDates',
+] as const
+
+async function injectLocalStorage(page: Page, entries: Record) {
+ await page.addInitScript((e: Record) => {
+ for (const [key, value] of Object.entries(e)) {
+ localStorage.setItem(key, value)
+ }
+ }, entries)
+}
+
+function readLs(page: Page, key: string) {
+ return page.evaluate((k: string) => localStorage.getItem(k), key)
+}
+
+function readLsJson(page: Page, key: string) {
+ return page.evaluate((k: string) => {
+ const raw = localStorage.getItem(k)
+ return raw ? JSON.parse(raw) : null
+ }, key)
+}
+
+async function verifyDefaults(page: Page) {
+ const prefs = await readLsJson(page, LS_USER_PREFERENCES)
+ expect(prefs.accentColorId).toBeNull()
+ expect(prefs.preferredBackgroundTheme).toBeNull()
+ expect(prefs.selectedLocale).toBeNull()
+ expect(prefs.relativeDates).toBe(false)
+}
+
+async function verifyLegacyCleaned(page: Page) {
+ const remaining = await readLsJson(page, LS_LOCAL_SETTINGS)
+ for (const key of MIGRATABLE_KEYS) {
+ expect(remaining).not.toHaveProperty(key)
+ }
+}
+
+test.describe('Legacy settings migration', () => {
+ test('migrates all legacy keys to user preferences', async ({ page, goto }) => {
+ const legacy = {
+ accentColorId: 'violet',
+ preferredBackgroundTheme: 'slate',
+ selectedLocale: 'de',
+ relativeDates: true,
+ // non-migratable key should remain untouched
+ sidebar: { collapsed: ['deps'] },
+ }
+
+ await injectLocalStorage(page, {
+ [LS_LOCAL_SETTINGS]: JSON.stringify(legacy),
+ })
+
+ await goto('/', { waitUntil: 'hydration' })
+
+ const prefs = await readLsJson(page, LS_USER_PREFERENCES)
+ expect(prefs).toMatchObject({
+ accentColorId: 'violet',
+ preferredBackgroundTheme: 'slate',
+ selectedLocale: 'de',
+ relativeDates: true,
+ })
+
+ await verifyLegacyCleaned(page)
+ const localSettings = await readLsJson(page, LS_LOCAL_SETTINGS)
+ expect(localSettings).toMatchObject({
+ sidebar: { collapsed: ['deps'] },
+ })
+ })
+
+ test('does not overwrite existing user preferences', async ({ page, goto }) => {
+ await injectLocalStorage(page, {
+ [LS_LOCAL_SETTINGS]: JSON.stringify({ accentColorId: 'coral', relativeDates: false }),
+ [LS_USER_PREFERENCES]: JSON.stringify({ accentColorId: 'violet' }),
+ })
+
+ await goto('/', { waitUntil: 'hydration' })
+
+ const prefs = await readLsJson(page, LS_USER_PREFERENCES)
+ // accentColorId should remain 'violet' (not overwritten by legacy 'coral')
+ expect(prefs.accentColorId).toBe('violet')
+ // relativeDates was not in user prefs, so it should be migrated from legacy
+ expect(prefs.relativeDates).toBe(false)
+
+ await verifyLegacyCleaned(page)
+ })
+
+ test('cleans migrated keys from legacy storage', async ({ page, goto }) => {
+ const legacy = {
+ accentColorId: 'violet',
+ preferredBackgroundTheme: 'slate',
+ sidebar: { collapsed: ['deps'] },
+ }
+
+ await injectLocalStorage(page, {
+ [LS_LOCAL_SETTINGS]: JSON.stringify(legacy),
+ })
+
+ await goto('/', { waitUntil: 'hydration' })
+
+ await verifyLegacyCleaned(page)
+ const remaining = await readLsJson(page, LS_LOCAL_SETTINGS)
+ expect(remaining).toHaveProperty('sidebar')
+ })
+
+ test('sets migration flag after completion', async ({ page, goto }) => {
+ await injectLocalStorage(page, {
+ [LS_LOCAL_SETTINGS]: JSON.stringify({ accentColorId: 'violet' }),
+ })
+
+ await goto('/', { waitUntil: 'hydration' })
+
+ const flag = await readLs(page, LS_MIGRATION_FLAG)
+ expect(flag).toBe('1')
+ })
+
+ test('skips migration if flag is already set', async ({ page, goto }) => {
+ await injectLocalStorage(page, {
+ [LS_LOCAL_SETTINGS]: JSON.stringify({ accentColorId: 'coral' }),
+ [LS_MIGRATION_FLAG]: '1',
+ })
+
+ await goto('/', { waitUntil: 'hydration' })
+
+ // Legacy accentColorId should NOT have been migrated since flag was already set
+ const prefs = await readLsJson(page, LS_USER_PREFERENCES)
+ expect(prefs?.accentColorId).not.toBe('coral')
+ })
+
+ test('applies migrated accent color to DOM', async ({ page, goto }) => {
+ await injectLocalStorage(page, {
+ [LS_LOCAL_SETTINGS]: JSON.stringify({ accentColorId: 'violet' }),
+ })
+
+ await goto('/', { waitUntil: 'hydration' })
+
+ const accentColor = await page.evaluate(() =>
+ document.documentElement.style.getPropertyValue('--accent-color'),
+ )
+ expect(accentColor).toBe('var(--swatch-violet)')
+ const prefs = await readLsJson(page, LS_USER_PREFERENCES)
+ expect(prefs?.accentColorId).toBe('violet')
+
+ await verifyLegacyCleaned(page)
+ })
+
+ test('applies migrated background theme to DOM', async ({ page, goto }) => {
+ await injectLocalStorage(page, {
+ [LS_LOCAL_SETTINGS]: JSON.stringify({ preferredBackgroundTheme: 'slate' }),
+ })
+
+ await goto('/', { waitUntil: 'hydration' })
+
+ const bgTheme = await page.evaluate(() => document.documentElement.dataset.bgTheme)
+ expect(bgTheme).toBe('slate')
+ const prefs = await readLsJson(page, LS_USER_PREFERENCES)
+ expect(prefs?.preferredBackgroundTheme).toBe('slate')
+
+ await verifyLegacyCleaned(page)
+ })
+
+ test('handles empty legacy storage gracefully', async ({ page, goto }) => {
+ await injectLocalStorage(page, {
+ [LS_LOCAL_SETTINGS]: JSON.stringify({}),
+ })
+
+ await goto('/', { waitUntil: 'hydration' })
+
+ const flag = await readLs(page, LS_MIGRATION_FLAG)
+ expect(flag).toBe('1')
+
+ await verifyDefaults(page)
+ })
+
+ test('handles missing legacy storage gracefully', async ({ page, goto }) => {
+ // No npmx-settings at all — migration should still set the flag
+ await goto('/', { waitUntil: 'hydration' })
+
+ const flag = await readLs(page, LS_MIGRATION_FLAG)
+ expect(flag).toBe('1')
+ await verifyDefaults(page)
+ })
+
+ test('handles missing legacy storage and applies current', async ({ page, goto }) => {
+ await injectLocalStorage(page, {
+ [LS_USER_PREFERENCES]: JSON.stringify({ accentColorId: 'violet' }),
+ })
+ await goto('/', { waitUntil: 'hydration' })
+
+ const flag = await readLs(page, LS_MIGRATION_FLAG)
+ expect(flag).toBe('1')
+ const prefs = await readLsJson(page, LS_USER_PREFERENCES)
+ expect(prefs?.accentColorId).toBe('violet')
+ })
+})
diff --git a/test/nuxt/components/DateTime.spec.ts b/test/nuxt/components/DateTime.spec.ts
index 6dd7b1a763..e65af1f935 100644
--- a/test/nuxt/components/DateTime.spec.ts
+++ b/test/nuxt/components/DateTime.spec.ts
@@ -2,10 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import DateTime from '~/components/DateTime.vue'
-// Mock the useRelativeDates composable
+// Mock the useRelativeDatesPreference composable
const mockRelativeDates = shallowRef(false)
-vi.mock('~/composables/userPreferences/useRelativeDates', () => ({
- useRelativeDates: () => mockRelativeDates,
+vi.mock('~/composables/userPreferences/useRelativeDatesPreference', () => ({
+ useRelativeDatesPreference: () => mockRelativeDates,
}))
describe('DateTime', () => {
diff --git a/test/nuxt/components/compare/FacetRow.spec.ts b/test/nuxt/components/compare/FacetRow.spec.ts
index 29b0e2a379..1f7d73abb5 100644
--- a/test/nuxt/components/compare/FacetRow.spec.ts
+++ b/test/nuxt/components/compare/FacetRow.spec.ts
@@ -2,9 +2,9 @@ import { describe, expect, it, vi } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import FacetRow from '~/components/Compare/FacetRow.vue'
-// Mock useRelativeDates for DateTime component
-vi.mock('~/composables/userPreferences/useRelativeDates', () => ({
- useRelativeDates: () => ref(false),
+// Mock useRelativeDatesPreference for DateTime component
+vi.mock('~/composables/userPreferences/useRelativeDatesPreference', () => ({
+ useRelativeDatesPreference: () => ref(false),
}))
describe('FacetRow', () => {
diff --git a/test/nuxt/composables/use-install-command.spec.ts b/test/nuxt/composables/use-install-command.spec.ts
index eda09b08d6..1350fec6a1 100644
--- a/test/nuxt/composables/use-install-command.spec.ts
+++ b/test/nuxt/composables/use-install-command.spec.ts
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { JsrPackageInfo } from '#shared/types/jsr'
+import { __resetPreferencesForTest } from '../../../app/composables/useUserPreferencesProvider'
describe('useInstallCommand', () => {
beforeEach(() => {
@@ -12,6 +13,7 @@ describe('useInstallCommand', () => {
afterEach(() => {
vi.unstubAllGlobals()
+ __resetPreferencesForTest()
})
describe('basic install commands', () => {
diff --git a/test/nuxt/composables/use-keyboard-shortcuts.spec.ts b/test/nuxt/composables/use-keyboard-shortcuts.spec.ts
index a3fcc5cd80..eab8632022 100644
--- a/test/nuxt/composables/use-keyboard-shortcuts.spec.ts
+++ b/test/nuxt/composables/use-keyboard-shortcuts.spec.ts
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { DEFAULT_USER_PREFERENCES } from '#shared/schemas/userPreferences'
-describe('useKeyboardShortcuts', () => {
+describe('useKeyboardShortcutsPreference', () => {
beforeEach(() => {
localStorage.clear()
// Reset preferences to defaults
@@ -15,7 +15,7 @@ describe('useKeyboardShortcuts', () => {
})
it('should return true by default', () => {
- const enabled = useKeyboardShortcuts()
+ const enabled = useKeyboardShortcutsPreference()
expect(enabled.value).toBe(true)
})
@@ -23,12 +23,12 @@ describe('useKeyboardShortcuts', () => {
const { preferences } = useUserPreferencesState()
preferences.value = { ...preferences.value, keyboardShortcuts: false }
- const enabled = useKeyboardShortcuts()
+ const enabled = useKeyboardShortcutsPreference()
expect(enabled.value).toBe(false)
})
it('should reactively update when preferences change', () => {
- const enabled = useKeyboardShortcuts()
+ const enabled = useKeyboardShortcutsPreference()
const { preferences } = useUserPreferencesState()
expect(enabled.value).toBe(true)
@@ -43,7 +43,7 @@ describe('useKeyboardShortcuts', () => {
it('should set data-kbd-shortcuts attribute when disabled', async () => {
const { preferences } = useUserPreferencesState()
- useKeyboardShortcuts()
+ useKeyboardShortcutsPreference()
preferences.value = { ...preferences.value, keyboardShortcuts: false }
await nextTick()
diff --git a/test/nuxt/composables/use-package-list-preferences.spec.ts b/test/nuxt/composables/use-package-list-preferences.spec.ts
index 0b4655bf69..3235724d54 100644
--- a/test/nuxt/composables/use-package-list-preferences.spec.ts
+++ b/test/nuxt/composables/use-package-list-preferences.spec.ts
@@ -1,22 +1,25 @@
import { describe, it, expect, beforeEach } from 'vitest'
-import { defineComponent, onMounted } from 'vue'
+import { defineComponent, nextTick } from 'vue'
import { mount } from '@vue/test-utils'
import { usePackageListPreferences } from '../../../app/composables/usePackageListPreferences'
import { DEFAULT_PREFERENCES } from '../../../shared/types/preferences'
const STORAGE_KEY = 'npmx-list-prefs'
-function mountWithSetup(run: () => void) {
- return mount(
+async function mountWithSetup(setupFn: () => T) {
+ let result: T
+ const wrapper = mount(
defineComponent({
name: 'TestHarness',
setup() {
- run()
+ result = setupFn()
return () => null
},
}),
{ attachTo: document.body },
)
+ await nextTick()
+ return { wrapper, result: result! }
}
function setLocalStorage(stored: Record) {
@@ -28,37 +31,28 @@ describe('usePackageListPreferences', () => {
localStorage.clear()
})
- it('initializes with default values when storage is empty', () => {
- mountWithSetup(() => {
- const { preferences } = usePackageListPreferences()
- onMounted(() => {
- expect(preferences.value).toEqual(DEFAULT_PREFERENCES)
- })
- })
+ it('initializes with default values when storage is empty', async () => {
+ const { result, wrapper } = await mountWithSetup(() => usePackageListPreferences())
+ expect(result.preferences.value).toEqual(DEFAULT_PREFERENCES)
+ wrapper.unmount()
})
- it('loads and merges values from localStorage', () => {
- mountWithSetup(() => {
- const stored = { viewMode: 'table' }
- setLocalStorage(stored)
- const { preferences } = usePackageListPreferences()
- onMounted(() => {
- expect(preferences.value.viewMode).toBe('table')
- expect(preferences.value.paginationMode).toBe(DEFAULT_PREFERENCES.paginationMode)
- expect(preferences.value.pageSize).toBe(DEFAULT_PREFERENCES.pageSize)
- expect(preferences.value.columns).toEqual(DEFAULT_PREFERENCES.columns)
- })
- })
+ it('loads and merges values from localStorage', async () => {
+ const stored = { viewMode: 'table' }
+ setLocalStorage(stored)
+ const { result, wrapper } = await mountWithSetup(() => usePackageListPreferences())
+ expect(result.preferences.value.viewMode).toBe('table')
+ expect(result.preferences.value.paginationMode).toBe(DEFAULT_PREFERENCES.paginationMode)
+ expect(result.preferences.value.pageSize).toBe(DEFAULT_PREFERENCES.pageSize)
+ expect(result.preferences.value.columns).toEqual(DEFAULT_PREFERENCES.columns)
+ wrapper.unmount()
})
- it('handles array merging by replacement', () => {
- mountWithSetup(() => {
- const stored = { columns: [] }
- setLocalStorage(stored)
- const { preferences } = usePackageListPreferences()
- onMounted(() => {
- expect(preferences.value.columns).toEqual([])
- })
- })
+ it('handles array merging by replacement', async () => {
+ const stored = { columns: [] }
+ setLocalStorage(stored)
+ const { result, wrapper } = await mountWithSetup(() => usePackageListPreferences())
+ expect(result.preferences.value.columns).toEqual([])
+ wrapper.unmount()
})
})
From c910ddf1f9dc054e38ebf091ed68b1445340b9c2 Mon Sep 17 00:00:00 2001
From: Yevhen Husak
Date: Wed, 11 Mar 2026 00:54:02 +0000
Subject: [PATCH 09/12] docs: add Readme for user preferences feature
---
CONTRIBUTING.md | 4 +
app/composables/userPreferences/README.md | 128 ++++++++++++++++++++++
2 files changed, 132 insertions(+)
create mode 100644 app/composables/userPreferences/README.md
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1628fdee01..497a98348c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -166,6 +166,7 @@ The `.cache/` directory is a separate storage mount used for fetch-cache and atp
app/ # Nuxt 4 app directory
├── components/ # Vue components (PascalCase.vue)
├── composables/ # Vue composables (useFeature.ts)
+│ └── userPreferences/ # User preference composables (synced to server)
├── pages/ # File-based routing
├── plugins/ # Nuxt plugins
├── app.vue # Root component
@@ -189,6 +190,9 @@ test/ # Vitest tests
> [!TIP]
> For more about the meaning of these directories, check out the docs on the [Nuxt directory structure](https://nuxt.com/docs/4.x/directory-structure).
+> [!TIP]
+> For guidance on working with user preferences and local settings, see the [User Preferences README](./app/composables/userPreferences/README.md).
+
### Local connector CLI
The `cli/` workspace contains a local connector that enables authenticated npm operations from the web UI. It runs on your machine and uses your existing npm credentials.
diff --git a/app/composables/userPreferences/README.md b/app/composables/userPreferences/README.md
new file mode 100644
index 0000000000..989a979bb8
--- /dev/null
+++ b/app/composables/userPreferences/README.md
@@ -0,0 +1,128 @@
+# User Preferences
+
+This directory contains composables for managing user preferences — settings that are synced to the server for authenticated users and persisted in `localStorage` for anonymous users.
+
+## Two stores, two purposes
+
+| Store | Composable | localStorage key | Synced to server | Use case |
+| -------------------- | --------------------------- | ----------------------- | ------------------- | ------------------------------------------------ |
+| **User preferences** | `useUserPreferencesState()` | `npmx-user-preferences` | Yes (authenticated) | Settings the user would expect on another device |
+| **Local settings** | `useUserLocalSettings()` | `npmx-settings` | No | Device-specific UI state |
+
+### Decision rule
+
+> Would a user expect this setting to transfer to another browser or device?
+>
+> - **Yes** → user preference (`npmx-user-preferences`)
+> - **No** → local setting (`npmx-settings`)
+
+**User preferences** (synced): accent color, background theme, color mode, locale, search provider, keyboard shortcuts, instant search, relative dates, hide platform packages, include @types in install.
+
+**Local settings** (device-only): chart filter params (average window, smoothing, prediction, anomalies), sidebar collapse state, connector auto-open URL.
+
+## Available composables
+
+| Composable | Purpose |
+| ---------------------------------- | ----------------------------------------------------------------- |
+| `useUserPreferencesState()` | Read/write access to the full preferences ref |
+| `useAccentColor()` | Accent color picker with DOM sync |
+| `useBackgroundTheme()` | Background shade with DOM sync |
+| `useColorModePreference()` | Color mode synced with `@nuxtjs/color-mode` |
+| `useInstantSearchPreference()` | Toggle instant search (shared) |
+| `useKeyboardShortcutsPreference()` | Toggle keyboard shortcuts with DOM attribute sync (shared) |
+| `useRelativeDatesPreference()` | Read-only computed for relative date display |
+| `useSearchProvider()` | npm/algolia toggle |
+| `useUserPreferencesSyncStatus()` | Sync status signals (`isSyncing`, `isSynced`, `hasError`) for UI |
+| `useInitUserPreferencesSync()` | Imperative `initSync()` — called by the plugin, not by components |
+
+## Adding a new user preference
+
+1. **Add the field to the schema** in `shared/schemas/userPreferences.ts`:
+
+ ```ts
+ export const UserPreferencesSchema = object({
+ // ... existing fields
+ myNewPref: optional(boolean()),
+ })
+ ```
+
+2. **Add a default value** in `DEFAULT_USER_PREFERENCES` (same file):
+
+ ```ts
+ export const DEFAULT_USER_PREFERENCES = {
+ // ... existing defaults
+ myNewPref: false,
+ }
+ ```
+
+3. **Create a composable** in this directory (e.g. `useMyNewPref.ts`):
+
+ ```ts
+ export function useMyNewPref() {
+ const { preferences } = useUserPreferencesState()
+
+ return computed({
+ get: () => preferences.value.myNewPref ?? false,
+ set: (value: boolean) => {
+ preferences.value.myNewPref = value
+ },
+ })
+ }
+ ```
+
+4. **Use it in components** — the composable is auto-imported:
+
+ ```vue
+
+ ```
+
+The preference will automatically persist to localStorage and sync to the server for authenticated users. No additional wiring needed.
+
+## Adding a new local setting
+
+1. **Add the field** to the `UserLocalSettings` interface and `DEFAULT_USER_LOCAL_SETTINGS` in `app/composables/useUserLocalSettings.ts`:
+
+ ```ts
+ export interface UserLocalSettings {
+ // ... existing fields
+ myLocalThing: boolean
+ }
+
+ const DEFAULT_USER_LOCAL_SETTINGS: UserLocalSettings = {
+ // ... existing defaults
+ myLocalThing: false,
+ }
+ ```
+
+2. **Use it in components:**
+
+ ```vue
+
+ ```
+
+## Architecture overview
+
+```
+useUserPreferencesProvider ← singleton, manages localStorage + sync lifecycle
+ ├── useUserPreferencesSync ← client-only: debounced server writes, route guard, sendBeacon
+ ├── useUserPreferencesState ← read/write access to reactive ref (used by all composables above)
+ └── preferences-merge.ts ← merge logic for first-login vs returning-user scenarios
+
+useUserLocalSettings ← separate singleton, localStorage only, no sync
+
+useLocalStorageHashProvider ← generic localStorage + defu provider (used by usePackageListPreferences)
+```
+
+### Sync flow (authenticated users)
+
+1. `preferences-sync.client.ts` plugin calls `initSync()` on app boot
+2. Preferences are loaded from server and merged with local state
+3. A deep watcher on the preferences ref triggers `scheduleSync()` on every change
+4. `scheduleSync()` debounces for 2 seconds, then pushes to `PUT /api/user/preferences`
+5. On route navigation, `router.beforeEach` flushes any pending sync
+6. On tab close, `sendBeacon` fires the latest preferences via `POST /api/user/preferences`
From 5f587a8accefbcaff1fee76c20551d32cc3711a6 Mon Sep 17 00:00:00 2001
From: Yevhen Husak
Date: Sun, 29 Mar 2026 13:28:25 +0100
Subject: [PATCH 10/12] fix: create shared cache for user preferences provier,
enhance useAtproto mocking
---
app/app.vue | 2 +-
app/components/Chart/SplitSparkline.vue | 2 +-
app/components/Compare/FacetBarChart.vue | 2 +-
app/components/Link/Base.vue | 58 +-
app/components/Package/TrendsChart.vue | 1210 ++++++++---------
.../Package/VersionDistribution.vue | 2 +-
.../Package/WeeklyDownloadStats.vue | 7 +-
app/composables/atproto/useAtproto.ts | 30 +-
app/composables/npm/useSearch.ts | 348 +++--
app/composables/useGlobalSearch.ts | 102 +-
app/composables/useShortcuts.ts | 4 +-
app/composables/useUserPreferencesProvider.ts | 115 +-
.../useUserPreferencesSync.client.ts | 5 +-
app/composables/userPreferences/README.md | 17 +-
.../userPreferences/useAccentColor.ts | 18 +-
.../userPreferences/useBackgroundTheme.ts | 24 +-
.../userPreferences/useColorModePreference.ts | 1 +
.../userPreferences/useInstantSearch.ts | 20 +-
.../userPreferences/useKeyboardShortcuts.ts | 4 +-
app/pages/package/[[org]]/[name].vue | 514 ++++---
app/pages/settings.vue | 153 ++-
app/utils/prehydrate.ts | 5 +
shared/schemas/userPreferences.ts | 14 +-
shared/utils/constants.ts | 4 +
test/e2e/legacy-settings-migration.spec.ts | 15 +
.../components/ProfileInviteSection.spec.ts | 44 +-
.../compare/PackageSelector.spec.ts | 1 -
.../composables/use-install-command.spec.ts | 1 -
.../user-preferences-merge.spec.ts | 141 +-
29 files changed, 1458 insertions(+), 1405 deletions(-)
diff --git a/app/app.vue b/app/app.vue
index 75c85108ef..8b43698b3b 100644
--- a/app/app.vue
+++ b/app/app.vue
@@ -22,7 +22,7 @@ const localeMap = locales.value.reduce(
)
const darkMode = usePreferredDark()
-const colorMode = useColorMode()
+const { colorMode } = useColorModePreference()
const colorScheme = computed(() => {
return {
system: darkMode ? 'dark light' : 'light dark',
diff --git a/app/components/Chart/SplitSparkline.vue b/app/components/Chart/SplitSparkline.vue
index 583f36a909..982c4dfa55 100644
--- a/app/components/Chart/SplitSparkline.vue
+++ b/app/components/Chart/SplitSparkline.vue
@@ -30,7 +30,7 @@ const props = defineProps<{
}>()
const { locale } = useI18n()
-const colorMode = useColorMode()
+const { colorMode } = useColorModePreference()
const resolvedMode = shallowRef<'light' | 'dark'>('light')
const rootEl = shallowRef(null)
const palette = getPalette('')
diff --git a/app/components/Compare/FacetBarChart.vue b/app/components/Compare/FacetBarChart.vue
index 67bed343e6..7b91163439 100644
--- a/app/components/Compare/FacetBarChart.vue
+++ b/app/components/Compare/FacetBarChart.vue
@@ -26,7 +26,7 @@ const props = defineProps<{
facetLoading?: boolean
}>()
-const colorMode = useColorMode()
+const { colorMode } = useColorModePreference()
const resolvedMode = shallowRef<'light' | 'dark'>('light')
const rootEl = shallowRef(null)
const { width } = useElementSize(rootEl)
diff --git a/app/components/Link/Base.vue b/app/components/Link/Base.vue
index f9467b1c2c..4cb4b5c407 100644
--- a/app/components/Link/Base.vue
+++ b/app/components/Link/Base.vue
@@ -1,79 +1,79 @@
-import type { Theme as VueDataUiTheme, VueUiXyConfig, VueUiXyDatasetItem } from "vue-data-ui";
-import { VueUiXy } from "vue-data-ui/vue-ui-xy";
-import { useDebounceFn, useElementSize, useTimeoutFn } from "@vueuse/core";
-import { useCssVariables } from "~/composables/useColors";
-import { OKLCH_NEUTRAL_FALLBACK, transparentizeOklch, lightenOklch } from "~/utils/colors";
-import { getFrameworkColor, isListedFramework } from "~/utils/frameworks";
-import { drawNpmxLogoAndTaglineWatermark } from "~/composables/useChartWatermark";
-import type { RepoRef } from "#shared/utils/git-providers";
+import type { Theme as VueDataUiTheme, VueUiXyConfig, VueUiXyDatasetItem } from 'vue-data-ui'
+import { VueUiXy } from 'vue-data-ui/vue-ui-xy'
+import { useDebounceFn, useElementSize, useTimeoutFn } from '@vueuse/core'
+import { useCssVariables } from '~/composables/useColors'
+import { OKLCH_NEUTRAL_FALLBACK, transparentizeOklch, lightenOklch } from '~/utils/colors'
+import { getFrameworkColor, isListedFramework } from '~/utils/frameworks'
+import { drawNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark'
+import type { RepoRef } from '#shared/utils/git-providers'
import type {
ChartTimeGranularity,
DailyDataPoint,
@@ -16,135 +16,135 @@ import type {
MonthlyDataPoint,
WeeklyDataPoint,
YearlyDataPoint,
-} from "~/types/chart";
-import { DATE_INPUT_MAX } from "~/utils/input";
+} from '~/types/chart'
+import { DATE_INPUT_MAX } from '~/utils/input'
import {
applyDataPipeline,
endDateOnlyToUtcMs,
DEFAULT_PREDICTION_POINTS,
-} from "~/utils/chart-data-prediction";
-import { applyBlocklistCorrection, getAnomaliesForPackages } from "~/utils/download-anomalies";
-import { copyAltTextForTrendLineChart, sanitise, loadFile, applyEllipsis } from "~/utils/charts";
+} from '~/utils/chart-data-prediction'
+import { applyBlocklistCorrection, getAnomaliesForPackages } from '~/utils/download-anomalies'
+import { copyAltTextForTrendLineChart, sanitise, loadFile, applyEllipsis } from '~/utils/charts'
-import("vue-data-ui/style.css");
+import('vue-data-ui/style.css')
const props = withDefaults(
defineProps<{
// For single package downloads history
- weeklyDownloads?: WeeklyDataPoint[];
- inModal?: boolean;
+ weeklyDownloads?: WeeklyDataPoint[]
+ inModal?: boolean
/**
* Backward compatible single package mode.
* Used when `weeklyDownloads` is provided.
*/
- packageName?: string;
+ packageName?: string
/**
* Multi-package mode.
* Used when `weeklyDownloads` is not provided.
*/
- packageNames?: string[];
- repoRef?: RepoRef | null | undefined;
- createdIso?: string | null;
+ packageNames?: string[]
+ repoRef?: RepoRef | null | undefined
+ createdIso?: string | null
/** When true, shows facet selector (e.g. Downloads / Likes). */
- showFacetSelector?: boolean;
- permalink?: boolean;
+ showFacetSelector?: boolean
+ permalink?: boolean
}>(),
{
permalink: false,
},
-);
+)
-const { locale } = useI18n();
-const { accentColors, selectedAccentColor } = useAccentColor();
-const { localSettings } = useUserLocalSettings();
-const { copy, copied } = useClipboard();
+const { locale } = useI18n()
+const { accentColors, selectedAccentColor } = useAccentColor()
+const { localSettings } = useUserLocalSettings()
+const { copy, copied } = useClipboard()
-const colorMode = useColorMode();
-const resolvedMode = shallowRef<"light" | "dark">("light");
-const rootEl = shallowRef(null);
-const isZoomed = shallowRef(false);
+const { colorMode } = useColorModePreference()
+const resolvedMode = shallowRef<'light' | 'dark'>('light')
+const rootEl = shallowRef(null)
+const isZoomed = shallowRef(false)
function setIsZoom({ isZoom }: { isZoom: boolean }) {
- isZoomed.value = isZoom;
+ isZoomed.value = isZoom
}
-const { width } = useElementSize(rootEl);
+const { width } = useElementSize(rootEl)
-const compactNumberFormatter = useCompactNumberFormatter();
+const compactNumberFormatter = useCompactNumberFormatter()
onMounted(async () => {
- rootEl.value = document.documentElement;
- resolvedMode.value = colorMode.value === "dark" ? "dark" : "light";
+ rootEl.value = document.documentElement
+ resolvedMode.value = colorMode.value === 'dark' ? 'dark' : 'light'
- initDateRangeFromWeekly();
- initDateRangeForMultiPackageWeekly52();
- initDateRangeFallbackClient();
+ initDateRangeFromWeekly()
+ initDateRangeForMultiPackageWeekly52()
+ initDateRangeFallbackClient()
- await nextTick();
- isMounted.value = true;
+ await nextTick()
+ isMounted.value = true
- loadMetric(selectedMetric.value);
-});
+ loadMetric(selectedMetric.value)
+})
const { colors } = useCssVariables(
[
- "--bg",
- "--fg",
- "--bg-subtle",
- "--bg-elevated",
- "--fg-subtle",
- "--fg-muted",
- "--border",
- "--border-subtle",
+ '--bg',
+ '--fg',
+ '--bg-subtle',
+ '--bg-elevated',
+ '--fg-subtle',
+ '--fg-muted',
+ '--border',
+ '--border-subtle',
],
{
element: rootEl,
watchHtmlAttributes: true,
watchResize: false,
},
-);
+)
watch(
() => colorMode.value,
- (value) => {
- resolvedMode.value = value === "dark" ? "dark" : "light";
+ value => {
+ resolvedMode.value = value === 'dark' ? 'dark' : 'light'
},
- { flush: "sync" },
-);
+ { flush: 'sync' },
+)
-const isDarkMode = computed(() => resolvedMode.value === "dark");
+const isDarkMode = computed(() => resolvedMode.value === 'dark')
const accentColorValueById = computed>(() => {
- const map: Record = {};
+ const map: Record = {}
for (const item of accentColors.value) {
- map[item.id] = item.value;
+ map[item.id] = item.value
}
- return map;
-});
+ return map
+})
const accent = computed(() => {
- const id = selectedAccentColor.value;
+ const id = selectedAccentColor.value
return id
? (accentColorValueById.value[id] ?? colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK)
- : (colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK);
-});
+ : (colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK)
+})
const watermarkColors = computed(() => ({
fg: colors.value.fg ?? OKLCH_NEUTRAL_FALLBACK,
bg: colors.value.bg ?? OKLCH_NEUTRAL_FALLBACK,
fgSubtle: colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK,
-}));
+}))
-const mobileBreakpointWidth = 640;
-const isMobile = computed(() => width.value > 0 && width.value < mobileBreakpointWidth);
+const mobileBreakpointWidth = 640
+const isMobile = computed(() => width.value > 0 && width.value < mobileBreakpointWidth)
-const DEFAULT_GRANULARITY: ChartTimeGranularity = "weekly";
+const DEFAULT_GRANULARITY: ChartTimeGranularity = 'weekly'
function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null;
+ return typeof value === 'object' && value !== null
}
function isWeeklyDataset(data: unknown): data is WeeklyDataPoint[] {
@@ -152,37 +152,37 @@ function isWeeklyDataset(data: unknown): data is WeeklyDataPoint[] {
Array.isArray(data) &&
data.length > 0 &&
isRecord(data[0]) &&
- "weekStart" in data[0] &&
- "weekEnd" in data[0] &&
- "value" in data[0]
- );
+ 'weekStart' in data[0] &&
+ 'weekEnd' in data[0] &&
+ 'value' in data[0]
+ )
}
function isDailyDataset(data: unknown): data is DailyDataPoint[] {
return (
Array.isArray(data) &&
data.length > 0 &&
isRecord(data[0]) &&
- "day" in data[0] &&
- "value" in data[0]
- );
+ 'day' in data[0] &&
+ 'value' in data[0]
+ )
}
function isMonthlyDataset(data: unknown): data is MonthlyDataPoint[] {
return (
Array.isArray(data) &&
data.length > 0 &&
isRecord(data[0]) &&
- "month" in data[0] &&
- "value" in data[0]
- );
+ 'month' in data[0] &&
+ 'value' in data[0]
+ )
}
function isYearlyDataset(data: unknown): data is YearlyDataPoint[] {
return (
Array.isArray(data) &&
data.length > 0 &&
isRecord(data[0]) &&
- "year" in data[0] &&
- "value" in data[0]
- );
+ 'year' in data[0] &&
+ 'value' in data[0]
+ )
}
/**
@@ -213,48 +213,48 @@ function formatXyDataset(
dataset: EvolutionData,
seriesName: string,
): { dataset: VueUiXyDatasetItem[] | null; dates: number[] } {
- const lightColor = isDarkMode.value ? lightenOklch(accent.value, 0.618) : undefined;
+ const lightColor = isDarkMode.value ? lightenOklch(accent.value, 0.618) : undefined
// Subtle path gradient applied in dark mode only
- const temperatureColors = lightColor ? [lightColor, accent.value] : undefined;
+ const temperatureColors = lightColor ? [lightColor, accent.value] : undefined
const datasetItem: VueUiXyDatasetItem = {
name: applyEllipsis(seriesName, 32),
- type: "line",
- series: dataset.map((d) => d.value),
+ type: 'line',
+ series: dataset.map(d => d.value),
color: accent.value,
temperatureColors,
useArea: true,
dashIndices: dataset
.map((item, index) => (item.hasAnomaly ? index : -1))
- .filter((index) => index !== -1),
- };
+ .filter(index => index !== -1),
+ }
- if (selectedGranularity === "weekly" && isWeeklyDataset(dataset)) {
+ if (selectedGranularity === 'weekly' && isWeeklyDataset(dataset)) {
return {
dataset: [datasetItem],
- dates: dataset.map((d) => d.timestampEnd),
- };
+ dates: dataset.map(d => d.timestampEnd),
+ }
}
- if (selectedGranularity === "daily" && isDailyDataset(dataset)) {
+ if (selectedGranularity === 'daily' && isDailyDataset(dataset)) {
return {
dataset: [datasetItem],
- dates: dataset.map((d) => d.timestamp),
- };
+ dates: dataset.map(d => d.timestamp),
+ }
}
- if (selectedGranularity === "monthly" && isMonthlyDataset(dataset)) {
+ if (selectedGranularity === 'monthly' && isMonthlyDataset(dataset)) {
return {
dataset: [datasetItem],
- dates: dataset.map((d) => d.timestamp),
- };
+ dates: dataset.map(d => d.timestamp),
+ }
}
- if (selectedGranularity === "yearly" && isYearlyDataset(dataset)) {
+ if (selectedGranularity === 'yearly' && isYearlyDataset(dataset)) {
return {
dataset: [datasetItem],
- dates: dataset.map((d) => d.timestamp),
- };
+ dates: dataset.map(d => d.timestamp),
+ }
}
- return { dataset: null, dates: [] };
+ return { dataset: null, dates: [] }
}
/**
@@ -285,40 +285,40 @@ function extractSeriesPoints(
selectedGranularity: ChartTimeGranularity,
dataset: EvolutionData,
): Array<{ timestamp: number; value: number; hasAnomaly: boolean }> {
- if (selectedGranularity === "weekly" && isWeeklyDataset(dataset)) {
- return dataset.map((d) => ({
+ if (selectedGranularity === 'weekly' && isWeeklyDataset(dataset)) {
+ return dataset.map(d => ({
timestamp: d.timestampEnd,
value: d.value,
hasAnomaly: !!d.hasAnomaly,
- }));
+ }))
}
if (
- (selectedGranularity === "daily" && isDailyDataset(dataset)) ||
- (selectedGranularity === "monthly" && isMonthlyDataset(dataset)) ||
- (selectedGranularity === "yearly" && isYearlyDataset(dataset))
+ (selectedGranularity === 'daily' && isDailyDataset(dataset)) ||
+ (selectedGranularity === 'monthly' && isMonthlyDataset(dataset)) ||
+ (selectedGranularity === 'yearly' && isYearlyDataset(dataset))
) {
return (dataset as Array<{ timestamp: number; value: number; hasAnomaly?: boolean }>).map(
- (d) => ({
+ d => ({
timestamp: d.timestamp,
value: d.value,
hasAnomaly: !!d.hasAnomaly,
}),
- );
+ )
}
- return [];
+ return []
}
function toIsoDateOnly(value: string): string {
- return value.slice(0, 10);
+ return value.slice(0, 10)
}
function isValidIsoDateOnly(value: string): boolean {
- return /^\d{4}-\d{2}-\d{2}$/.test(value);
+ return /^\d{4}-\d{2}-\d{2}$/.test(value)
}
function safeMin(a: string, b: string): string {
- return a.localeCompare(b) <= 0 ? a : b;
+ return a.localeCompare(b) <= 0 ? a : b
}
function safeMax(a: string, b: string): string {
- return a.localeCompare(b) >= 0 ? a : b;
+ return a.localeCompare(b) >= 0 ? a : b
}
/**
@@ -326,101 +326,101 @@ function safeMax(a: string, b: string): string {
* packageNames has entries, and packageName is not set.
*/
const isMultiPackageMode = computed(() => {
- const names = (props.packageNames ?? []).map((n) => String(n).trim()).filter(Boolean);
- const single = String(props.packageName ?? "").trim();
- return names.length > 0 && !single;
-});
+ const names = (props.packageNames ?? []).map(n => String(n).trim()).filter(Boolean)
+ const single = String(props.packageName ?? '').trim()
+ return names.length > 0 && !single
+})
const effectivePackageNames = computed(() => {
if (isMultiPackageMode.value)
- return (props.packageNames ?? []).map((n) => String(n).trim()).filter(Boolean);
- const single = String(props.packageName ?? "").trim();
- return single ? [single] : [];
-});
+ return (props.packageNames ?? []).map(n => String(n).trim()).filter(Boolean)
+ const single = String(props.packageName ?? '').trim()
+ return single ? [single] : []
+})
const {
fetchPackageDownloadEvolution,
fetchPackageLikesEvolution,
fetchRepoContributorsEvolution,
fetchRepoRefsForPackages,
-} = useCharts();
+} = useCharts()
-const repoRefsByPackage = shallowRef>({});
-const repoRefsRequestToken = shallowRef(0);
+const repoRefsByPackage = shallowRef>({})
+const repoRefsRequestToken = shallowRef(0)
watch(
() => effectivePackageNames.value,
- async (names) => {
- if (!import.meta.client) return;
+ async names => {
+ if (!import.meta.client) return
if (!isMultiPackageMode.value) {
- repoRefsByPackage.value = {};
- return;
+ repoRefsByPackage.value = {}
+ return
}
- const currentToken = ++repoRefsRequestToken.value;
- const refs = await fetchRepoRefsForPackages(names);
- if (currentToken !== repoRefsRequestToken.value) return;
- repoRefsByPackage.value = refs;
+ const currentToken = ++repoRefsRequestToken.value
+ const refs = await fetchRepoRefsForPackages(names)
+ if (currentToken !== repoRefsRequestToken.value) return
+ repoRefsByPackage.value = refs
},
{ immediate: true },
-);
+)
-const selectedGranularity = usePermalink("granularity", DEFAULT_GRANULARITY, {
+const selectedGranularity = usePermalink('granularity', DEFAULT_GRANULARITY, {
permanent: props.permalink,
-});
+})
-const displayedGranularity = shallowRef(DEFAULT_GRANULARITY);
+const displayedGranularity = shallowRef(DEFAULT_GRANULARITY)
const isEndDateOnPeriodEnd = computed(() => {
- const g = selectedGranularity.value;
+ const g = selectedGranularity.value
- const iso = String(endDate.value ?? "").slice(0, 10);
- if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false;
+ const iso = String(endDate.value ?? '').slice(0, 10)
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false
- const [year, month, day] = iso.split("-").map(Number);
- if (!year || !month || !day) return false;
+ const [year, month, day] = iso.split('-').map(Number)
+ if (!year || !month || !day) return false
- if (g === "daily") return true; // every day is a complete period
+ if (g === 'daily') return true // every day is a complete period
- if (g === "weekly") {
+ if (g === 'weekly') {
// The last week bucket is complete when the range length is divisible by 7
- const startIso = String(startDate.value ?? "").slice(0, 10);
- if (!/^\d{4}-\d{2}-\d{2}$/.test(startIso)) return false;
- const startMs = Date.UTC(...(startIso.split("-").map(Number) as [number, number, number]));
- const endMs = Date.UTC(year, month - 1, day);
- const totalDays = Math.floor((endMs - startMs) / 86400000) + 1;
- return totalDays % 7 === 0;
+ const startIso = String(startDate.value ?? '').slice(0, 10)
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(startIso)) return false
+ const startMs = Date.UTC(...(startIso.split('-').map(Number) as [number, number, number]))
+ const endMs = Date.UTC(year, month - 1, day)
+ const totalDays = Math.floor((endMs - startMs) / 86400000) + 1
+ return totalDays % 7 === 0
}
// Monthly: endDate is the last day of its month (UTC)
- if (g === "monthly") {
- const lastDayOfMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
- return day === lastDayOfMonth;
+ if (g === 'monthly') {
+ const lastDayOfMonth = new Date(Date.UTC(year, month, 0)).getUTCDate()
+ return day === lastDayOfMonth
}
// Yearly: endDate is the last day of the year (UTC)
- return month === 12 && day === 31;
-});
+ return month === 12 && day === 31
+})
const supportsEstimation = computed(
() =>
- !["daily", "weekly"].includes(displayedGranularity.value) &&
- selectedMetric.value !== "contributors",
-);
+ !['daily', 'weekly'].includes(displayedGranularity.value) &&
+ selectedMetric.value !== 'contributors',
+)
const hasDownloadAnomalies = computed(() =>
- normalisedDataset.value?.some((datapoint) => !!datapoint.dashIndices.length),
-);
+ normalisedDataset.value?.some(datapoint => !!datapoint.dashIndices.length),
+)
-const shouldRenderEstimationOverlay = computed(() => !pending.value && supportsEstimation.value);
+const shouldRenderEstimationOverlay = computed(() => !pending.value && supportsEstimation.value)
-const startDate = usePermalink("start", "", {
+const startDate = usePermalink('start', '', {
permanent: props.permalink,
-});
-const endDate = usePermalink("end", "", {
+})
+const endDate = usePermalink('end', '', {
permanent: props.permalink,
-});
+})
-const hasUserEditedDates = shallowRef(false);
+const hasUserEditedDates = shallowRef(false)
/**
* Initializes the date range from the provided weeklyDownloads dataset.
@@ -439,15 +439,15 @@ const hasUserEditedDates = shallowRef(false);
* override user-defined dates.
*/
function initDateRangeFromWeekly() {
- if (hasUserEditedDates.value) return;
- if (!props.weeklyDownloads?.length) return;
-
- const first = props.weeklyDownloads[0];
- const last = props.weeklyDownloads[props.weeklyDownloads.length - 1];
- const start = first?.weekStart ? toIsoDateOnly(first.weekStart) : "";
- const end = last?.weekEnd ? toIsoDateOnly(last.weekEnd) : "";
- if (isValidIsoDateOnly(start)) startDate.value = start;
- if (isValidIsoDateOnly(end)) endDate.value = end;
+ if (hasUserEditedDates.value) return
+ if (!props.weeklyDownloads?.length) return
+
+ const first = props.weeklyDownloads[0]
+ const last = props.weeklyDownloads[props.weeklyDownloads.length - 1]
+ const start = first?.weekStart ? toIsoDateOnly(first.weekStart) : ''
+ const end = last?.weekEnd ? toIsoDateOnly(last.weekEnd) : ''
+ if (isValidIsoDateOnly(start)) startDate.value = start
+ if (isValidIsoDateOnly(end)) endDate.value = end
}
/**
@@ -465,32 +465,32 @@ function initDateRangeFromWeekly() {
* - both `startDate` and `endDate` are already defined
*/
function initDateRangeFallbackClient() {
- if (hasUserEditedDates.value) return;
- if (!import.meta.client) return;
- if (startDate.value && endDate.value) return;
+ if (hasUserEditedDates.value) return
+ if (!import.meta.client) return
+ if (startDate.value && endDate.value) return
- const today = new Date();
+ const today = new Date()
const yesterday = new Date(
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 1),
- );
- const end = yesterday.toISOString().slice(0, 10);
+ )
+ const end = yesterday.toISOString().slice(0, 10)
- const startObj = new Date(yesterday);
- startObj.setUTCDate(startObj.getUTCDate() - 29);
- const start = startObj.toISOString().slice(0, 10);
+ const startObj = new Date(yesterday)
+ startObj.setUTCDate(startObj.getUTCDate() - 29)
+ const start = startObj.toISOString().slice(0, 10)
- if (!startDate.value) startDate.value = start;
- if (!endDate.value) endDate.value = end;
+ if (!startDate.value) startDate.value = start
+ if (!endDate.value) endDate.value = end
}
function toUtcDateOnly(date: Date): string {
- return date.toISOString().slice(0, 10);
+ return date.toISOString().slice(0, 10)
}
function addUtcDays(date: Date, days: number): Date {
- const next = new Date(date);
- next.setUTCDate(next.getUTCDate() + days);
- return next;
+ const next = new Date(date)
+ next.setUTCDate(next.getUTCDate() + days)
+ return next
}
/**
@@ -512,71 +512,71 @@ function addUtcDays(date: Date, days: number): Date {
* - both `startDate` and `endDate` are already defined
*/
function initDateRangeForMultiPackageWeekly52() {
- if (hasUserEditedDates.value) return;
- if (!import.meta.client) return;
- if (!isMultiPackageMode.value) return;
- if (startDate.value && endDate.value) return;
+ if (hasUserEditedDates.value) return
+ if (!import.meta.client) return
+ if (!isMultiPackageMode.value) return
+ if (startDate.value && endDate.value) return
- const today = new Date();
+ const today = new Date()
const yesterday = new Date(
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 1),
- );
+ )
- endDate.value = toUtcDateOnly(yesterday);
- startDate.value = toUtcDateOnly(addUtcDays(yesterday, -(52 * 7) + 1));
+ endDate.value = toUtcDateOnly(yesterday)
+ startDate.value = toUtcDateOnly(addUtcDays(yesterday, -(52 * 7) + 1))
}
watch(
() => (props.packageNames ?? []).length,
() => {
- initDateRangeForMultiPackageWeekly52();
+ initDateRangeForMultiPackageWeekly52()
},
{ immediate: true },
-);
+)
-const initialStartDate = shallowRef(""); // YYYY-MM-DD
-const initialEndDate = shallowRef(""); // YYYY-MM-DD
+const initialStartDate = shallowRef('') // YYYY-MM-DD
+const initialEndDate = shallowRef('') // YYYY-MM-DD
function setInitialRangeIfEmpty() {
- if (initialStartDate.value || initialEndDate.value) return;
- if (startDate.value) initialStartDate.value = startDate.value;
- if (endDate.value) initialEndDate.value = endDate.value;
+ if (initialStartDate.value || initialEndDate.value) return
+ if (startDate.value) initialStartDate.value = startDate.value
+ if (endDate.value) initialEndDate.value = endDate.value
}
watch(
[startDate, endDate],
() => {
- if (startDate.value || endDate.value) hasUserEditedDates.value = true;
- setInitialRangeIfEmpty();
+ if (startDate.value || endDate.value) hasUserEditedDates.value = true
+ setInitialRangeIfEmpty()
},
- { immediate: true, flush: "post" },
-);
+ { immediate: true, flush: 'post' },
+)
const showResetButton = computed(() => {
- if (!initialStartDate.value && !initialEndDate.value) return false;
- return startDate.value !== initialStartDate.value || endDate.value !== initialEndDate.value;
-});
+ if (!initialStartDate.value && !initialEndDate.value) return false
+ return startDate.value !== initialStartDate.value || endDate.value !== initialEndDate.value
+})
function resetDateRange() {
- hasUserEditedDates.value = false;
- startDate.value = "";
- endDate.value = "";
- initDateRangeFromWeekly();
- initDateRangeForMultiPackageWeekly52();
- initDateRangeFallbackClient();
+ hasUserEditedDates.value = false
+ startDate.value = ''
+ endDate.value = ''
+ initDateRangeFromWeekly()
+ initDateRangeForMultiPackageWeekly52()
+ initDateRangeFallbackClient()
}
const options = shallowRef<
- | { granularity: "day"; startDate?: string; endDate?: string }
- | { granularity: "week"; weeks: number; startDate?: string; endDate?: string }
+ | { granularity: 'day'; startDate?: string; endDate?: string }
+ | { granularity: 'week'; weeks: number; startDate?: string; endDate?: string }
| {
- granularity: "month";
- months: number;
- startDate?: string;
- endDate?: string;
+ granularity: 'month'
+ months: number
+ startDate?: string
+ endDate?: string
}
- | { granularity: "year"; startDate?: string; endDate?: string }
->({ granularity: "week", weeks: 52 });
+ | { granularity: 'year'; startDate?: string; endDate?: string }
+>({ granularity: 'week', weeks: 52 })
/**
* Applies the current date range (`startDate` / `endDate`) to a base options
@@ -597,56 +597,56 @@ const options = shallowRef<
* `endDate` fields
*/
function applyDateRange>(base: T): T & DateRangeFields {
- const next: T & DateRangeFields = { ...base };
+ const next: T & DateRangeFields = { ...base }
- const start = startDate.value ? toIsoDateOnly(startDate.value) : "";
- const end = endDate.value ? toIsoDateOnly(endDate.value) : "";
+ const start = startDate.value ? toIsoDateOnly(startDate.value) : ''
+ const end = endDate.value ? toIsoDateOnly(endDate.value) : ''
- const validStart = start && isValidIsoDateOnly(start) ? start : "";
- const validEnd = end && isValidIsoDateOnly(end) ? end : "";
+ const validStart = start && isValidIsoDateOnly(start) ? start : ''
+ const validEnd = end && isValidIsoDateOnly(end) ? end : ''
if (validStart && validEnd) {
- next.startDate = safeMin(validStart, validEnd);
- next.endDate = safeMax(validStart, validEnd);
+ next.startDate = safeMin(validStart, validEnd)
+ next.endDate = safeMax(validStart, validEnd)
} else {
- if (validStart) next.startDate = validStart;
- else delete next.startDate;
+ if (validStart) next.startDate = validStart
+ else delete next.startDate
- if (validEnd) next.endDate = validEnd;
- else delete next.endDate;
+ if (validEnd) next.endDate = validEnd
+ else delete next.endDate
}
- return next;
+ return next
}
-type MetricId = "downloads" | "likes" | "contributors";
-const DEFAULT_METRIC_ID: MetricId = "downloads";
+type MetricId = 'downloads' | 'likes' | 'contributors'
+const DEFAULT_METRIC_ID: MetricId = 'downloads'
type MetricContext = {
- packageName: string;
- repoRef?: RepoRef | null;
-};
+ packageName: string
+ repoRef?: RepoRef | null
+}
type MetricDef = {
- id: MetricId;
- label: string;
- fetch: (context: MetricContext, options: EvolutionOptions) => Promise;
- supportsMulti?: boolean;
-};
+ id: MetricId
+ label: string
+ fetch: (context: MetricContext, options: EvolutionOptions) => Promise
+ supportsMulti?: boolean
+}
const hasContributorsFacet = computed(() => {
if (isMultiPackageMode.value) {
- return Object.values(repoRefsByPackage.value).some((ref) => ref?.provider === "github");
+ return Object.values(repoRefsByPackage.value).some(ref => ref?.provider === 'github')
}
- const ref = props.repoRef;
- return ref?.provider === "github" && ref.owner && ref.repo;
-});
+ const ref = props.repoRef
+ return ref?.provider === 'github' && ref.owner && ref.repo
+})
const METRICS = computed(() => {
const metrics: MetricDef[] = [
{
- id: "downloads",
- label: $t("package.trends.items.downloads"),
+ id: 'downloads',
+ label: $t('package.trends.items.downloads'),
fetch: ({ packageName }, opts) =>
fetchPackageDownloadEvolution(
packageName,
@@ -656,84 +656,84 @@ const METRICS = computed(() => {
supportsMulti: true,
},
{
- id: "likes",
- label: $t("package.trends.items.likes"),
+ id: 'likes',
+ label: $t('package.trends.items.likes'),
fetch: ({ packageName }, opts) => fetchPackageLikesEvolution(packageName, opts),
supportsMulti: true,
},
- ];
+ ]
if (hasContributorsFacet.value) {
metrics.push({
- id: "contributors",
- label: $t("package.trends.items.contributors"),
+ id: 'contributors',
+ label: $t('package.trends.items.contributors'),
fetch: ({ repoRef }, opts) => fetchRepoContributorsEvolution(repoRef, opts),
supportsMulti: true,
- });
+ })
}
- return metrics;
-});
+ return metrics
+})
-const selectedMetric = usePermalink("facet", DEFAULT_METRIC_ID, {
+const selectedMetric = usePermalink('facet', DEFAULT_METRIC_ID, {
permanent: props.permalink,
-});
+})
const effectivePackageNamesForMetric = computed(() => {
- if (!isMultiPackageMode.value) return effectivePackageNames.value;
- if (selectedMetric.value !== "contributors") return effectivePackageNames.value;
+ if (!isMultiPackageMode.value) return effectivePackageNames.value
+ if (selectedMetric.value !== 'contributors') return effectivePackageNames.value
return effectivePackageNames.value.filter(
- (name) => repoRefsByPackage.value[name]?.provider === "github",
- );
-});
+ name => repoRefsByPackage.value[name]?.provider === 'github',
+ )
+})
const skippedPackagesWithoutGitHub = computed(() => {
- if (!isMultiPackageMode.value) return [];
- if (selectedMetric.value !== "contributors") return [];
- if (!effectivePackageNames.value.length) return [];
+ if (!isMultiPackageMode.value) return []
+ if (selectedMetric.value !== 'contributors') return []
+ if (!effectivePackageNames.value.length) return []
return effectivePackageNames.value.filter(
- (name) => repoRefsByPackage.value[name]?.provider !== "github",
- );
-});
+ name => repoRefsByPackage.value[name]?.provider !== 'github',
+ )
+})
const availableGranularities = computed(() => {
- if (selectedMetric.value === "contributors") {
- return ["weekly", "monthly", "yearly"];
+ if (selectedMetric.value === 'contributors') {
+ return ['weekly', 'monthly', 'yearly']
}
- return ["daily", "weekly", "monthly", "yearly"];
-});
+ return ['daily', 'weekly', 'monthly', 'yearly']
+})
watch(
() => [selectedMetric.value, availableGranularities.value] as const,
() => {
if (!availableGranularities.value.includes(selectedGranularity.value)) {
- selectedGranularity.value = "weekly";
+ selectedGranularity.value = 'weekly'
}
},
{ immediate: true },
-);
+)
watch(
() => METRICS.value,
- (metrics) => {
- if (!metrics.some((m) => m.id === selectedMetric.value)) {
- selectedMetric.value = DEFAULT_METRIC_ID;
+ metrics => {
+ if (!metrics.some(m => m.id === selectedMetric.value)) {
+ selectedMetric.value = DEFAULT_METRIC_ID
}
},
{ immediate: true },
-);
+)
// Per-metric state keyed by metric id
const metricStates = reactive<
Record<
MetricId,
{
- pending: boolean;
- evolution: EvolutionData;
- evolutionsByPackage: Record;
- requestToken: number;
+ pending: boolean
+ evolution: EvolutionData
+ evolutionsByPackage: Record
+ requestToken: number
}
>
>({
@@ -755,15 +755,15 @@ const metricStates = reactive<
evolutionsByPackage: {},
requestToken: 0,
},
-});
+})
-const activeMetricState = computed(() => metricStates[selectedMetric.value]);
+const activeMetricState = computed(() => metricStates[selectedMetric.value])
const activeMetricDef = computed(
- () => METRICS.value.find((m) => m.id === selectedMetric.value) ?? METRICS.value[0],
-);
-const pending = computed(() => activeMetricState.value.pending);
+ () => METRICS.value.find(m => m.id === selectedMetric.value) ?? METRICS.value[0],
+)
+const pending = computed(() => activeMetricState.value.pending)
-const isMounted = shallowRef(false);
+const isMounted = shallowRef(false)
// Watches granularity and date inputs to keep request options in sync and
// manage the loading state.
@@ -778,24 +778,24 @@ const isMounted = shallowRef(false);
watch(
[selectedGranularity, startDate, endDate],
([granularityValue]) => {
- if (granularityValue === "daily") options.value = applyDateRange({ granularity: "day" });
- else if (granularityValue === "weekly")
- options.value = applyDateRange({ granularity: "week", weeks: 52 });
- else if (granularityValue === "monthly")
- options.value = applyDateRange({ granularity: "month", months: 24 });
- else options.value = applyDateRange({ granularity: "year" });
+ if (granularityValue === 'daily') options.value = applyDateRange({ granularity: 'day' })
+ else if (granularityValue === 'weekly')
+ options.value = applyDateRange({ granularity: 'week', weeks: 52 })
+ else if (granularityValue === 'monthly')
+ options.value = applyDateRange({ granularity: 'month', months: 24 })
+ else options.value = applyDateRange({ granularity: 'year' })
// Do not set pending during initial setup
- if (!isMounted.value) return;
+ if (!isMounted.value) return
- const packageNames = effectivePackageNames.value;
+ const packageNames = effectivePackageNames.value
if (!import.meta.client || !packageNames.length) {
- activeMetricState.value.pending = false;
- return;
+ activeMetricState.value.pending = false
+ return
}
- const o = options.value;
- const hasExplicitRange = ("startDate" in o && o.startDate) || ("endDate" in o && o.endDate);
+ const o = options.value
+ const hasExplicitRange = ('startDate' in o && o.startDate) || ('endDate' in o && o.endDate)
// Do not show loading when weeklyDownloads is already provided
if (
@@ -805,14 +805,14 @@ watch(
props.weeklyDownloads?.length &&
!hasExplicitRange
) {
- activeMetricState.value.pending = false;
- return;
+ activeMetricState.value.pending = false
+ return
}
- activeMetricState.value.pending = true;
+ activeMetricState.value.pending = true
},
{ immediate: true },
-);
+)
/**
* Fetches evolution data for a given metric based on the current granularity,
@@ -826,86 +826,86 @@ watch(
* - manages the metric's `pending` loading state
*/
async function loadMetric(metricId: MetricId) {
- if (!import.meta.client) return;
+ if (!import.meta.client) return
- const state = metricStates[metricId];
- const metric = METRICS.value.find((m) => m.id === metricId)!;
- const currentToken = ++state.requestToken;
- state.pending = true;
+ const state = metricStates[metricId]
+ const metric = METRICS.value.find(m => m.id === metricId)!
+ const currentToken = ++state.requestToken
+ state.pending = true
- const fetchFn = (context: MetricContext) => metric.fetch(context, options.value);
+ const fetchFn = (context: MetricContext) => metric.fetch(context, options.value)
try {
- const packageNames = effectivePackageNamesForMetric.value;
+ const packageNames = effectivePackageNamesForMetric.value
if (!packageNames.length) {
- if (isMultiPackageMode.value) state.evolutionsByPackage = {};
- else state.evolution = [];
- displayedGranularity.value = selectedGranularity.value;
- return;
+ if (isMultiPackageMode.value) state.evolutionsByPackage = {}
+ else state.evolution = []
+ displayedGranularity.value = selectedGranularity.value
+ return
}
if (isMultiPackageMode.value) {
if (metric.supportsMulti === false) {
- state.evolutionsByPackage = {};
- displayedGranularity.value = selectedGranularity.value;
- return;
+ state.evolutionsByPackage = {}
+ displayedGranularity.value = selectedGranularity.value
+ return
}
const settled = await Promise.allSettled(
- packageNames.map(async (pkg) => {
- const repoRef = metricId === "contributors" ? repoRefsByPackage.value[pkg] : null;
- const result = await fetchFn({ packageName: pkg, repoRef });
- return { pkg, result: (result ?? []) as EvolutionData };
+ packageNames.map(async pkg => {
+ const repoRef = metricId === 'contributors' ? repoRefsByPackage.value[pkg] : null
+ const result = await fetchFn({ packageName: pkg, repoRef })
+ return { pkg, result: (result ?? []) as EvolutionData }
}),
- );
+ )
- if (currentToken !== state.requestToken) return;
+ if (currentToken !== state.requestToken) return
- const next: Record = {};
+ const next: Record = {}
for (const entry of settled) {
- if (entry.status === "fulfilled") next[entry.value.pkg] = entry.value.result;
+ if (entry.status === 'fulfilled') next[entry.value.pkg] = entry.value.result
}
- state.evolutionsByPackage = next;
- displayedGranularity.value = selectedGranularity.value;
- return;
+ state.evolutionsByPackage = next
+ displayedGranularity.value = selectedGranularity.value
+ return
}
- const pkg = packageNames[0] ?? "";
+ const pkg = packageNames[0] ?? ''
if (!pkg) {
- state.evolution = [];
- displayedGranularity.value = selectedGranularity.value;
- return;
+ state.evolution = []
+ displayedGranularity.value = selectedGranularity.value
+ return
}
// In single-package mode the parent already fetches weekly downloads for the
// sparkline (WeeklyDownloadStats). When the user hasn't customised the date
// range we can reuse that prop directly and skip a redundant API call.
if (metricId === DEFAULT_METRIC_ID) {
- const o = options.value;
- const hasExplicitRange = ("startDate" in o && o.startDate) || ("endDate" in o && o.endDate);
+ const o = options.value
+ const hasExplicitRange = ('startDate' in o && o.startDate) || ('endDate' in o && o.endDate)
if (
selectedGranularity.value === DEFAULT_GRANULARITY &&
props.weeklyDownloads?.length &&
!hasExplicitRange
) {
- state.evolution = props.weeklyDownloads;
- displayedGranularity.value = DEFAULT_GRANULARITY;
- return;
+ state.evolution = props.weeklyDownloads
+ displayedGranularity.value = DEFAULT_GRANULARITY
+ return
}
}
- const result = await fetchFn({ packageName: pkg, repoRef: props.repoRef });
- if (currentToken !== state.requestToken) return;
+ const result = await fetchFn({ packageName: pkg, repoRef: props.repoRef })
+ if (currentToken !== state.requestToken) return
- state.evolution = (result ?? []) as EvolutionData;
- displayedGranularity.value = selectedGranularity.value;
+ state.evolution = (result ?? []) as EvolutionData
+ displayedGranularity.value = selectedGranularity.value
} catch {
- if (currentToken !== state.requestToken) return;
- if (isMultiPackageMode.value) state.evolutionsByPackage = {};
- else state.evolution = [];
+ if (currentToken !== state.requestToken) return
+ if (isMultiPackageMode.value) state.evolutionsByPackage = {}
+ else state.evolution = []
} finally {
- if (currentToken === state.requestToken) state.pending = false;
+ if (currentToken === state.requestToken) state.pending = false
}
}
@@ -917,53 +917,53 @@ async function loadMetric(metricId: MetricId) {
// - prevents unnecessary API load and visual flicker of the loading state
//
const debouncedLoadNow = useDebounceFn(() => {
- loadMetric(selectedMetric.value);
-}, 1000);
+ loadMetric(selectedMetric.value)
+}, 1000)
const fetchTriggerKey = computed(() => {
- const names = effectivePackageNames.value.join(",");
- const o = options.value;
+ const names = effectivePackageNames.value.join(',')
+ const o = options.value
const repoKey = props.repoRef
? `${props.repoRef.provider}:${props.repoRef.owner}/${props.repoRef.repo}`
- : "";
+ : ''
return [
- isMultiPackageMode.value ? "M" : "S",
+ isMultiPackageMode.value ? 'M' : 'S',
names,
repoKey,
- String(props.createdIso ?? ""),
- String(o.granularity ?? ""),
- String("weeks" in o ? (o.weeks ?? "") : ""),
- String("months" in o ? (o.months ?? "") : ""),
- String("startDate" in o ? (o.startDate ?? "") : ""),
- String("endDate" in o ? (o.endDate ?? "") : ""),
- ].join("|");
-});
+ String(props.createdIso ?? ''),
+ String(o.granularity ?? ''),
+ String('weeks' in o ? (o.weeks ?? '') : ''),
+ String('months' in o ? (o.months ?? '') : ''),
+ String('startDate' in o ? (o.startDate ?? '') : ''),
+ String('endDate' in o ? (o.endDate ?? '') : ''),
+ ].join('|')
+})
watch(
() => fetchTriggerKey.value,
() => {
- if (!import.meta.client) return;
- if (!isMounted.value) return;
- debouncedLoadNow();
+ if (!import.meta.client) return
+ if (!isMounted.value) return
+ debouncedLoadNow()
},
- { flush: "post" },
-);
+ { flush: 'post' },
+)
watch(
() => repoRefsByPackage.value,
() => {
- if (!import.meta.client) return;
- if (!isMounted.value) return;
- if (!isMultiPackageMode.value) return;
- if (selectedMetric.value !== "contributors") return;
- debouncedLoadNow();
+ if (!import.meta.client) return
+ if (!isMounted.value) return
+ if (!isMultiPackageMode.value) return
+ if (selectedMetric.value !== 'contributors') return
+ debouncedLoadNow()
},
{ deep: true },
-);
+)
const effectiveDataSingle = computed(() => {
- const state = activeMetricState.value;
- let data: EvolutionData;
+ const state = activeMetricState.value
+ let data: EvolutionData
if (
selectedMetric.value === DEFAULT_METRIC_ID &&
displayedGranularity.value === DEFAULT_GRANULARITY &&
@@ -972,24 +972,24 @@ const effectiveDataSingle = computed(() => {
data =
isWeeklyDataset(state.evolution) && state.evolution.length
? state.evolution
- : props.weeklyDownloads;
+ : props.weeklyDownloads
} else {
- data = state.evolution;
+ data = state.evolution
}
if (isDownloadsMetric.value && data.length) {
- const pkg = effectivePackageNames.value[0] ?? props.packageName ?? "";
+ const pkg = effectivePackageNames.value[0] ?? props.packageName ?? ''
if (localSettings.value.chartFilter.anomaliesFixed) {
data = applyBlocklistCorrection({
data,
packageName: pkg,
granularity: displayedGranularity.value,
- });
+ })
}
}
- return data;
-});
+ return data
+})
/**
* Normalized chart data derived from the active metric's evolution datasets.
@@ -1006,154 +1006,154 @@ const effectiveDataSingle = computed(() => {
* the template to handle empty states without ambiguity.
*/
const chartData = computed<{
- dataset: VueUiXyDatasetItem[] | null;
- dates: number[];
+ dataset: VueUiXyDatasetItem[] | null
+ dates: number[]
}>(() => {
if (!isMultiPackageMode.value) {
- const pkg = effectivePackageNames.value[0] ?? props.packageName ?? "";
- return formatXyDataset(displayedGranularity.value, effectiveDataSingle.value, pkg);
+ const pkg = effectivePackageNames.value[0] ?? props.packageName ?? ''
+ return formatXyDataset(displayedGranularity.value, effectiveDataSingle.value, pkg)
}
- const state = activeMetricState.value;
- const names = effectivePackageNamesForMetric.value;
- const granularity = displayedGranularity.value;
+ const state = activeMetricState.value
+ const names = effectivePackageNamesForMetric.value
+ const granularity = displayedGranularity.value
- const timestampSet = new Set();
+ const timestampSet = new Set()
const pointsByPackage = new Map<
string,
Array<{ timestamp: number; value: number; hasAnomaly?: boolean }>
- >();
+ >()
for (const pkg of names) {
- let data = state.evolutionsByPackage[pkg] ?? [];
+ let data = state.evolutionsByPackage[pkg] ?? []
if (isDownloadsMetric.value && data.length) {
if (localSettings.value.chartFilter.anomaliesFixed) {
- data = applyBlocklistCorrection({ data, packageName: pkg, granularity });
+ data = applyBlocklistCorrection({ data, packageName: pkg, granularity })
}
}
- const points = extractSeriesPoints(granularity, data);
+ const points = extractSeriesPoints(granularity, data)
- pointsByPackage.set(pkg, points);
- for (const p of points) timestampSet.add(p.timestamp);
+ pointsByPackage.set(pkg, points)
+ for (const p of points) timestampSet.add(p.timestamp)
}
- const dates = Array.from(timestampSet).sort((a, b) => a - b);
- if (!dates.length) return { dataset: null, dates: [] };
+ const dates = Array.from(timestampSet).sort((a, b) => a - b)
+ if (!dates.length) return { dataset: null, dates: [] }
- const dataset: VueUiXyDatasetItem[] = names.map((pkg) => {
- const points = pointsByPackage.get(pkg) ?? [];
- const valueByTimestamp = new Map();
- const anomalyTimestamps = new Set();
+ const dataset: VueUiXyDatasetItem[] = names.map(pkg => {
+ const points = pointsByPackage.get(pkg) ?? []
+ const valueByTimestamp = new Map()
+ const anomalyTimestamps = new Set()
for (const p of points) {
- valueByTimestamp.set(p.timestamp, p.value);
- if (p.hasAnomaly) anomalyTimestamps.add(p.timestamp);
+ valueByTimestamp.set(p.timestamp, p.value)
+ if (p.hasAnomaly) anomalyTimestamps.add(p.timestamp)
}
- const series = dates.map((t) => valueByTimestamp.get(t) ?? 0);
+ const series = dates.map(t => valueByTimestamp.get(t) ?? 0)
const dashIndices = dates
.map((t, index) => (anomalyTimestamps.has(t) ? index : -1))
- .filter((index) => index !== -1);
+ .filter(index => index !== -1)
const item: VueUiXyDatasetItem = {
name: applyEllipsis(pkg, 32),
- type: "line",
+ type: 'line',
series,
dashIndices,
- } as VueUiXyDatasetItem;
+ } as VueUiXyDatasetItem
if (isListedFramework(pkg)) {
- item.color = getFrameworkColor(pkg);
+ item.color = getFrameworkColor(pkg)
}
- return item;
- });
+ return item
+ })
- return { dataset, dates };
-});
+ return { dataset, dates }
+})
const normalisedDataset = computed(() => {
- const granularity = displayedGranularity.value;
- const endDateMs = endDate.value ? endDateOnlyToUtcMs(endDate.value) : null;
- const referenceMs = endDateMs ?? Date.now();
- const lastDateMs = chartData.value.dates.at(-1) ?? 0;
- const isAbsoluteMetric = selectedMetric.value === "contributors";
+ const granularity = displayedGranularity.value
+ const endDateMs = endDate.value ? endDateOnlyToUtcMs(endDate.value) : null
+ const referenceMs = endDateMs ?? Date.now()
+ const lastDateMs = chartData.value.dates.at(-1) ?? 0
+ const isAbsoluteMetric = selectedMetric.value === 'contributors'
- return chartData.value.dataset?.map((d) => {
+ return chartData.value.dataset?.map(d => {
const series = applyDataPipeline(
- d.series.map((v) => v ?? 0),
+ d.series.map(v => v ?? 0),
{
averageWindow: localSettings.value.chartFilter.averageWindow,
smoothingTau: localSettings.value.chartFilter.smoothingTau,
predictionPoints:
- granularity === "weekly"
+ granularity === 'weekly'
? 0 // weekly buckets are end-aligned → always complete, no prediction needed
: (localSettings.value.chartFilter.predictionPoints ?? DEFAULT_PREDICTION_POINTS),
},
{ granularity, lastDateMs, referenceMs, isAbsoluteMetric },
- );
+ )
return {
...d,
series,
dashIndices: d.dashIndices ?? [],
- };
- });
-});
+ }
+ })
+})
const maxDatapoints = computed(() =>
- Math.max(0, ...(chartData.value.dataset ?? []).map((d) => d.series.length)),
-);
+ Math.max(0, ...(chartData.value.dataset ?? []).map(d => d.series.length)),
+)
const datetimeFormatterOptions = computed(() => {
return {
- daily: { year: "yyyy-MM-dd", month: "yyyy-MM-dd", day: "yyyy-MM-dd" },
- weekly: { year: "yyyy-MM-dd", month: "yyyy-MM-dd", day: "yyyy-MM-dd" },
- monthly: { year: "MMM yyyy", month: "MMM yyyy", day: "MMM yyyy" },
- yearly: { year: "yyyy", month: "yyyy", day: "yyyy" },
- }[selectedGranularity.value];
-});
+ daily: { year: 'yyyy-MM-dd', month: 'yyyy-MM-dd', day: 'yyyy-MM-dd' },
+ weekly: { year: 'yyyy-MM-dd', month: 'yyyy-MM-dd', day: 'yyyy-MM-dd' },
+ monthly: { year: 'MMM yyyy', month: 'MMM yyyy', day: 'MMM yyyy' },
+ yearly: { year: 'yyyy', month: 'yyyy', day: 'yyyy' },
+ }[selectedGranularity.value]
+})
// Cached date formatter for tooltip
const tooltipDateFormatter = computed(() => {
- const granularity = displayedGranularity.value;
+ const granularity = displayedGranularity.value
return new Intl.DateTimeFormat(locale.value, {
- year: "numeric",
- month: granularity === "yearly" ? undefined : "short",
- day: granularity === "daily" || granularity === "weekly" ? "numeric" : undefined,
- timeZone: "UTC",
- });
-});
+ year: 'numeric',
+ month: granularity === 'yearly' ? undefined : 'short',
+ day: granularity === 'daily' || granularity === 'weekly' ? 'numeric' : undefined,
+ timeZone: 'UTC',
+ })
+})
function buildExportFilename(extension: string): string {
- const g = selectedGranularity.value;
- const range = `${startDate.value}_${endDate.value}`;
+ const g = selectedGranularity.value
+ const range = `${startDate.value}_${endDate.value}`
if (!isMultiPackageMode.value) {
- const name = effectivePackageNames.value[0] ?? props.packageName ?? "package";
- return `${sanitise(applyEllipsis(name, 32))}-${g}_${range}.${extension}`;
+ const name = effectivePackageNames.value[0] ?? props.packageName ?? 'package'
+ return `${sanitise(applyEllipsis(name, 32))}-${g}_${range}.${extension}`
}
- const names = effectivePackageNames.value.map((name) => applyEllipsis(name, 32));
- const label = names.length === 1 ? names[0] : names.join("_");
- return `${sanitise(label ?? "")}-${g}_${range}.${extension}`;
+ const names = effectivePackageNames.value.map(name => applyEllipsis(name, 32))
+ const label = names.length === 1 ? names[0] : names.join('_')
+ return `${sanitise(label ?? '')}-${g}_${range}.${extension}`
}
const granularityLabels = computed(() => ({
- daily: $t("package.trends.granularity_daily"),
- weekly: $t("package.trends.granularity_weekly"),
- monthly: $t("package.trends.granularity_monthly"),
- yearly: $t("package.trends.granularity_yearly"),
-}));
+ daily: $t('package.trends.granularity_daily'),
+ weekly: $t('package.trends.granularity_weekly'),
+ monthly: $t('package.trends.granularity_monthly'),
+ yearly: $t('package.trends.granularity_yearly'),
+}))
function getGranularityLabel(granularity: ChartTimeGranularity) {
- return granularityLabels.value[granularity];
+ return granularityLabels.value[granularity]
}
const granularityItems = computed(() =>
- availableGranularities.value.map((granularity) => ({
+ availableGranularities.value.map(granularity => ({
label: granularityLabels.value[granularity],
value: granularity,
})),
-);
+)
/**
* Build and return svg markup for estimation overlays on the chart.
@@ -1170,23 +1170,23 @@ const granularityItems = computed(() =>
* when no estimation overlay should be rendered.
*/
function drawEstimationLine(svg: Record) {
- if (!shouldRenderEstimationOverlay.value) return "";
+ if (!shouldRenderEstimationOverlay.value) return ''
- const data = Array.isArray(svg?.data) ? svg.data : [];
- if (!data.length) return "";
+ const data = Array.isArray(svg?.data) ? svg.data : []
+ if (!data.length) return ''
// Collect per-series estimates and a global max candidate for the y-axis
- const lines: string[] = [];
+ const lines: string[] = []
for (const serie of data) {
- const plots = serie?.plots;
- if (!Array.isArray(plots) || plots.length < 2) continue;
+ const plots = serie?.plots
+ if (!Array.isArray(plots) || plots.length < 2) continue
- const previousPoint = plots.at(-2);
- const lastPoint = plots.at(-1);
- if (!previousPoint || !lastPoint) continue;
+ const previousPoint = plots.at(-2)
+ const lastPoint = plots.at(-1)
+ if (!previousPoint || !lastPoint) continue
- const stroke = String(serie?.color ?? colors.value.fg);
+ const stroke = String(serie?.color ?? colors.value.fg)
/**
* The following svg elements are injected in the #svg slot of VueUiXy:
@@ -1223,12 +1223,12 @@ function drawEstimationLine(svg: Record) {
stroke="${colors.value.bg}"
stroke-width="2"
/>
- `);
+ `)
}
- if (!lines.length) return "";
+ if (!lines.length) return ''
- return lines.join("\n");
+ return lines.join('\n')
}
/**
@@ -1250,13 +1250,13 @@ function drawEstimationLine(svg: Record) {
* no labels should be rendered.
*/
function drawLastDatapointLabel(svg: Record) {
- const data = Array.isArray(svg?.data) ? svg.data : [];
- if (!data.length) return "";
+ const data = Array.isArray(svg?.data) ? svg.data : []
+ if (!data.length) return ''
- const dataLabels: string[] = [];
+ const dataLabels: string[] = []
for (const serie of data) {
- const lastPlot = serie.plots.at(-1);
+ const lastPlot = serie.plots.at(-1)
dataLabels.push(`
) {
>
${compactNumberFormatter.value.format(Number.isFinite(lastPlot.value) ? lastPlot.value : 0)}
- `);
+ `)
}
- return dataLabels.join("\n");
+ return dataLabels.join('\n')
}
/**
@@ -1285,10 +1285,10 @@ function drawLastDatapointLabel(svg: Record) {
* Legend items are displayed in a column, on the top left of the chart.
*/
function drawSvgPrintLegend(svg: Record) {
- const data = Array.isArray(svg?.data) ? svg.data : [];
- if (!data.length) return "";
+ const data = Array.isArray(svg?.data) ? svg.data : []
+ if (!data.length) return ''
- const seriesNames: string[] = [];
+ const seriesNames: string[] = []
data.forEach((serie, index) => {
seriesNames.push(`
@@ -1313,8 +1313,8 @@ function drawSvgPrintLegend(svg: Record) {
>
${serie.name}
- `);
- });
+ `)
+ })
// Inject the estimation legend item when necessary
if (
@@ -1342,35 +1342,35 @@ function drawSvgPrintLegend(svg: Record) {
stroke-width="1"
paint-order="stroke fill"
>
- ${$t("package.trends.legend_estimation")}
+ ${$t('package.trends.legend_estimation')}
- `);
+ `)
}
- return seriesNames.join("\n");
+ return seriesNames.join('\n')
}
-const showCorrectionControls = shallowRef(false);
-const isResizing = shallowRef(false);
+const showCorrectionControls = shallowRef(false)
+const isResizing = shallowRef(false)
const chartHeight = computed(() => {
if (isMobile.value) {
- return 950;
+ return 950
}
- return showCorrectionControls.value && props.inModal ? 494 : 600;
-});
+ return showCorrectionControls.value && props.inModal ? 494 : 600
+})
const { start } = useTimeoutFn(
() => {
- isResizing.value = false;
+ isResizing.value = false
},
200,
{ immediate: false },
-);
+)
function pauseChartTransitions() {
- isResizing.value = true;
- start();
+ isResizing.value = true
+ start()
}
watch(
@@ -1378,29 +1378,29 @@ watch(
(newH, oldH) => {
if (newH !== oldH) {
// Avoids triggering chart line transitions when the chart is resized
- pauseChartTransitions();
+ pauseChartTransitions()
}
},
{ immediate: true },
-);
+)
// VueUiXy chart component configuration
const chartConfig = computed(() => {
return {
- theme: isDarkMode.value ? "dark" : ("" as VueDataUiTheme),
+ theme: isDarkMode.value ? 'dark' : ('' as VueDataUiTheme),
a11y: {
translations: {
keyboardNavigation: $t(
- "package.trends.chart_assistive_text.keyboard_navigation_horizontal",
+ 'package.trends.chart_assistive_text.keyboard_navigation_horizontal',
),
- tableAvailable: $t("package.trends.chart_assistive_text.table_available"),
- tableCaption: $t("package.trends.chart_assistive_text.table_caption"),
+ tableAvailable: $t('package.trends.chart_assistive_text.table_available'),
+ tableCaption: $t('package.trends.chart_assistive_text.table_caption'),
},
},
chart: {
height: chartHeight.value,
backgroundColor: colors.value.bg,
- padding: { bottom: displayedGranularity.value === "yearly" ? 84 : 64, right: 128 }, // padding right is set to leave space of last datapoint label(s)
+ padding: { bottom: displayedGranularity.value === 'yearly' ? 84 : 64, right: 128 }, // padding right is set to leave space of last datapoint label(s)
userOptions: {
buttons: {
pdf: false,
@@ -1411,53 +1411,53 @@ const chartConfig = computed(() => {
altCopy: true,
},
buttonTitles: {
- csv: $t("package.trends.download_file", { fileType: "CSV" }),
- img: $t("package.trends.download_file", { fileType: "PNG" }),
- svg: $t("package.trends.download_file", { fileType: "SVG" }),
- annotator: $t("package.trends.toggle_annotator"),
- stack: $t("package.trends.toggle_stack_mode"),
- altCopy: $t("package.trends.copy_alt.button_label"), // Do not make this text dependant on the `copied` variable, since this would re-render the component, which is undesirable if the minimap was used to select a time frame.
- open: $t("package.trends.open_options"),
- close: $t("package.trends.close_options"),
+ csv: $t('package.trends.download_file', { fileType: 'CSV' }),
+ img: $t('package.trends.download_file', { fileType: 'PNG' }),
+ svg: $t('package.trends.download_file', { fileType: 'SVG' }),
+ annotator: $t('package.trends.toggle_annotator'),
+ stack: $t('package.trends.toggle_stack_mode'),
+ altCopy: $t('package.trends.copy_alt.button_label'), // Do not make this text dependant on the `copied` variable, since this would re-render the component, which is undesirable if the minimap was used to select a time frame.
+ open: $t('package.trends.open_options'),
+ close: $t('package.trends.close_options'),
},
callbacks: {
- img: (args) => {
- const imageUri = args?.imageUri;
- if (!imageUri) return;
- loadFile(imageUri, buildExportFilename("png"));
+ img: args => {
+ const imageUri = args?.imageUri
+ if (!imageUri) return
+ loadFile(imageUri, buildExportFilename('png'))
},
- csv: (csvStr) => {
- if (!csvStr) return;
- const PLACEHOLDER_CHAR = "\0";
- const multilineDateTemplate = $t("package.trends.date_range_multiline", {
+ csv: csvStr => {
+ if (!csvStr) return
+ const PLACEHOLDER_CHAR = '\0'
+ const multilineDateTemplate = $t('package.trends.date_range_multiline', {
start: PLACEHOLDER_CHAR,
end: PLACEHOLDER_CHAR,
})
- .replaceAll(PLACEHOLDER_CHAR, "")
- .trim();
+ .replaceAll(PLACEHOLDER_CHAR, '')
+ .trim()
const blob = new Blob([
csvStr
- .replace("data:text/csv;charset=utf-8,", "")
+ .replace('data:text/csv;charset=utf-8,', '')
.replaceAll(`\n${multilineDateTemplate}`, ` ${multilineDateTemplate}`),
- ]);
- const url = URL.createObjectURL(blob);
- loadFile(url, buildExportFilename("csv"));
- URL.revokeObjectURL(url);
+ ])
+ const url = URL.createObjectURL(blob)
+ loadFile(url, buildExportFilename('csv'))
+ URL.revokeObjectURL(url)
},
- svg: (args) => {
- const blob = args?.blob;
- if (!blob) return;
- const url = URL.createObjectURL(blob);
- loadFile(url, buildExportFilename("svg"));
- URL.revokeObjectURL(url);
+ svg: args => {
+ const blob = args?.blob
+ if (!blob) return
+ const url = URL.createObjectURL(blob)
+ loadFile(url, buildExportFilename('svg'))
+ URL.revokeObjectURL(url)
},
altCopy: ({ dataset: dst, config: cfg }) =>
copyAltTextForTrendLineChart({
dataset: dst,
config: {
...cfg,
- formattedDatasetValues: (dst?.lines || []).map((d) =>
- d.series.map((n) => compactNumberFormatter.value.format(n ?? 0)),
+ formattedDatasetValues: (dst?.lines || []).map(d =>
+ d.series.map(n => compactNumberFormatter.value.format(n ?? 0)),
),
hasEstimation:
supportsEstimation.value && !isEndDateOnPeriodEnd.value && !isZoomed.value,
@@ -1476,7 +1476,7 @@ const chartConfig = computed(() => {
fontSize: isMobile.value ? 24 : 16,
color: pending.value ? colors.value.border : colors.value.fgSubtle,
axis: {
- yLabel: $t("package.trends.y_axis_label", {
+ yLabel: $t('package.trends.y_axis_label', {
granularity: getGranularityLabel(selectedGranularity.value),
facet: activeMetricDef.value?.label,
}),
@@ -1497,7 +1497,7 @@ const chartConfig = computed(() => {
},
yAxis: {
formatter: ({ value }: { value: number }) => {
- return compactNumberFormatter.value.format(Number.isFinite(value) ? value : 0);
+ return compactNumberFormatter.value.format(Number.isFinite(value) ? value : 0)
},
useNiceScale: true, // daily/weekly -> true, monthly/yearly -> false
gap: 24, // vertical gap between individual series in stacked mode
@@ -1511,43 +1511,43 @@ const chartConfig = computed(() => {
fontSize: 16,
circleMarker: { radius: 3, color: colors.value.border },
useDefaultFormat: true,
- timeFormat: "yyyy-MM-dd HH:mm:ss",
+ timeFormat: 'yyyy-MM-dd HH:mm:ss',
},
highlighter: { useLine: true },
- legend: { show: false, position: "top" },
+ legend: { show: false, position: 'top' },
tooltip: {
- teleportTo: props.inModal ? "#chart-modal" : undefined,
- borderColor: "transparent",
+ teleportTo: props.inModal ? '#chart-modal' : undefined,
+ borderColor: 'transparent',
backdropFilter: false,
- backgroundColor: "transparent",
+ backgroundColor: 'transparent',
customFormat: ({ datapoint: items, absoluteIndex }) => {
- if (!items || pending.value) return "";
+ if (!items || pending.value) return ''
- const hasMultipleItems = items.length > 1;
+ const hasMultipleItems = items.length > 1
// Format date for multiple series datasets
- let formattedDate = "";
+ let formattedDate = ''
if (hasMultipleItems && absoluteIndex !== undefined) {
- const index = Number(absoluteIndex);
+ const index = Number(absoluteIndex)
if (Number.isInteger(index) && index >= 0 && index < chartData.value.dates.length) {
- const timestamp = chartData.value.dates[index];
- if (typeof timestamp === "number") {
- formattedDate = tooltipDateFormatter.value.format(new Date(timestamp));
+ const timestamp = chartData.value.dates[index]
+ if (typeof timestamp === 'number') {
+ formattedDate = tooltipDateFormatter.value.format(new Date(timestamp))
}
}
}
const rows = items
.map((d: Record) => {
- const label = String(d?.name ?? "").trim();
- const raw = Number(d?.value ?? 0);
- const v = compactNumberFormatter.value.format(Number.isFinite(raw) ? raw : 0);
+ const label = String(d?.name ?? '').trim()
+ const raw = Number(d?.value ?? 0)
+ const v = compactNumberFormatter.value.format(Number.isFinite(raw) ? raw : 0)
if (!hasMultipleItems) {
// We don't need the name of the package in this case, since it is shown in the xAxis label
return `
${v}
-
`;
+ `
}
return `
@@ -1564,16 +1564,16 @@ const chartConfig = computed(() => {
${v}
-
`;
+
`
})
- .join("");
+ .join('')
return `
- ${formattedDate ? `
${formattedDate}
` : ""}
-
+ ${formattedDate ? `
${formattedDate}
` : ''}
+
${rows}
-
`;
+
`
},
},
zoom: {
@@ -1582,13 +1582,13 @@ const chartConfig = computed(() => {
useResetSlot: true,
minimap: {
show: true,
- lineColor: "#FAFAFA",
+ lineColor: '#FAFAFA',
selectedColor: accent.value,
selectedColorOpacity: 0.06,
frameColor: colors.value.border,
handleWidth: isMobile.value ? 40 : 20, // does not affect the size of the touch area
handleBorderColor: colors.value.fgSubtle,
- handleType: "grab", // 'empty' | 'chevron' | 'arrow' | 'grab'
+ handleType: 'grab', // 'empty' | 'chevron' | 'arrow' | 'grab'
},
preview: {
fill: transparentizeOklch(accent.value, isDarkMode.value ? 0.95 : 0.92),
@@ -1598,39 +1598,39 @@ const chartConfig = computed(() => {
},
},
},
- };
-});
+ }
+})
-const isDownloadsMetric = computed(() => selectedMetric.value === "downloads");
+const isDownloadsMetric = computed(() => selectedMetric.value === 'downloads')
-const packageAnomalies = computed(() => getAnomaliesForPackages(effectivePackageNames.value));
-const hasAnomalies = computed(() => packageAnomalies.value.length > 0);
+const packageAnomalies = computed(() => getAnomaliesForPackages(effectivePackageNames.value))
+const hasAnomalies = computed(() => packageAnomalies.value.length > 0)
function formatAnomalyDate(dateStr: string) {
- const [y, m, d] = dateStr.split("-").map(Number);
- if (!y || !m || !d) return dateStr;
+ const [y, m, d] = dateStr.split('-').map(Number)
+ if (!y || !m || !d) return dateStr
return new Intl.DateTimeFormat(locale.value, {
- year: "numeric",
- month: "short",
- day: "numeric",
- timeZone: "UTC",
- }).format(new Date(Date.UTC(y, m - 1, d)));
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ timeZone: 'UTC',
+ }).format(new Date(Date.UTC(y, m - 1, d)))
}
// Trigger data loading when the metric is switched
-watch(selectedMetric, (value) => {
- if (!isMounted.value) return;
- loadMetric(value);
-});
+watch(selectedMetric, value => {
+ if (!isMounted.value) return
+ loadMetric(value)
+})
// Sparkline charts (a11y alternative display for multi series)
-const chartLayout = usePermalink<"combined" | "split">("layout", "combined");
+const chartLayout = usePermalink<'combined' | 'split'>('layout', 'combined')
const isSparklineLayout = computed({
- get: () => chartLayout.value === "split",
+ get: () => chartLayout.value === 'split',
set: (v: boolean) => {
- chartLayout.value = v ? "split" : "combined";
+ chartLayout.value = v ? 'split' : 'combined'
},
-});
+})
@@ -1647,14 +1647,14 @@ const isSparklineLayout = computed({
>
- {{ $t("package.trends.chart_view_combined") }}
+ {{ $t('package.trends.chart_view_combined') }}
- {{ $t("package.trends.chart_view_split") }}
+ {{ $t('package.trends.chart_view_split') }}
@@ -1666,7 +1666,7 @@ const isSparklineLayout = computed({
id="trends-metric-select"
v-model="selectedMetric"
:disabled="activeMetricState.pending"
- :items="METRICS.map((m) => ({ label: m.label, value: m.id }))"
+ :items="METRICS.map(m => ({ label: m.label, value: m.id }))"
:label="$t('package.trends.facet')"
block
/>
@@ -1686,7 +1686,7 @@ const isSparklineLayout = computed({
for="startDate"
class="text-2xs font-mono text-fg-subtle tracking-wide uppercase"
>
- {{ $t("package.trends.start_date") }}
+ {{ $t('package.trends.start_date') }}
- {{ $t("package.trends.end_date") }}
+ {{ $t('package.trends.end_date') }}
- {{ $t("package.trends.data_correction") }}
+ {{ $t('package.trends.data_correction') }}
- {{ $t("package.trends.average_window") }}
+ {{ $t('package.trends.average_window') }}
({{ localSettings.chartFilter.averageWindow }})
- {{ $t("package.trends.smoothing") }}
+ {{ $t('package.trends.smoothing') }}
({{ localSettings.chartFilter.smoothingTau }})
- {{ $t("package.trends.prediction") }}
+ {{ $t('package.trends.prediction') }}
({{ localSettings.chartFilter.predictionPoints }})
@@ -1806,7 +1806,7 @@ const isSparklineLayout = computed({
- {{ $t("package.trends.known_anomalies") }}
+ {{ $t('package.trends.known_anomalies') }}
- {{ $t("package.trends.known_anomalies_description") }}
+ {{ $t('package.trends.known_anomalies_description') }}
- {{ $t("package.trends.known_anomalies_ranges") }}
+ {{ $t('package.trends.known_anomalies_ranges') }}
-
{{
isMultiPackageMode
- ? $t("package.trends.known_anomalies_range_named", {
+ ? $t('package.trends.known_anomalies_range_named', {
packageName: a.packageName,
start: formatAnomalyDate(a.start),
end: formatAnomalyDate(a.end),
})
- : $t("package.trends.known_anomalies_range", {
+ : $t('package.trends.known_anomalies_range', {
start: formatAnomalyDate(a.start),
end: formatAnomalyDate(a.end),
})
@@ -1845,7 +1845,7 @@ const isSparklineLayout = computed({
{{
- $t("package.trends.known_anomalies_none", effectivePackageNames.length)
+ $t('package.trends.known_anomalies_none', effectivePackageNames.length)
}}
@@ -1853,7 +1853,7 @@ const isSparklineLayout = computed({
to="https://github.com/npmx-dev/npmx.dev/edit/main/app/utils/download-anomalies.data.ts"
class="text-xs text-accent"
>
- {{ $t("package.trends.known_anomalies_contribute") }}
+ {{ $t('package.trends.known_anomalies_contribute') }}
@@ -1875,7 +1875,7 @@ const isSparklineLayout = computed({
type="checkbox"
class="accent-[var(--accent-color,var(--fg-subtle))]"
/>
- {{ $t("package.trends.apply_correction") }}
+ {{ $t('package.trends.apply_correction') }}
@@ -1884,13 +1884,13 @@ const isSparklineLayout = computed({
- {{ $t("package.trends.contributors_skip", { count: skippedPackagesWithoutGitHub.length }) }}
- {{ skippedPackagesWithoutGitHub.join(", ") }}
+ {{ $t('package.trends.contributors_skip', { count: skippedPackagesWithoutGitHub.length }) }}
+ {{ skippedPackagesWithoutGitHub.join(', ') }}
- {{ $t("package.trends.title") }} — {{ activeMetricDef?.label }}
+ {{ $t('package.trends.title') }} — {{ activeMetricDef?.label }}
@@ -1944,7 +1944,7 @@ const isSparklineLayout = computed({
- {{ $t("compare.packages.line_chart_nav_hint") }}
+ {{ $t('compare.packages.line_chart_nav_hint') }}
@@ -2052,7 +2052,7 @@ const isSparklineLayout = computed({
stroke-linecap="round"
/>
- {{ $t("package.trends.legend_estimation") }}
+ {{ $t('package.trends.legend_estimation') }}
@@ -2185,7 +2185,7 @@ const isSparklineLayout = computed({
v-if="!chartData.dataset && !activeMetricState.pending"
class="min-h-[260px] flex items-center justify-center text-fg-subtle font-mono text-sm"
>
- {{ $t("package.trends.no_data") }}
+ {{ $t('package.trends.no_data') }}
@@ -2195,7 +2195,7 @@ const isSparklineLayout = computed({
aria-live="polite"
class="absolute top-1/2 inset-is-1/2 -translate-x-1/2 -translate-y-1/2 text-xs text-fg-subtle font-mono bg-bg/70 backdrop-blur px-3 py-2 rounded-md border border-border"
>
- {{ $t("package.trends.loading") }}
+ {{ $t('package.trends.loading') }}
@@ -2236,11 +2236,11 @@ const isSparklineLayout = computed({
}
}
-[data-pending="true"] .vue-data-ui-zoom {
+[data-pending='true'] .vue-data-ui-zoom {
opacity: 0.1;
}
-[data-pending="true"] .vue-data-ui-time-label {
+[data-pending='true'] .vue-data-ui-time-label {
opacity: 0;
}
@@ -2249,7 +2249,7 @@ const isSparklineLayout = computed({
top: unset !important;
}
-[data-minimap-visible="false"] .vue-data-ui-watermark {
+[data-minimap-visible='false'] .vue-data-ui-watermark {
top: calc(100% - 2rem) !important;
}
diff --git a/app/components/Package/VersionDistribution.vue b/app/components/Package/VersionDistribution.vue
index e104851546..b99d36d249 100644
--- a/app/components/Package/VersionDistribution.vue
+++ b/app/components/Package/VersionDistribution.vue
@@ -21,7 +21,7 @@ const props = defineProps<{
const { accentColors, selectedAccentColor } = useAccentColor()
const { copy, copied } = useClipboard()
-const colorMode = useColorMode()
+const { colorMode } = useColorModePreference()
const resolvedMode = shallowRef<'light' | 'dark'>('light')
const rootEl = shallowRef(null)
diff --git a/app/components/Package/WeeklyDownloadStats.vue b/app/components/Package/WeeklyDownloadStats.vue
index cc8aec64f2..9c86235813 100644
--- a/app/components/Package/WeeklyDownloadStats.vue
+++ b/app/components/Package/WeeklyDownloadStats.vue
@@ -20,6 +20,7 @@ const props = defineProps<{
const router = useRouter()
const route = useRoute()
const { localSettings } = useUserLocalSettings()
+const { preferences } = useUserPreferencesState()
const chartModal = useModal('chart-modal')
const hasChartModalTransitioned = shallowRef(false)
@@ -63,7 +64,7 @@ const { fetchPackageDownloadEvolution } = useCharts()
const { accentColors, selectedAccentColor } = useAccentColor()
-const colorMode = useColorMode()
+const { colorMode } = useColorModePreference()
const resolvedMode = shallowRef<'light' | 'dark'>('light')
@@ -306,7 +307,7 @@ function layEgg() {
showPulse.value = false
nextTick(() => {
showPulse.value = true
- settings.value.enableGraphPulseLooping = !settings.value.enableGraphPulseLooping
+ preferences.value.enableGraphPulseLooping = !preferences.value.enableGraphPulseLooping
playEggPulse()
})
}
@@ -364,7 +365,7 @@ const config = computed(() => {
color: colors.value.borderHover,
pulse: {
show: showPulse.value, // the pulse will not show if prefers-reduced-motion (enforced by vue-data-ui)
- loop: settings.value.enableGraphPulseLooping,
+ loop: preferences.value.enableGraphPulseLooping,
radius: 1.5,
color: pulseColor.value!,
easing: 'ease-in-out',
diff --git a/app/composables/atproto/useAtproto.ts b/app/composables/atproto/useAtproto.ts
index 6282b27bcc..0ac27b733f 100644
--- a/app/composables/atproto/useAtproto.ts
+++ b/app/composables/atproto/useAtproto.ts
@@ -1,4 +1,24 @@
-export const useAtproto = createSharedComposable(function useAtproto() {
+import type { PublicUserSessionSchema } from '#shared/schemas/publicUserSession'
+import type { InferOutput } from 'valibot'
+
+type User = InferOutput
+
+export interface UseAtprotoReturn {
+ user: Ref
+ pending: Ref
+ logout: () => Promise
+}
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __useAtprotoMock: UseAtprotoReturn | undefined
+}
+
+function _useAtprotoImpl(): UseAtprotoReturn {
+ if (import.meta.test && globalThis.__useAtprotoMock) {
+ return globalThis.__useAtprotoMock
+ }
+
const {
data: user,
pending,
@@ -17,4 +37,10 @@ export const useAtproto = createSharedComposable(function useAtproto() {
}
return { user, pending, logout }
-})
+}
+
+// In tests, skip createSharedComposable so each call checks globalThis.__useAtprotoMock fresh.
+// In production, import.meta.test is false and the test branch is tree-shaken.
+export const useAtproto = import.meta.test
+ ? _useAtprotoImpl
+ : createSharedComposable(_useAtprotoImpl)
diff --git a/app/composables/npm/useSearch.ts b/app/composables/npm/useSearch.ts
index 28c3260162..5454683983 100644
--- a/app/composables/npm/useSearch.ts
+++ b/app/composables/npm/useSearch.ts
@@ -1,19 +1,15 @@
-import type { NpmSearchResponse, NpmSearchResult } from "#shared/types";
-import type { SearchProvider } from "#shared/schemas/userPreferences";
-import type { AlgoliaMultiSearchChecks } from "./useAlgoliaSearch";
-import { type SearchSuggestion, emptySearchResponse, parseSuggestionIntent } from "./search-utils";
-import { isValidNewPackageName, checkPackageExists } from "~/utils/package-name";
+import type { SearchProvider } from '#shared/schemas/userPreferences'
function emptySearchPayload() {
return {
searchResponse: emptySearchResponse(),
suggestions: [] as SearchSuggestion[],
packageAvailability: null as { name: string; available: boolean } | null,
- };
+ }
}
export interface SearchOptions {
- size?: number;
+ size?: number
}
export interface UseSearchConfig {
@@ -22,7 +18,7 @@ export interface UseSearchConfig {
* Algolia bundles these into the same multi-search request.
* npm runs them as separate API calls in parallel.
*/
- suggestions?: boolean;
+ suggestions?: boolean
}
export function useSearch(
@@ -31,65 +27,65 @@ export function useSearch(
options: MaybeRefOrGetter = {},
config: UseSearchConfig = {},
) {
- const { search: searchAlgolia, searchWithSuggestions: algoliaMultiSearch } = useAlgoliaSearch();
+ const { search: searchAlgolia, searchWithSuggestions: algoliaMultiSearch } = useAlgoliaSearch()
const {
search: searchNpm,
checkOrgExists: checkOrgNpm,
checkUserExists: checkUserNpm,
- } = useNpmSearch();
+ } = useNpmSearch()
const cache = shallowRef<{
- query: string;
- provider: SearchProvider;
- objects: NpmSearchResult[];
- total: number;
- } | null>(null);
+ query: string
+ provider: SearchProvider
+ objects: NpmSearchResult[]
+ total: number
+ } | null>(null)
- const isLoadingMore = shallowRef(false);
- const isRateLimited = shallowRef(false);
+ const isLoadingMore = shallowRef(false)
+ const isRateLimited = shallowRef(false)
- const suggestions = shallowRef([]);
- const suggestionsLoading = shallowRef(false);
- const packageAvailability = shallowRef<{ name: string; available: boolean } | null>(null);
- const existenceCache = shallowRef>({});
- const suggestionRequestId = shallowRef(0);
+ const suggestions = shallowRef([])
+ const suggestionsLoading = shallowRef(false)
+ const packageAvailability = shallowRef<{ name: string; available: boolean } | null>(null)
+ const existenceCache = shallowRef>({})
+ const suggestionRequestId = shallowRef(0)
/**
* Determine which extra checks to include in the Algolia multi-search.
* Returns `undefined` when nothing uncached needs checking.
*/
function buildAlgoliaChecks(q: string): AlgoliaMultiSearchChecks | undefined {
- if (!config.suggestions) return undefined;
+ if (!config.suggestions) return undefined
- const { intent, name } = parseSuggestionIntent(q);
- const lowerName = name.toLowerCase();
+ const { intent, name } = parseSuggestionIntent(q)
+ const lowerName = name.toLowerCase()
- const checks: AlgoliaMultiSearchChecks = {};
- let hasChecks = false;
+ const checks: AlgoliaMultiSearchChecks = {}
+ let hasChecks = false
if (intent && name) {
- const wantOrg = intent === "org" || intent === "both";
- const wantUser = intent === "user" || intent === "both";
+ const wantOrg = intent === 'org' || intent === 'both'
+ const wantUser = intent === 'user' || intent === 'both'
if (wantOrg && existenceCache.value[`org:${lowerName}`] === undefined) {
- checks.name = name;
- checks.checkOrg = true;
- hasChecks = true;
+ checks.name = name
+ checks.checkOrg = true
+ hasChecks = true
}
if (wantUser && existenceCache.value[`user:${lowerName}`] === undefined) {
- checks.name = name;
- checks.checkUser = true;
- hasChecks = true;
+ checks.name = name
+ checks.checkUser = true
+ hasChecks = true
}
}
- const trimmed = q.trim();
+ const trimmed = q.trim()
if (isValidNewPackageName(trimmed)) {
- checks.checkPackage = trimmed;
- hasChecks = true;
+ checks.checkPackage = trimmed
+ hasChecks = true
}
- return hasChecks ? checks : undefined;
+ return hasChecks ? checks : undefined
}
/**
@@ -102,99 +98,99 @@ export function useSearch(
checks: AlgoliaMultiSearchChecks | undefined,
result: { orgExists: boolean; userExists: boolean; packageExists: boolean | null },
) {
- const { intent, name } = parseSuggestionIntent(q);
+ const { intent, name } = parseSuggestionIntent(q)
if (intent && name) {
- const lowerName = name.toLowerCase();
- const wantOrg = intent === "org" || intent === "both";
- const wantUser = intent === "user" || intent === "both";
+ const lowerName = name.toLowerCase()
+ const wantOrg = intent === 'org' || intent === 'both'
+ const wantUser = intent === 'user' || intent === 'both'
- const updates: Record = {};
- if (checks?.checkOrg) updates[`org:${lowerName}`] = result.orgExists;
- if (checks?.checkUser) updates[`user:${lowerName}`] = result.userExists;
+ const updates: Record = {}
+ if (checks?.checkOrg) updates[`org:${lowerName}`] = result.orgExists
+ if (checks?.checkUser) updates[`user:${lowerName}`] = result.userExists
if (Object.keys(updates).length > 0) {
- existenceCache.value = { ...existenceCache.value, ...updates };
+ existenceCache.value = { ...existenceCache.value, ...updates }
}
// Prefer org over user when both match (orgs always match owner.name too)
- const isOrg = wantOrg && existenceCache.value[`org:${lowerName}`];
- const isUser = wantUser && existenceCache.value[`user:${lowerName}`];
+ const isOrg = wantOrg && existenceCache.value[`org:${lowerName}`]
+ const isUser = wantUser && existenceCache.value[`user:${lowerName}`]
- const newSuggestions: SearchSuggestion[] = [];
+ const newSuggestions: SearchSuggestion[] = []
if (isOrg) {
- newSuggestions.push({ type: "org", name: lowerName, exists: true });
+ newSuggestions.push({ type: 'org', name: lowerName, exists: true })
}
if (isUser && !isOrg) {
- newSuggestions.push({ type: "user", name: lowerName, exists: true });
+ newSuggestions.push({ type: 'user', name: lowerName, exists: true })
}
- suggestions.value = newSuggestions;
+ suggestions.value = newSuggestions
} else {
- suggestions.value = [];
+ suggestions.value = []
}
- const trimmed = q.trim();
+ const trimmed = q.trim()
if (result.packageExists !== null && isValidNewPackageName(trimmed)) {
- packageAvailability.value = { name: trimmed, available: !result.packageExists };
+ packageAvailability.value = { name: trimmed, available: !result.packageExists }
} else if (!isValidNewPackageName(trimmed)) {
- packageAvailability.value = null;
+ packageAvailability.value = null
}
- suggestionsLoading.value = false;
+ suggestionsLoading.value = false
}
const asyncData = useLazyAsyncData(
() => `search:${toValue(searchProvider)}:${toValue(query)}`,
async (_nuxtApp, { signal }) => {
- const q = toValue(query);
- const provider = toValue(searchProvider);
+ const q = toValue(query)
+ const provider = toValue(searchProvider)
if (!q.trim()) {
- isRateLimited.value = false;
- return emptySearchPayload();
+ isRateLimited.value = false
+ return emptySearchPayload()
}
- const opts = toValue(options);
- cache.value = null;
+ const opts = toValue(options)
+ cache.value = null
- if (provider === "algolia") {
- const checks = config.suggestions ? buildAlgoliaChecks(q) : undefined;
+ if (provider === 'algolia') {
+ const checks = config.suggestions ? buildAlgoliaChecks(q) : undefined
if (config.suggestions) {
- suggestionsLoading.value = true;
- const result = await algoliaMultiSearch(q, { size: opts.size ?? 25 }, checks);
+ suggestionsLoading.value = true
+ const result = await algoliaMultiSearch(q, { size: opts.size ?? 25 }, checks)
if (q !== toValue(query)) {
- return emptySearchPayload();
+ return emptySearchPayload()
}
- isRateLimited.value = false;
- processAlgoliaChecks(q, checks, result);
+ isRateLimited.value = false
+ processAlgoliaChecks(q, checks, result)
return {
searchResponse: result.search,
suggestions: suggestions.value,
packageAvailability: packageAvailability.value,
- };
+ }
}
- const response = await searchAlgolia(q, { size: opts.size ?? 25 });
+ const response = await searchAlgolia(q, { size: opts.size ?? 25 })
if (q !== toValue(query)) {
- return emptySearchPayload();
+ return emptySearchPayload()
}
- isRateLimited.value = false;
+ isRateLimited.value = false
return {
searchResponse: response,
suggestions: [],
packageAvailability: null,
- };
+ }
}
try {
- const response = await searchNpm(q, { size: opts.size ?? 25 }, signal);
+ const response = await searchNpm(q, { size: opts.size ?? 25 }, signal)
if (q !== toValue(query)) {
- return emptySearchPayload();
+ return emptySearchPayload()
}
cache.value = {
@@ -202,87 +198,87 @@ export function useSearch(
provider,
objects: response.objects,
total: response.total,
- };
+ }
- isRateLimited.value = false;
+ isRateLimited.value = false
return {
searchResponse: response,
suggestions: [],
packageAvailability: null,
- };
+ }
} catch (error: unknown) {
- const errorMessage = (error as { message?: string })?.message || String(error);
+ const errorMessage = (error as { message?: string })?.message || String(error)
const isRateLimitError =
- errorMessage.includes("Failed to fetch") || errorMessage.includes("429");
+ errorMessage.includes('Failed to fetch') || errorMessage.includes('429')
if (isRateLimitError) {
- isRateLimited.value = true;
- return emptySearchPayload();
+ isRateLimited.value = true
+ return emptySearchPayload()
}
- throw error;
+ throw error
}
},
{ default: emptySearchPayload },
- );
+ )
async function fetchMore(targetSize: number): Promise {
- const q = toValue(query).trim();
- const provider = toValue(searchProvider);
+ const q = toValue(query).trim()
+ const provider = toValue(searchProvider)
if (!q) {
- cache.value = null;
- return;
+ cache.value = null
+ return
}
if (cache.value && (cache.value.query !== q || cache.value.provider !== provider)) {
- cache.value = null;
- await asyncData.refresh();
- return;
+ cache.value = null
+ await asyncData.refresh()
+ return
}
// Seed cache from asyncData for Algolia (which skips cache on initial fetch)
if (!cache.value && asyncData.data.value) {
- const { searchResponse } = asyncData.data.value;
+ const { searchResponse } = asyncData.data.value
cache.value = {
query: q,
provider,
objects: [...searchResponse.objects],
total: searchResponse.total,
- };
+ }
}
- const currentCount = cache.value?.objects.length ?? 0;
- const total = cache.value?.total ?? Infinity;
+ const currentCount = cache.value?.objects.length ?? 0
+ const total = cache.value?.total ?? Infinity
if (currentCount >= targetSize || currentCount >= total) {
- return;
+ return
}
- isLoadingMore.value = true;
+ isLoadingMore.value = true
try {
- const from = currentCount;
- const size = Math.min(targetSize - currentCount, total - currentCount);
+ const from = currentCount
+ const size = Math.min(targetSize - currentCount, total - currentCount)
- const doSearch = provider === "algolia" ? searchAlgolia : searchNpm;
- const response = await doSearch(q, { size, from });
+ const doSearch = provider === 'algolia' ? searchAlgolia : searchNpm
+ const response = await doSearch(q, { size, from })
if (cache.value && cache.value.query === q && cache.value.provider === provider) {
- const existingNames = new Set(cache.value.objects.map((obj) => obj.package.name));
- const newObjects = response.objects.filter((obj) => !existingNames.has(obj.package.name));
+ const existingNames = new Set(cache.value.objects.map(obj => obj.package.name))
+ const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name))
cache.value = {
query: q,
provider,
objects: [...cache.value.objects, ...newObjects],
total: response.total,
- };
+ }
} else {
cache.value = {
query: q,
provider,
objects: response.objects,
total: response.total,
- };
+ }
}
if (
@@ -290,35 +286,35 @@ export function useSearch(
cache.value.objects.length < targetSize &&
cache.value.objects.length < cache.value.total
) {
- await fetchMore(targetSize);
+ await fetchMore(targetSize)
}
} finally {
- isLoadingMore.value = false;
+ isLoadingMore.value = false
}
}
watch(
() => toValue(options).size,
async (newSize, oldSize) => {
- if (!newSize) return;
+ if (!newSize) return
if (oldSize && newSize > oldSize && toValue(query).trim()) {
- await fetchMore(newSize);
+ await fetchMore(newSize)
}
},
- );
+ )
watch(
() => toValue(searchProvider),
async () => {
- cache.value = null;
- existenceCache.value = {};
- await asyncData.refresh();
- const targetSize = toValue(options).size;
+ cache.value = null
+ existenceCache.value = {}
+ await asyncData.refresh()
+ const targetSize = toValue(options).size
if (targetSize) {
- await fetchMore(targetSize);
+ await fetchMore(targetSize)
}
},
- );
+ )
const data = computed(() => {
if (cache.value) {
@@ -327,131 +323,131 @@ export function useSearch(
objects: cache.value.objects,
total: cache.value.total,
time: new Date().toISOString(),
- };
+ }
}
- return asyncData.data.value?.searchResponse ?? null;
- });
+ return asyncData.data.value?.searchResponse ?? null
+ })
const hasMore = computed(() => {
- if (!cache.value) return true;
- return cache.value.objects.length < cache.value.total;
- });
+ if (!cache.value) return true
+ return cache.value.objects.length < cache.value.total
+ })
async function validateSuggestionsNpm(q: string) {
- const requestId = ++suggestionRequestId.value;
- const { intent, name } = parseSuggestionIntent(q);
- let availability: { name: string; available: boolean } | null = null;
+ const requestId = ++suggestionRequestId.value
+ const { intent, name } = parseSuggestionIntent(q)
+ let availability: { name: string; available: boolean } | null = null
- const promises: Promise[] = [];
+ const promises: Promise[] = []
- const trimmed = q.trim();
+ const trimmed = q.trim()
if (isValidNewPackageName(trimmed)) {
promises.push(
checkPackageExists(trimmed)
- .then((exists) => {
+ .then(exists => {
if (trimmed === toValue(query).trim()) {
- availability = { name: trimmed, available: !exists };
- packageAvailability.value = availability;
+ availability = { name: trimmed, available: !exists }
+ packageAvailability.value = availability
}
})
.catch(() => {
- availability = null;
+ availability = null
}),
- );
+ )
} else {
- availability = null;
+ availability = null
}
if (!intent || !name) {
- suggestionsLoading.value = false;
- await Promise.all(promises);
- return { suggestions: [], packageAvailability: availability };
+ suggestionsLoading.value = false
+ await Promise.all(promises)
+ return { suggestions: [], packageAvailability: availability }
}
- suggestionsLoading.value = true;
- const result: SearchSuggestion[] = [];
- const lowerName = name.toLowerCase();
+ suggestionsLoading.value = true
+ const result: SearchSuggestion[] = []
+ const lowerName = name.toLowerCase()
try {
- const wantOrg = intent === "org" || intent === "both";
- const wantUser = intent === "user" || intent === "both";
+ const wantOrg = intent === 'org' || intent === 'both'
+ const wantUser = intent === 'user' || intent === 'both'
if (wantOrg && existenceCache.value[`org:${lowerName}`] === undefined) {
promises.push(
checkOrgNpm(lowerName)
- .then((exists) => {
- existenceCache.value = { ...existenceCache.value, [`org:${lowerName}`]: exists };
+ .then(exists => {
+ existenceCache.value = { ...existenceCache.value, [`org:${lowerName}`]: exists }
})
.catch(() => {
- existenceCache.value = { ...existenceCache.value, [`org:${lowerName}`]: false };
+ existenceCache.value = { ...existenceCache.value, [`org:${lowerName}`]: false }
}),
- );
+ )
}
if (wantUser && existenceCache.value[`user:${lowerName}`] === undefined) {
promises.push(
checkUserNpm(lowerName)
- .then((exists) => {
- existenceCache.value = { ...existenceCache.value, [`user:${lowerName}`]: exists };
+ .then(exists => {
+ existenceCache.value = { ...existenceCache.value, [`user:${lowerName}`]: exists }
})
.catch(() => {
- existenceCache.value = { ...existenceCache.value, [`user:${lowerName}`]: false };
+ existenceCache.value = { ...existenceCache.value, [`user:${lowerName}`]: false }
}),
- );
+ )
}
if (promises.length > 0) {
- await Promise.all(promises);
+ await Promise.all(promises)
}
if (requestId !== suggestionRequestId.value)
- return { suggestions: [], packageAvailability: availability };
+ return { suggestions: [], packageAvailability: availability }
- const isOrg = wantOrg && existenceCache.value[`org:${lowerName}`];
- const isUser = wantUser && existenceCache.value[`user:${lowerName}`];
+ const isOrg = wantOrg && existenceCache.value[`org:${lowerName}`]
+ const isUser = wantUser && existenceCache.value[`user:${lowerName}`]
if (isOrg) {
- result.push({ type: "org", name: lowerName, exists: true });
+ result.push({ type: 'org', name: lowerName, exists: true })
}
if (isUser && !isOrg) {
- result.push({ type: "user", name: lowerName, exists: true });
+ result.push({ type: 'user', name: lowerName, exists: true })
}
} finally {
if (requestId === suggestionRequestId.value) {
- suggestionsLoading.value = false;
+ suggestionsLoading.value = false
}
}
if (requestId === suggestionRequestId.value) {
- suggestions.value = result;
- return { suggestions: result, packageAvailability: availability };
+ suggestions.value = result
+ return { suggestions: result, packageAvailability: availability }
}
- return { suggestions: [], packageAvailability: availability };
+ return { suggestions: [], packageAvailability: availability }
}
const npmSuggestions = useLazyAsyncData(
() => `npm-suggestions:${toValue(searchProvider)}:${toValue(query)}`,
async () => {
- const q = toValue(query).trim();
- if (toValue(searchProvider) === "algolia" || !q)
- return { suggestions: [], packageAvailability: null };
- const { intent, name } = parseSuggestionIntent(q);
- if (!intent || !name) return { suggestions: [], packageAvailability: null };
- return validateSuggestionsNpm(q);
+ const q = toValue(query).trim()
+ if (toValue(searchProvider) === 'algolia' || !q)
+ return { suggestions: [], packageAvailability: null }
+ const { intent, name } = parseSuggestionIntent(q)
+ if (!intent || !name) return { suggestions: [], packageAvailability: null }
+ return validateSuggestionsNpm(q)
},
{ default: () => ({ suggestions: [], packageAvailability: null }) },
- );
+ )
watch(
[() => asyncData.data.value.suggestions, () => npmSuggestions.data.value.suggestions],
([algoliaSuggestions, npmSuggestionsValue]) => {
if (algoliaSuggestions.length || npmSuggestionsValue.length) {
- suggestions.value = algoliaSuggestions.length ? algoliaSuggestions : npmSuggestionsValue;
+ suggestions.value = algoliaSuggestions.length ? algoliaSuggestions : npmSuggestionsValue
}
},
{ immediate: true },
- );
+ )
watch(
[
@@ -460,16 +456,16 @@ export function useSearch(
],
([algoliaPackageAvailability, npmPackageAvailability]) => {
if (algoliaPackageAvailability || npmPackageAvailability) {
- packageAvailability.value = algoliaPackageAvailability || npmPackageAvailability;
+ packageAvailability.value = algoliaPackageAvailability || npmPackageAvailability
}
},
{ immediate: true },
- );
+ )
if (import.meta.client && asyncData.data.value?.searchResponse.isStale) {
onMounted(() => {
- asyncData.refresh();
- });
+ asyncData.refresh()
+ })
}
return {
@@ -482,5 +478,5 @@ export function useSearch(
suggestions: readonly(suggestions),
suggestionsLoading: readonly(suggestionsLoading),
packageAvailability: readonly(packageAvailability),
- };
+ }
}
diff --git a/app/composables/useGlobalSearch.ts b/app/composables/useGlobalSearch.ts
index 1855da053d..3bda8fbdad 100644
--- a/app/composables/useGlobalSearch.ts
+++ b/app/composables/useGlobalSearch.ts
@@ -1,117 +1,117 @@
-import { normalizeSearchParam } from "#shared/utils/url";
-import { debounce } from "perfect-debounce";
+import { normalizeSearchParam } from '#shared/utils/url'
+import { debounce } from 'perfect-debounce'
// Pages that have their own local filter using ?q
-const pagesWithLocalFilter = new Set(["~username", "org"]);
+const pagesWithLocalFilter = new Set(['~username', 'org'])
-const SEARCH_DEBOUNCE_MS = 100;
+const SEARCH_DEBOUNCE_MS = 100
-export function useGlobalSearch(place: "header" | "content" = "content") {
- const instantSearch = useInstantSearchPreference();
- const { searchProvider } = useSearchProvider();
+export function useGlobalSearch(place: 'header' | 'content' = 'content') {
+ const instantSearch = useInstantSearchPreference()
+ const { searchProvider } = useSearchProvider()
const searchProviderValue = computed(() => {
- const p = normalizeSearchParam(route.query.p);
- if (p === "npm" || searchProvider.value === "npm") return "npm";
- return "algolia";
- });
+ const p = normalizeSearchParam(route.query.p)
+ if (p === 'npm' || searchProvider.value === 'npm') return 'npm'
+ return 'algolia'
+ })
- const router = useRouter();
- const route = useRoute();
+ const router = useRouter()
+ const route = useRoute()
// Internally used searchQuery state
- const searchQuery = useState("search-query", () => {
+ const searchQuery = useState('search-query', () => {
if (pagesWithLocalFilter.has(route.name as string)) {
- return "";
+ return ''
}
- return normalizeSearchParam(route.query.q);
- });
+ return normalizeSearchParam(route.query.q)
+ })
// Committed search query: last value submitted by user
// Syncs instantly when instantSearch is on, but only on Enter press when off
- const committedSearchQuery = useState("committed-search-query", () => searchQuery.value);
+ const committedSearchQuery = useState('committed-search-query', () => searchQuery.value)
const commitSearchQuery = debounce((val: string) => {
- committedSearchQuery.value = val;
- }, SEARCH_DEBOUNCE_MS);
+ committedSearchQuery.value = val
+ }, SEARCH_DEBOUNCE_MS)
// This is basically doing instant search as user types
- watch(searchQuery, (val) => {
+ watch(searchQuery, val => {
if (instantSearch.value) {
- commitSearchQuery(val);
+ commitSearchQuery(val)
}
- });
+ })
// clean search input when navigating away from search page
watch(
() => route.query.q,
- (urlQuery) => {
- const value = normalizeSearchParam(urlQuery);
- if (!value) searchQuery.value = "";
- if (!searchQuery.value) searchQuery.value = value;
+ urlQuery => {
+ const value = normalizeSearchParam(urlQuery)
+ if (!value) searchQuery.value = ''
+ if (!searchQuery.value) searchQuery.value = value
},
- );
+ )
// Updates URL when search query changes (immediately for instantSearch or after Enter hit otherwise)
- const updateUrlQueryImpl = (value: string, provider: "npm" | "algolia") => {
- const isSameQuery = route.query.q === value && route.query.p === provider;
+ const updateUrlQueryImpl = (value: string, provider: 'npm' | 'algolia') => {
+ const isSameQuery = route.query.q === value && route.query.p === provider
// Don't navigate away from pages that use ?q for local filtering
- if ((pagesWithLocalFilter.has(route.name as string) && place === "content") || isSameQuery) {
- return;
+ if ((pagesWithLocalFilter.has(route.name as string) && place === 'content') || isSameQuery) {
+ return
}
- if (route.name === "search") {
+ if (route.name === 'search') {
router.replace({
query: {
...route.query,
q: value || undefined,
- p: provider === "npm" ? "npm" : undefined,
+ p: provider === 'npm' ? 'npm' : undefined,
},
- });
- return;
+ })
+ return
}
router.push({
- name: "search",
+ name: 'search',
query: {
q: value,
- p: provider === "npm" ? "npm" : undefined,
+ p: provider === 'npm' ? 'npm' : undefined,
},
- });
- };
+ })
+ }
- const updateUrlQuery = debounce(updateUrlQueryImpl, SEARCH_DEBOUNCE_MS);
+ const updateUrlQuery = debounce(updateUrlQueryImpl, SEARCH_DEBOUNCE_MS)
function flushUpdateUrlQuery() {
// Commit the current query when explicitly submitted (Enter pressed)
- commitSearchQuery.cancel();
- committedSearchQuery.value = searchQuery.value;
+ commitSearchQuery.cancel()
+ committedSearchQuery.value = searchQuery.value
// When instant search is off the debounce queue is empty, so call directly
if (!instantSearch.value) {
- updateUrlQueryImpl(searchQuery.value, searchProvider.value);
+ updateUrlQueryImpl(searchQuery.value, searchProvider.value)
} else {
- updateUrlQuery.flush();
+ updateUrlQuery.flush()
}
}
const searchQueryValue = computed({
get: () => searchQuery.value,
set: async (value: string) => {
- searchQuery.value = value;
+ searchQuery.value = value
// When instant search is off, skip debounced URL updates
// Only explicitly called flushUpdateUrlQuery commits and navigates
- if (!instantSearch.value) return;
+ if (!instantSearch.value) return
// Leading debounce implementation as it doesn't work properly out of the box (https://github.com/unjs/perfect-debounce/issues/43)
if (!updateUrlQuery.isPending()) {
- updateUrlQueryImpl(value, searchProvider.value);
+ updateUrlQueryImpl(value, searchProvider.value)
}
- updateUrlQuery(value, searchProvider.value);
+ updateUrlQuery(value, searchProvider.value)
},
- });
+ })
return {
model: searchQueryValue,
committedModel: committedSearchQuery,
provider: searchProviderValue,
startSearch: flushUpdateUrlQuery,
- };
+ }
}
diff --git a/app/composables/useShortcuts.ts b/app/composables/useShortcuts.ts
index c1ac793265..add45b7f09 100644
--- a/app/composables/useShortcuts.ts
+++ b/app/composables/useShortcuts.ts
@@ -7,7 +7,7 @@ type ShortcutTargetFactory = () => ShortcutTarget
const registry = new Map()
export function initKeyShortcuts() {
- const keyboardShortcuts = useKeyboardShortcuts()
+ const keyboardShortcuts = useKeyboardShortcutsPreference()
onKeyStroke(
e => !e.repeat && keyboardShortcuts.value && !isEditableElement(e.target),
@@ -19,7 +19,7 @@ export function initKeyShortcuts() {
const target = getTarget()
if (!target) return
e.preventDefault()
- navigateTo(target)
+ void navigateTo(target)
return
}
},
diff --git a/app/composables/useUserPreferencesProvider.ts b/app/composables/useUserPreferencesProvider.ts
index 433be3ee9e..8d9f89201e 100644
--- a/app/composables/useUserPreferencesProvider.ts
+++ b/app/composables/useUserPreferencesProvider.ts
@@ -11,7 +11,6 @@
* a ref with defaults (no real localStorage); on the client, there's only one app instance.
*/
-import type { RemovableRef } from '@vueuse/core'
import { useLocalStorage } from '@vueuse/core'
import { DEFAULT_USER_PREFERENCES } from '#shared/schemas/userPreferences'
import {
@@ -22,56 +21,83 @@ import {
const STORAGE_KEY = 'npmx-user-preferences'
-let dataRef: RemovableRef | null = null
+let cached: ReturnType | null = null
let syncInitialized = false
-export function useUserPreferencesProvider(
- defaultValue: HydratedUserPreferences = DEFAULT_USER_PREFERENCES,
-) {
- if (!dataRef) {
- dataRef = useLocalStorage(STORAGE_KEY, defaultValue, {
- mergeDefaults: true,
- })
- }
+function createProvider(defaultValue: HydratedUserPreferences) {
+ const preferences = useLocalStorage(STORAGE_KEY, defaultValue, {
+ mergeDefaults: true,
+ })
- // After the guard above, dataRef is guaranteed to be initialized.
- const preferences: RemovableRef = dataRef
-
- const { user } = useAtproto()
-
- const isAuthenticated = computed(() => !!user.value?.did)
- const {
- status,
- lastSyncedAt,
- error,
- loadFromServer,
- scheduleSync,
- setupRouteGuard,
- setupBeforeUnload,
- } = useUserPreferencesSync()
+ const isAuthenticated = shallowRef(false)
+ const status = shallowRef<'idle' | 'syncing' | 'synced' | 'error'>('idle')
+ const lastSyncedAt = shallowRef(null)
+ const error = shallowRef(null)
const isSyncing = computed(() => status.value === 'syncing')
const isSynced = computed(() => status.value === 'synced')
const hasError = computed(() => status.value === 'error')
- async function syncWithServer(): Promise {
- const serverResult = await loadFromServer()
-
- // If the server load failed, keep current local preferences untouched
- if (hasError.value) return
-
- const { merged, shouldPushToServer } = mergePreferences(preferences.value, serverResult)
- if (shouldPushToServer) {
- scheduleSync(preferences.value)
- } else if (!arePreferencesEqual(preferences.value, merged)) {
- preferences.value = merged
- }
- }
-
async function initSync(): Promise {
if (syncInitialized || import.meta.server) return
syncInitialized = true
+ // Resolve auth + sync dependencies lazily
+ const { user } = useAtproto()
+ watch(
+ () => !!user.value?.did,
+ v => {
+ isAuthenticated.value = v
+ },
+ { immediate: true },
+ )
+
+ const {
+ status: syncStatus,
+ lastSyncedAt: syncLastSyncedAt,
+ error: syncError,
+ loadFromServer,
+ scheduleSync,
+ setupRouteGuard,
+ setupBeforeUnload,
+ } = useUserPreferencesSync(isAuthenticated)
+
+ watch(
+ syncStatus,
+ v => {
+ status.value = v
+ },
+ { immediate: true },
+ )
+ watch(
+ syncLastSyncedAt,
+ v => {
+ lastSyncedAt.value = v
+ },
+ { immediate: true },
+ )
+ watch(
+ syncError,
+ v => {
+ error.value = v
+ },
+ { immediate: true },
+ )
+
+ async function syncWithServer(): Promise {
+ const serverResult = await loadFromServer()
+
+ // If the server load failed, keep current local preferences untouched
+ if (hasError.value) return
+
+ const { merged, shouldPushToServer } = mergePreferences(preferences.value, serverResult)
+ if (shouldPushToServer) {
+ scheduleSync(preferences.value)
+ } else if (!arePreferencesEqual(preferences.value, merged)) {
+ preferences.value = merged
+ }
+ }
+
setupRouteGuard(() => preferences.value)
setupBeforeUnload(() => preferences.value)
@@ -108,12 +134,21 @@ export function useUserPreferencesProvider(
}
}
+export function useUserPreferencesProvider(
+ defaultValue: HydratedUserPreferences = DEFAULT_USER_PREFERENCES,
+) {
+ if (!cached) {
+ cached = createProvider(defaultValue)
+ }
+ return cached
+}
+
/**
* Reset module-level singleton state. Test-only — do not use in production code.
*/
export function __resetPreferencesForTest(): void {
if (import.meta.test) {
- dataRef = null
+ cached = null
syncInitialized = false
}
}
diff --git a/app/composables/useUserPreferencesSync.client.ts b/app/composables/useUserPreferencesSync.client.ts
index 105753a098..f534dfe373 100644
--- a/app/composables/useUserPreferencesSync.client.ts
+++ b/app/composables/useUserPreferencesSync.client.ts
@@ -104,13 +104,10 @@ function cancelPendingDebounce(): void {
}
}
-export function useUserPreferencesSync() {
- const { user } = useAtproto()
+export function useUserPreferencesSync(isAuthenticated: Ref) {
const state = getSyncState()
const router = useRouter()
- const isAuthenticated = computed(() => !!user.value?.did)
-
function scheduleSync(preferences: UserPreferences): void {
if (!isAuthenticated.value) return
diff --git a/app/composables/userPreferences/README.md b/app/composables/userPreferences/README.md
index 989a979bb8..b593a2127f 100644
--- a/app/composables/userPreferences/README.md
+++ b/app/composables/userPreferences/README.md
@@ -108,8 +108,10 @@ The preference will automatically persist to localStorage and sync to the server
## Architecture overview
```
-useUserPreferencesProvider ← singleton, manages localStorage + sync lifecycle
- ├── useUserPreferencesSync ← client-only: debounced server writes, route guard, sendBeacon
+useUserPreferencesProvider ← cached singleton, manages localStorage + sync lifecycle
+ ├── createProvider() ← internal: sets up localStorage ref + lazy sync state
+ │ └── initSync() ← resolves useAtproto() + useUserPreferencesSync() lazily
+ ├── useUserPreferencesSync ← client-only: receives isAuthenticated ref via DI
├── useUserPreferencesState ← read/write access to reactive ref (used by all composables above)
└── preferences-merge.ts ← merge logic for first-login vs returning-user scenarios
@@ -121,8 +123,9 @@ useLocalStorageHashProvider ← generic localStorage + defu provider (used
### Sync flow (authenticated users)
1. `preferences-sync.client.ts` plugin calls `initSync()` on app boot
-2. Preferences are loaded from server and merged with local state
-3. A deep watcher on the preferences ref triggers `scheduleSync()` on every change
-4. `scheduleSync()` debounces for 2 seconds, then pushes to `PUT /api/user/preferences`
-5. On route navigation, `router.beforeEach` flushes any pending sync
-6. On tab close, `sendBeacon` fires the latest preferences via `POST /api/user/preferences`
+2. `initSync()` lazily resolves `useAtproto()` and `useUserPreferencesSync(isAuthenticated)` — auth is not fetched at provider construction time
+3. Preferences are loaded from server and merged with local state
+4. A deep watcher on the preferences ref triggers `scheduleSync()` on every change
+5. `scheduleSync()` debounces for 2 seconds, then pushes to `PUT /api/user/preferences`
+6. On route navigation, `router.beforeEach` flushes any pending sync
+7. On tab close, `sendBeacon` fires the latest preferences via `POST /api/user/preferences`
diff --git a/app/composables/userPreferences/useAccentColor.ts b/app/composables/userPreferences/useAccentColor.ts
index 7b57b26ea3..7a4cd3feb5 100644
--- a/app/composables/userPreferences/useAccentColor.ts
+++ b/app/composables/userPreferences/useAccentColor.ts
@@ -1,9 +1,17 @@
-import { ACCENT_COLORS } from '#shared/utils/constants'
-import type { AccentColorId } from '#shared/schemas/userPreferences'
-
export function useAccentColor() {
const { preferences } = useUserPreferencesState()
- const colorMode = useColorMode()
+ const { colorMode } = useColorModePreference()
+ const { t } = useI18n()
+
+ const accentColorLabels = computed>(() => ({
+ sky: t('settings.accent_colors.sky'),
+ coral: t('settings.accent_colors.coral'),
+ amber: t('settings.accent_colors.amber'),
+ emerald: t('settings.accent_colors.emerald'),
+ violet: t('settings.accent_colors.violet'),
+ magenta: t('settings.accent_colors.magenta'),
+ neutral: t('settings.clear_accent'),
+ }))
const accentColors = computed(() => {
const isDark = colorMode.value === 'dark'
@@ -11,7 +19,7 @@ export function useAccentColor() {
return Object.entries(colors).map(([id, value]) => ({
id: id as AccentColorId,
- name: id,
+ label: accentColorLabels.value[id as AccentColorId],
value,
}))
})
diff --git a/app/composables/userPreferences/useBackgroundTheme.ts b/app/composables/userPreferences/useBackgroundTheme.ts
index 123297970d..0951a69321 100644
--- a/app/composables/userPreferences/useBackgroundTheme.ts
+++ b/app/composables/userPreferences/useBackgroundTheme.ts
@@ -1,14 +1,22 @@
-import { BACKGROUND_THEMES } from '#shared/utils/constants'
-import type { BackgroundThemeId } from '#shared/schemas/userPreferences'
-
export function useBackgroundTheme() {
- const backgroundThemes = Object.entries(BACKGROUND_THEMES).map(([id, value]) => ({
- id: id as BackgroundThemeId,
- name: id,
- value,
+ const { preferences } = useUserPreferencesState()
+ const { t } = useI18n()
+
+ const bgThemeLabels = computed>(() => ({
+ neutral: t('settings.background_themes.neutral'),
+ stone: t('settings.background_themes.stone'),
+ zinc: t('settings.background_themes.zinc'),
+ slate: t('settings.background_themes.slate'),
+ black: t('settings.background_themes.black'),
}))
- const { preferences } = useUserPreferencesState()
+ const backgroundThemes = computed(() =>
+ Object.entries(BACKGROUND_THEMES).map(([id, value]) => ({
+ id: id as BackgroundThemeId,
+ label: bgThemeLabels.value[id as BackgroundThemeId],
+ value,
+ })),
+ )
function setBackgroundTheme(id: BackgroundThemeId | null) {
if (import.meta.server) {
diff --git a/app/composables/userPreferences/useColorModePreference.ts b/app/composables/userPreferences/useColorModePreference.ts
index 242e629b6f..f283bbf966 100644
--- a/app/composables/userPreferences/useColorModePreference.ts
+++ b/app/composables/userPreferences/useColorModePreference.ts
@@ -26,6 +26,7 @@ export function useColorModePreference() {
}
return {
+ colorMode,
colorModePreference: computed(
() => preferences.value.colorModePreference ?? colorMode.preference,
),
diff --git a/app/composables/userPreferences/useInstantSearch.ts b/app/composables/userPreferences/useInstantSearch.ts
index ffb5a73d9e..ad43fcfec9 100644
--- a/app/composables/userPreferences/useInstantSearch.ts
+++ b/app/composables/userPreferences/useInstantSearch.ts
@@ -1,12 +1,10 @@
-export const useInstantSearchPreference = createSharedComposable(
- function useInstantSearchPreference() {
- const { preferences } = useUserPreferencesState()
+export const useInstantSearchPreference = () => {
+ const { preferences } = useUserPreferencesState()
- return computed({
- get: () => preferences.value.instantSearch ?? true,
- set: (value: boolean) => {
- preferences.value.instantSearch = value
- },
- })
- },
-)
+ return computed({
+ get: () => preferences.value.instantSearch,
+ set: (value: boolean) => {
+ preferences.value.instantSearch = value
+ },
+ })
+}
diff --git a/app/composables/userPreferences/useKeyboardShortcuts.ts b/app/composables/userPreferences/useKeyboardShortcuts.ts
index a464fcaf0a..6a40e7e719 100644
--- a/app/composables/userPreferences/useKeyboardShortcuts.ts
+++ b/app/composables/userPreferences/useKeyboardShortcuts.ts
@@ -1,7 +1,7 @@
export const useKeyboardShortcutsPreference = createSharedComposable(
- function useKeyboardShortcutsPreference() {
+ function useKeyboardShortcuts() {
const { preferences } = useUserPreferencesState()
- const enabled = computed(() => preferences.value.keyboardShortcuts ?? true)
+ const enabled = computed(() => preferences.value.keyboardShortcuts)
if (import.meta.client) {
watch(
diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue
index 1b756cc347..4bc4d85bca 100644
--- a/app/pages/package/[[org]]/[name].vue
+++ b/app/pages/package/[[org]]/[name].vue
@@ -1,67 +1,67 @@
@@ -588,7 +540,7 @@ const showSkeleton = shallowRef(false);
- {{ $t("package.no_description") }}
+ {{ $t('package.no_description') }}
@@ -608,16 +560,16 @@ const showSkeleton = shallowRef(false);
>
{{
- deprecationNotice.type === "package"
- ? $t("package.deprecation.package")
- : $t("package.deprecation.version")
+ deprecationNotice.type === 'package'
+ ? $t('package.deprecation.package')
+ : $t('package.deprecation.version')
}}
- {{ $t("package.deprecation.no_reason") }}
+ {{ $t('package.deprecation.no_reason') }}
@@ -627,17 +579,17 @@ const showSkeleton = shallowRef(false);
>
- {{ $t("package.stats.license") }}
+ {{ $t('package.stats.license') }}
- {{ $t("package.license.none") }}
+ {{ $t('package.license.none') }}
- {{ $t("package.stats.deps") }}
+ {{ $t('package.stats.deps') }}
@@ -676,7 +628,7 @@ const showSkeleton = shallowRef(false);
:title="$t('package.stats.view_dependency_graph')"
classicon="i-lucide:network -rotate-90"
>
- {{ $t("package.stats.view_dependency_graph") }}
+ {{ $t('package.stats.view_dependency_graph') }}
- {{ $t("package.stats.inspect_dependency_tree") }}
+ {{ $t('package.stats.inspect_dependency_tree') }}
@@ -694,7 +646,7 @@ const showSkeleton = shallowRef(false);
- {{ $t("package.stats.install_size") }}
+ {{ $t('package.stats.install_size') }}
- {{ $t("package.stats.vulns") }}
+ {{ $t('package.stats.vulns') }}
- {{ $t("package.stats.published") }}
+ {{ $t('package.stats.published') }}
@@ -793,7 +745,7 @@ const showSkeleton = shallowRef(false);
@@ -1116,13 +1068,13 @@ const showSkeleton = shallowRef(false);
class="flex flex-col items-center py-20 text-center container w-full"
>
- {{ $t("package.not_found") }}
+ {{ $t('package.not_found') }}
- {{ error?.message ?? $t("package.not_found_message") }}
+ {{ error?.message ?? $t('package.not_found_message') }}
{{
- $t("common.go_back_home")
+ $t('common.go_back_home')
}}
@@ -1138,11 +1090,11 @@ const showSkeleton = shallowRef(false);
/* Mobile: single column, sidebar above readme */
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
- "details"
- "install"
- "vulns"
- "sidebar"
- "readme";
+ 'details'
+ 'install'
+ 'vulns'
+ 'sidebar'
+ 'readme';
}
/* Tablet/medium: install/vulns full width, readme+sidebar side by side */
@@ -1150,10 +1102,10 @@ const showSkeleton = shallowRef(false);
.packagePage {
grid-template-columns: 2fr 1fr;
grid-template-areas:
- "details details"
- "install sidebar"
- "vulns sidebar"
- "readme sidebar";
+ 'details details'
+ 'install sidebar'
+ 'vulns sidebar'
+ 'readme sidebar';
grid-template-rows: auto auto auto 1fr;
}
}
@@ -1163,10 +1115,10 @@ const showSkeleton = shallowRef(false);
.packagePage {
grid-template-columns: 1fr 20rem;
grid-template-areas:
- "details sidebar"
- "install sidebar"
- "vulns sidebar"
- "readme sidebar";
+ 'details sidebar'
+ 'install sidebar'
+ 'vulns sidebar'
+ 'readme sidebar';
grid-template-rows: auto auto auto 1fr;
}
}
diff --git a/app/pages/settings.vue b/app/pages/settings.vue
index 9d61529f8f..8f981750c1 100644
--- a/app/pages/settings.vue
+++ b/app/pages/settings.vue
@@ -1,45 +1,46 @@
@@ -49,12 +50,12 @@ const setLocale: typeof setNuxti18nLocale = (newLocale) => {
- {{ $t("settings.title") }}
+ {{ $t('settings.title') }}
- {{ $t("settings.tagline") }}
+ {{ $t('settings.tagline') }}
@@ -69,7 +70,7 @@ const setLocale: typeof setNuxti18nLocale = (newLocale) => {
class="i-lucide:cloud-upload w-4 h-4 text-fg-muted animate-pulse"
aria-hidden="true"
/>
- {{ $t("settings.syncing") }}
+ {{ $t('settings.syncing') }}
{
aria-hidden="true"
/>
{{
- $t("settings.synced")
+ $t('settings.synced')
}}
- {{ $t("settings.sync_error") }}
+ {{ $t('settings.sync_error') }}
- {{ $t("settings.sync_enabled") }}
+ {{ $t('settings.sync_enabled') }}
@@ -98,39 +99,41 @@ const setLocale: typeof setNuxti18nLocale = (newLocale) => {
- {{ $t("settings.sections.appearance") }}
+ {{ $t('settings.sections.appearance') }}
- {{ $t("settings.theme") }}
+ {{ $t('settings.theme') }}
-
+ :model-value="colorModePreference"
+ @update:modelValue="setColorMode($event as 'light' | 'dark' | 'system')"
+ block
+ size="sm"
+ class="max-w-48"
+ :items="[
+ { label: $t('settings.theme_system'), value: 'system' },
+ { label: $t('settings.theme_light'), value: 'light' },
+ { label: $t('settings.theme_dark'), value: 'dark' },
+ ]"
+ />
-
+ :items="[
+ { label: $t('settings.theme_system'), value: 'system' },
+ { label: $t('settings.theme_light'), value: 'light' },
+ { label: $t('settings.theme_dark'), value: 'dark' },
+ ]"
+ block
+ size="sm"
+ class="max-w-48"
+ />
@@ -138,7 +141,7 @@ const setLocale: typeof setNuxti18nLocale = (newLocale) => {
- {{ $t("settings.accent_colors.label") }}
+ {{ $t('settings.accent_colors.label') }}
@@ -146,7 +149,7 @@ const setLocale: typeof setNuxti18nLocale = (newLocale) => {
- {{ $t("settings.background_themes.label") }}
+ {{ $t('settings.background_themes.label') }}
@@ -156,7 +159,7 @@ const setLocale: typeof setNuxti18nLocale = (newLocale) => {
- {{ $t("settings.sections.display") }}
+ {{ $t('settings.sections.display') }}
@@ -200,15 +203,15 @@ const setLocale: typeof setNuxti18nLocale = (newLocale) => {
- {{ $t("settings.sections.search") }}
+ {{ $t('settings.sections.search') }}
- {{ $t("settings.data_source.label") }}
+ {{ $t('settings.data_source.label') }}
- {{ $t("settings.data_source.description") }}
+ {{ $t('settings.data_source.description') }}
@@ -238,9 +241,9 @@ const setLocale: typeof setNuxti18nLocale = (newLocale) => {
{{
- preferences.searchProvider === "algolia"
- ? $t("settings.data_source.algolia_description")
- : $t("settings.data_source.npm_description")
+ preferences.searchProvider === 'algolia'
+ ? $t('settings.data_source.algolia_description')
+ : $t('settings.data_source.npm_description')
}}
@@ -252,7 +255,7 @@ const setLocale: typeof setNuxti18nLocale = (newLocale) => {
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-xs text-fg-subtle hover:text-fg-muted transition-colors mt-2"
>
- {{ $t("search.algolia_disclaimer") }}
+ {{ $t('search.algolia_disclaimer') }}
@@ -263,7 +266,7 @@ const setLocale: typeof setNuxti18nLocale = (newLocale) => {
@@ -271,19 +274,19 @@ const setLocale: typeof setNuxti18nLocale = (newLocale) => {
- {{ $t("settings.sections.language") }}
+ {{ $t('settings.sections.language') }}
- {{ $t("settings.language") }}
+ {{ $t('settings.language') }}
{
class="inline-flex items-center gap-2 text-sm text-fg-muted hover:text-fg transition-colors duration-200 focus-visible:outline-accent/70 rounded"
>
- {{ $t("settings.help_translate") }}
+ {{ $t('settings.help_translate') }}
@@ -328,7 +331,7 @@ const setLocale: typeof setNuxti18nLocale = (newLocale) => {
class="font-sans text-fg-muted text-sm"
>
- {{ $t("settings.translation_status") }}
+ {{ $t('settings.translation_status') }}
@@ -337,7 +340,7 @@ const setLocale: typeof setNuxti18nLocale = (newLocale) => {
- {{ $t("settings.sections.keyboard_shortcuts") }}
+ {{ $t('settings.sections.keyboard_shortcuts') }}
-export type AccentColorId = keyof typeof ACCENT_COLORS.light
-export type BackgroundThemeId = keyof typeof BACKGROUND_THEMES
export type ColorModePreference = 'light' | 'dark' | 'system'
export type SearchProvider = 'npm' | 'algolia'
@@ -56,6 +53,7 @@ export const DEFAULT_USER_PREFERENCES: Required>
+export const BACKGROUND_THEME_IDS = ['neutral', 'stone', 'zinc', 'slate', 'black'] as const
+
export const BACKGROUND_THEMES = {
neutral: 'oklch(0.555 0 0)',
stone: 'oklch(0.555 0.013 58.123)',
@@ -100,6 +102,8 @@ export const BACKGROUND_THEMES = {
black: 'oklch(0.4 0 0)',
} as const
+export type BackgroundThemeId = (typeof BACKGROUND_THEME_IDS)[number]
+
// INFO: Regex for capture groups
export const BLUESKY_URL_EXTRACT_REGEX = /profile\/([^/]+)\/post\/([^/]+)/
export const BSKY_POST_AT_URI_REGEX =
diff --git a/test/e2e/legacy-settings-migration.spec.ts b/test/e2e/legacy-settings-migration.spec.ts
index e739d99730..6fdc59c330 100644
--- a/test/e2e/legacy-settings-migration.spec.ts
+++ b/test/e2e/legacy-settings-migration.spec.ts
@@ -10,6 +10,11 @@ const MIGRATABLE_KEYS = [
'preferredBackgroundTheme',
'selectedLocale',
'relativeDates',
+ 'includeTypesInInstall',
+ 'hidePlatformPackages',
+ 'searchProvider',
+ 'instantSearch',
+ 'keyboardShortcuts',
] as const
async function injectLocalStorage(page: Page, entries: Record) {
@@ -53,6 +58,11 @@ test.describe('Legacy settings migration', () => {
preferredBackgroundTheme: 'slate',
selectedLocale: 'de',
relativeDates: true,
+ includeTypesInInstall: false,
+ hidePlatformPackages: false,
+ searchProvider: 'npm',
+ instantSearch: false,
+ keyboardShortcuts: false,
// non-migratable key should remain untouched
sidebar: { collapsed: ['deps'] },
}
@@ -69,6 +79,11 @@ test.describe('Legacy settings migration', () => {
preferredBackgroundTheme: 'slate',
selectedLocale: 'de',
relativeDates: true,
+ includeTypesInInstall: false,
+ hidePlatformPackages: false,
+ searchProvider: 'npm',
+ instantSearch: false,
+ keyboardShortcuts: false,
})
await verifyLegacyCleaned(page)
diff --git a/test/nuxt/components/ProfileInviteSection.spec.ts b/test/nuxt/components/ProfileInviteSection.spec.ts
index 6b5a897ce7..d83ec25075 100644
--- a/test/nuxt/components/ProfileInviteSection.spec.ts
+++ b/test/nuxt/components/ProfileInviteSection.spec.ts
@@ -1,12 +1,10 @@
import { mockNuxtImport, mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
-import { describe, expect, it, vi, beforeEach } from 'vitest'
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
-const { mockUseAtproto, mockUseProfileLikes } = vi.hoisted(() => ({
- mockUseAtproto: vi.fn(),
+const { mockUseProfileLikes } = vi.hoisted(() => ({
mockUseProfileLikes: vi.fn(),
}))
-mockNuxtImport('useAtproto', () => mockUseAtproto)
mockNuxtImport('useProfileLikes', () => mockUseProfileLikes)
import ProfilePage from '~/pages/profile/[identity]/index.vue'
@@ -19,18 +17,32 @@ registerEndpoint('/api/social/profile/test-handle', () => ({
recordExists: false,
}))
+function mockUseAtproto(
+ overrides: {
+ user?: Ref | null>
+ pending?: Ref
+ logout?: () => Promise
+ } = {},
+) {
+ globalThis.__useAtprotoMock = {
+ user: ref(null),
+ pending: ref(false),
+ logout: vi.fn(),
+ ...overrides,
+ } as UseAtprotoReturn
+}
+
describe('Profile invite section', () => {
beforeEach(() => {
- mockUseAtproto.mockReset()
mockUseProfileLikes.mockReset()
})
+ afterEach(() => {
+ globalThis.__useAtprotoMock = undefined
+ })
+
it('does not show invite section while auth is still loading', async () => {
- mockUseAtproto.mockReturnValue({
- user: ref(null),
- pending: ref(true),
- logout: vi.fn(),
- })
+ mockUseAtproto({ pending: ref(true) })
mockUseProfileLikes.mockReturnValue({
data: ref({ records: [] }),
@@ -45,11 +57,7 @@ describe('Profile invite section', () => {
})
it('shows invite section after auth resolves for non-owner', async () => {
- mockUseAtproto.mockReturnValue({
- user: ref({ handle: 'other-user' }),
- pending: ref(false),
- logout: vi.fn(),
- })
+ mockUseAtproto({ user: ref({ handle: 'other-user' }) })
mockUseProfileLikes.mockReturnValue({
data: ref({ records: [] }),
@@ -64,11 +72,7 @@ describe('Profile invite section', () => {
})
it('does not show invite section for profile owner', async () => {
- mockUseAtproto.mockReturnValue({
- user: ref({ handle: 'test-handle' }),
- pending: ref(false),
- logout: vi.fn(),
- })
+ mockUseAtproto({ user: ref({ handle: 'test-handle' }) })
mockUseProfileLikes.mockReturnValue({
data: ref({ records: [] }),
diff --git a/test/nuxt/components/compare/PackageSelector.spec.ts b/test/nuxt/components/compare/PackageSelector.spec.ts
index 36054fc6a5..a9aa2c202f 100644
--- a/test/nuxt/components/compare/PackageSelector.spec.ts
+++ b/test/nuxt/components/compare/PackageSelector.spec.ts
@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref, shallowRef } from 'vue'
import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
-import type { NpmSearchResponse } from '#shared/types'
import PackageSelector from '~/components/Compare/PackageSelector.vue'
const mockSearchData = shallowRef(null)
diff --git a/test/nuxt/composables/use-install-command.spec.ts b/test/nuxt/composables/use-install-command.spec.ts
index 1350fec6a1..41cdd1fbcc 100644
--- a/test/nuxt/composables/use-install-command.spec.ts
+++ b/test/nuxt/composables/use-install-command.spec.ts
@@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { JsrPackageInfo } from '#shared/types/jsr'
-import { __resetPreferencesForTest } from '../../../app/composables/useUserPreferencesProvider'
describe('useInstallCommand', () => {
beforeEach(() => {
diff --git a/test/unit/app/composables/user-preferences-merge.spec.ts b/test/unit/app/composables/user-preferences-merge.spec.ts
index 9ad5f630f0..2a656c2572 100644
--- a/test/unit/app/composables/user-preferences-merge.spec.ts
+++ b/test/unit/app/composables/user-preferences-merge.spec.ts
@@ -7,62 +7,75 @@ import {
type HydratedUserPreferences,
} from '~/utils/preferences-merge'
-describe('user preferences merge logic', () => {
- const defaults: HydratedUserPreferences = { ...DEFAULT_USER_PREFERENCES }
+const createPrefs = (
+ overrides: Partial = {},
+): HydratedUserPreferences => ({
+ ...DEFAULT_USER_PREFERENCES,
+ ...overrides,
+})
+
+const createServerPrefs = (
+ overrides: Partial = {},
+ includeDefaults = true,
+): UserPreferences => ({
+ ...(includeDefaults ? DEFAULT_USER_PREFERENCES : {}),
+ ...overrides,
+})
+
+const createServerResult = (
+ isNewUser: boolean,
+ preferences: UserPreferences = createServerPrefs(),
+): ServerPreferencesResult => ({
+ preferences,
+ isNewUser,
+})
+describe('user preferences merge logic', () => {
describe('arePreferencesEqual', () => {
it('returns true when all preference keys match', () => {
- const a = { ...defaults, accentColorId: 'rose' }
- const b = { ...defaults, accentColorId: 'rose' }
+ const a = createPrefs({ accentColorId: 'magenta' })
+ const b = createPrefs({ accentColorId: 'magenta' })
expect(arePreferencesEqual(a, b)).toBe(true)
})
it('returns false when a preference key differs', () => {
- const a = { ...defaults, accentColorId: 'rose' }
- const b = { ...defaults, accentColorId: 'amber' }
+ const a = createPrefs({ accentColorId: 'magenta' })
+ const b = createPrefs({ accentColorId: 'amber' })
expect(arePreferencesEqual(a, b)).toBe(false)
})
it('ignores updatedAt when comparing', () => {
- const a = { ...defaults, updatedAt: '2025-01-01T00:00:00Z' }
- const b = { ...defaults, updatedAt: '2026-02-28T12:00:00Z' }
+ const a = createPrefs({ updatedAt: '2025-01-01T00:00:00Z' })
+ const b = createPrefs({ updatedAt: '2026-02-28T12:00:00Z' })
expect(arePreferencesEqual(a, b)).toBe(true)
})
})
describe('first-time user (isNewUser: true)', () => {
it('preserves local preferences when server has no stored prefs', () => {
- const localPrefs: HydratedUserPreferences = {
- ...defaults,
- accentColorId: 'rose',
+ const localPrefs = createPrefs({
+ accentColorId: 'magenta',
colorModePreference: 'dark',
selectedLocale: 'de',
- }
+ })
- const serverResult: ServerPreferencesResult = {
- preferences: { ...DEFAULT_USER_PREFERENCES },
- isNewUser: true,
- }
+ const serverResult = createServerResult(true)
const { merged, shouldPushToServer } = mergePreferences(localPrefs, serverResult)
- expect(merged.accentColorId).toBe('rose')
+ expect(merged.accentColorId).toBe('magenta')
expect(merged.colorModePreference).toBe('dark')
expect(merged.selectedLocale).toBe('de')
expect(shouldPushToServer).toBe(true)
})
it('local prefs are returned unchanged', () => {
- const localPrefs: HydratedUserPreferences = {
- ...defaults,
+ const localPrefs = createPrefs({
relativeDates: true,
keyboardShortcuts: false,
- }
+ })
- const serverResult: ServerPreferencesResult = {
- preferences: { ...DEFAULT_USER_PREFERENCES },
- isNewUser: true,
- }
+ const serverResult = createServerResult(true)
const { merged } = mergePreferences(localPrefs, serverResult)
@@ -72,23 +85,19 @@ describe('user preferences merge logic', () => {
describe('returning user (isNewUser: false)', () => {
it('server preferences override local preferences', () => {
- const localPrefs: HydratedUserPreferences = {
- ...defaults,
- accentColorId: 'rose',
+ const localPrefs = createPrefs({
+ accentColorId: 'magenta',
colorModePreference: 'dark',
- }
-
- const serverPrefs: UserPreferences = {
- ...DEFAULT_USER_PREFERENCES,
- accentColorId: 'amber',
- colorModePreference: 'light',
- updatedAt: '2026-01-15T10:00:00Z',
- }
+ })
- const serverResult: ServerPreferencesResult = {
- preferences: serverPrefs,
- isNewUser: false,
- }
+ const serverResult = createServerResult(
+ false,
+ createServerPrefs({
+ accentColorId: 'amber',
+ colorModePreference: 'light',
+ updatedAt: '2026-01-15T10:00:00Z',
+ }),
+ )
const { merged, shouldPushToServer } = mergePreferences(localPrefs, serverResult)
@@ -98,23 +107,22 @@ describe('user preferences merge logic', () => {
})
it('local preferences fill new keys not yet stored on server (schema migration)', () => {
- const localPrefs: HydratedUserPreferences = {
- ...defaults,
- accentColorId: 'rose',
+ const localPrefs = createPrefs({
+ accentColorId: 'magenta',
selectedLocale: 'ja',
- }
-
- // Simulates a server response from before a new preference key was added:
- // the server has accentColorId but not selectedLocale (added later)
- const serverPrefs: UserPreferences = {
- accentColorId: 'emerald',
- updatedAt: '2026-01-15T10:00:00Z',
- }
-
- const serverResult: ServerPreferencesResult = {
- preferences: serverPrefs,
- isNewUser: false,
- }
+ })
+
+ // Pass `false` to createServerPrefs to simulate a sparse object without defaults
+ const serverResult = createServerResult(
+ false,
+ createServerPrefs(
+ {
+ accentColorId: 'emerald',
+ updatedAt: '2026-01-15T10:00:00Z',
+ },
+ false,
+ ),
+ )
const { merged } = mergePreferences(localPrefs, serverResult)
@@ -125,21 +133,14 @@ describe('user preferences merge logic', () => {
})
it('returning user with default server prefs keeps defaults (not a false first-login)', () => {
- const localPrefs: HydratedUserPreferences = {
- ...defaults,
- accentColorId: 'rose',
- }
-
- // User explicitly saved defaults on another device
- const serverPrefs: UserPreferences = {
- ...DEFAULT_USER_PREFERENCES,
- updatedAt: '2026-02-01T00:00:00Z',
- }
-
- const serverResult: ServerPreferencesResult = {
- preferences: serverPrefs,
- isNewUser: false,
- }
+ const localPrefs = createPrefs({
+ accentColorId: 'magenta',
+ })
+
+ const serverResult = createServerResult(
+ false,
+ createServerPrefs({ updatedAt: '2026-02-01T00:00:00Z' }),
+ )
const { merged, shouldPushToServer } = mergePreferences(localPrefs, serverResult)
From 298ce5c57d6c7543b5277e161fc52c73f03ade7e Mon Sep 17 00:00:00 2001
From: Yevhen Husak
Date: Wed, 1 Apr 2026 11:07:44 +0100
Subject: [PATCH 11/12] fix: hydration mismatch for search and setting page
- fix empty page rendered when search provider is npm in localstorage
---
app/composables/atproto/useAtproto.ts | 10 ++--
app/composables/npm/useSearch.ts | 17 ++++++
app/composables/useUserPreferencesProvider.ts | 4 ++
app/pages/settings.vue | 54 ++++++++++++-------
app/plugins/preferences-sync.client.ts | 8 +--
5 files changed, 60 insertions(+), 33 deletions(-)
diff --git a/app/composables/atproto/useAtproto.ts b/app/composables/atproto/useAtproto.ts
index 0ac27b733f..585ae16360 100644
--- a/app/composables/atproto/useAtproto.ts
+++ b/app/composables/atproto/useAtproto.ts
@@ -14,7 +14,7 @@ declare global {
var __useAtprotoMock: UseAtprotoReturn | undefined
}
-function _useAtprotoImpl(): UseAtprotoReturn {
+function useAtprotoImpl(): UseAtprotoReturn {
if (import.meta.test && globalThis.__useAtprotoMock) {
return globalThis.__useAtprotoMock
}
@@ -39,8 +39,6 @@ function _useAtprotoImpl(): UseAtprotoReturn {
return { user, pending, logout }
}
-// In tests, skip createSharedComposable so each call checks globalThis.__useAtprotoMock fresh.
-// In production, import.meta.test is false and the test branch is tree-shaken.
-export const useAtproto = import.meta.test
- ? _useAtprotoImpl
- : createSharedComposable(_useAtprotoImpl)
+export const useAtproto: () => UseAtprotoReturn = import.meta.test
+ ? useAtprotoImpl
+ : createSharedComposable(useAtprotoImpl)
diff --git a/app/composables/npm/useSearch.ts b/app/composables/npm/useSearch.ts
index 5454683983..c9c22bfd11 100644
--- a/app/composables/npm/useSearch.ts
+++ b/app/composables/npm/useSearch.ts
@@ -138,6 +138,23 @@ export function useSearch(
suggestionsLoading.value = false
}
+ // Bridge SSR payload when provider differs between server and client.
+ // SSR always uses the default provider ('algolia'), but the client may
+ // read a different provider from localStorage. Copy the SSR data to the
+ // client's cache key so useLazyAsyncData can hydrate without a mismatch.
+ if (import.meta.client) {
+ const nuxtApp = useNuxtApp()
+ const q = toValue(query)
+ const provider = toValue(searchProvider)
+ if (nuxtApp.isHydrating && q && provider !== 'algolia') {
+ const ssrKey = `search:algolia:${q}`
+ const clientKey = `search:${provider}:${q}`
+ if (nuxtApp.payload.data[ssrKey] && !nuxtApp.payload.data[clientKey]) {
+ nuxtApp.payload.data[clientKey] = nuxtApp.payload.data[ssrKey]
+ }
+ }
+ }
+
const asyncData = useLazyAsyncData(
() => `search:${toValue(searchProvider)}:${toValue(query)}`,
async (_nuxtApp, { signal }) => {
diff --git a/app/composables/useUserPreferencesProvider.ts b/app/composables/useUserPreferencesProvider.ts
index 8d9f89201e..509cf4449a 100644
--- a/app/composables/useUserPreferencesProvider.ts
+++ b/app/composables/useUserPreferencesProvider.ts
@@ -40,6 +40,8 @@ function createProvider(defaultValue: HydratedUserPreferences) {
async function initSync(): Promise {
if (syncInitialized || import.meta.server) return
+ const { applyStoredColorMode } = useColorModePreference()
+
syncInitialized = true
// Resolve auth + sync dependencies lazily
@@ -105,6 +107,8 @@ function createProvider(defaultValue: HydratedUserPreferences) {
await syncWithServer()
}
+ applyStoredColorMode()
+
watch(
preferences,
newPrefs => {
diff --git a/app/pages/settings.vue b/app/pages/settings.vue
index 8f981750c1..fe580c9796 100644
--- a/app/pages/settings.vue
+++ b/app/pages/settings.vue
@@ -238,26 +238,40 @@ const setLocale: typeof setNuxti18nLocale = newLocale => {
-
-
- {{
- preferences.searchProvider === 'algolia'
- ? $t('settings.data_source.algolia_description')
- : $t('settings.data_source.npm_description')
- }}
-
-
-
-
- {{ $t('search.algolia_disclaimer') }}
-
-
+
+
+
+ {{
+ preferences.searchProvider === 'algolia'
+ ? $t('settings.data_source.algolia_description')
+ : $t('settings.data_source.npm_description')
+ }}
+
+
+ {{ $t('search.algolia_disclaimer') }}
+
+
+
+
+ {{ $t('settings.data_source.algolia_description') }}
+
+
+ {{ $t('search.algolia_disclaimer') }}
+
+
+
+
diff --git a/app/plugins/preferences-sync.client.ts b/app/plugins/preferences-sync.client.ts
index cf58122d6a..3e43e696d6 100644
--- a/app/plugins/preferences-sync.client.ts
+++ b/app/plugins/preferences-sync.client.ts
@@ -1,13 +1,7 @@
export default defineNuxtPlugin({
name: 'preferences-sync',
setup() {
- const { initSync } = useInitUserPreferencesSync()
- const { applyStoredColorMode } = useColorModePreference()
-
- // Apply stored color mode preference early (before components mount)
- applyStoredColorMode()
-
// Initialize server sync for authenticated users
- initSync()
+ void useInitUserPreferencesSync().initSync()
},
})
From 6e4345c7b7ad28269fbdf0805c1f4bc52fd42b8c Mon Sep 17 00:00:00 2001
From: Yevhen Husak
Date: Sat, 4 Apr 2026 16:53:06 +0100
Subject: [PATCH 12/12] 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
---
.../SearchProviderToggle.client.vue | 19 +-
app/composables/npm/search-utils.ts | 27 ++
app/composables/npm/useOrgPackages.ts | 14 +-
app/composables/npm/useSearch.ts | 24 +-
app/composables/npm/useUserPackages.ts | 26 +-
app/composables/useCodeContainer.ts | 14 +
app/composables/useGlobalSearch.ts | 21 +-
app/composables/useUserLocalSettings.ts | 2 +
.../userPreferences/useSearchProvider.ts | 8 +-
.../v/[version]/[...filePath].vue | 4 +-
test/e2e/interactions.spec.ts | 41 ++-
test/fixtures/npm-registry/search/@vue.json | 276 ++++++++++++++++++
12 files changed, 400 insertions(+), 76 deletions(-)
create mode 100644 app/composables/useCodeContainer.ts
create mode 100644 test/fixtures/npm-registry/search/@vue.json
diff --git a/app/components/SearchProviderToggle.client.vue b/app/components/SearchProviderToggle.client.vue
index 4a9033dd24..a3d1589721 100644
--- a/app/components/SearchProviderToggle.client.vue
+++ b/app/components/SearchProviderToggle.client.vue
@@ -2,11 +2,6 @@
const route = useRoute()
const router = useRouter()
const { searchProvider } = useSearchProvider()
-const searchProviderValue = computed(() => {
- const p = normalizeSearchParam(route.query.p)
- if (p === 'npm' || searchProvider.value === 'npm') return 'npm'
- return 'algolia'
-})
const isOpen = shallowRef(false)
const toggleRef = useTemplateRef('toggleRef')
@@ -54,7 +49,7 @@ useEventListener('keydown', event => {
type="button"
role="menuitem"
class="w-full flex items-start gap-3 px-3 py-2.5 rounded-md text-start transition-colors hover:bg-bg-muted"
- :class="[searchProviderValue !== 'algolia' ? 'bg-bg-muted' : '']"
+ :class="[searchProvider !== 'algolia' ? 'bg-bg-muted' : '']"
@click="
() => {
searchProvider = 'npm'
@@ -65,13 +60,13 @@ useEventListener('keydown', event => {
>
{{ $t('settings.data_source.npm') }}
@@ -86,7 +81,7 @@ useEventListener('keydown', event => {
type="button"
role="menuitem"
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"
- :class="[searchProviderValue === 'algolia' ? 'bg-bg-muted' : '']"
+ :class="[searchProvider === 'algolia' ? 'bg-bg-muted' : '']"
@click="
() => {
searchProvider = 'algolia'
@@ -97,13 +92,13 @@ useEventListener('keydown', event => {
>
{{ $t('settings.data_source.algolia') }}
@@ -115,7 +110,7 @@ useEventListener('keydown', event => {
,
+ provider: MaybeRefOrGetter,
+): void {
+ if (import.meta.client) {
+ const nuxtApp = useNuxtApp()
+ const id = toValue(identifier)
+ const p = toValue(provider)
+
+ if (nuxtApp.isHydrating && id && p !== 'algolia') {
+ const ssrKey = `${prefix}:algolia:${id}`
+ const clientKey = `${prefix}:${p}:${id}`
+ if (nuxtApp.payload.data[ssrKey] && !nuxtApp.payload.data[clientKey]) {
+ nuxtApp.payload.data[clientKey] = nuxtApp.payload.data[ssrKey]
+ }
+ }
+ }
+}
+
export function metaToSearchResult(meta: PackageMetaResponse): NpmSearchResult {
return {
package: {
diff --git a/app/composables/npm/useOrgPackages.ts b/app/composables/npm/useOrgPackages.ts
index a26ad4fe8c..0a4a77cbc6 100644
--- a/app/composables/npm/useOrgPackages.ts
+++ b/app/composables/npm/useOrgPackages.ts
@@ -1,3 +1,5 @@
+import { bridgeSearchSSRPayload } from './search-utils'
+
/**
* Fetch all packages for an npm organization.
*
@@ -6,17 +8,13 @@
* 3. Falls back to lightweight server-side package-meta lookups
*/
export function useOrgPackages(orgName: MaybeRefOrGetter) {
- const route = useRoute()
const { searchProvider } = useSearchProvider()
- const searchProviderValue = computed(() => {
- const p = normalizeSearchParam(route.query.p)
- if (p === 'npm' || searchProvider.value === 'npm') return 'npm'
- return 'algolia'
- })
const { getPackagesByName } = useAlgoliaSearch()
+ bridgeSearchSSRPayload('org-packages', orgName, searchProvider)
+
const asyncData = useLazyAsyncData(
- () => `org-packages:${searchProviderValue.value}:${toValue(orgName)}`,
+ () => `org-packages:${searchProvider.value}:${toValue(orgName)}`,
async ({ ssrContext }, { signal }) => {
const org = toValue(orgName)
if (!org) {
@@ -53,7 +51,7 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) {
}
// Fetch metadata + downloads from Algolia (single request via getObjects)
- if (searchProviderValue.value === 'algolia') {
+ if (searchProvider.value === 'algolia') {
try {
const response = await getPackagesByName(packageNames)
if (response.objects.length > 0) {
diff --git a/app/composables/npm/useSearch.ts b/app/composables/npm/useSearch.ts
index c9c22bfd11..dc3188d1f8 100644
--- a/app/composables/npm/useSearch.ts
+++ b/app/composables/npm/useSearch.ts
@@ -1,4 +1,5 @@
import type { SearchProvider } from '#shared/schemas/userPreferences'
+import { bridgeSearchSSRPayload } from './search-utils'
function emptySearchPayload() {
return {
@@ -138,22 +139,7 @@ export function useSearch(
suggestionsLoading.value = false
}
- // Bridge SSR payload when provider differs between server and client.
- // SSR always uses the default provider ('algolia'), but the client may
- // read a different provider from localStorage. Copy the SSR data to the
- // client's cache key so useLazyAsyncData can hydrate without a mismatch.
- if (import.meta.client) {
- const nuxtApp = useNuxtApp()
- const q = toValue(query)
- const provider = toValue(searchProvider)
- if (nuxtApp.isHydrating && q && provider !== 'algolia') {
- const ssrKey = `search:algolia:${q}`
- const clientKey = `search:${provider}:${q}`
- if (nuxtApp.payload.data[ssrKey] && !nuxtApp.payload.data[clientKey]) {
- nuxtApp.payload.data[clientKey] = nuxtApp.payload.data[ssrKey]
- }
- }
- }
+ bridgeSearchSSRPayload('search', query, searchProvider)
const asyncData = useLazyAsyncData(
() => `search:${toValue(searchProvider)}:${toValue(query)}`,
@@ -481,12 +467,14 @@ export function useSearch(
if (import.meta.client && asyncData.data.value?.searchResponse.isStale) {
onMounted(() => {
- asyncData.refresh()
+ void asyncData.refresh()
})
}
+ const { data: _data, ...rest } = asyncData
+
return {
- ...asyncData,
+ ...rest,
data,
isLoadingMore,
hasMore,
diff --git a/app/composables/npm/useUserPackages.ts b/app/composables/npm/useUserPackages.ts
index 59577cefaf..273cf0d997 100644
--- a/app/composables/npm/useUserPackages.ts
+++ b/app/composables/npm/useUserPackages.ts
@@ -1,3 +1,5 @@
+import { bridgeSearchSSRPayload } from './search-utils'
+
/** Default page size for incremental loading (npm registry path) */
const PAGE_SIZE = 50 as const
@@ -19,13 +21,7 @@ const MAX_RESULTS = 250
* ```
*/
export function useUserPackages(username: MaybeRefOrGetter) {
- const route = useRoute()
const { searchProvider } = useSearchProvider()
- const searchProviderValue = computed(() => {
- const p = normalizeSearchParam(route.query.p)
- if (p === 'npm' || searchProvider.value === 'npm') return 'npm'
- return 'algolia'
- })
// this is only used in npm path, but we need to extract it when the composable runs
const { $npmRegistry } = useNuxtApp()
const { searchByOwner } = useAlgoliaSearch()
@@ -35,7 +31,9 @@ export function useUserPackages(username: MaybeRefOrGetter) {
/** Tracks which provider actually served the current data (may differ from
* searchProvider when Algolia returns empty and we fall through to npm) */
- const activeProvider = shallowRef<'npm' | 'algolia'>(searchProviderValue.value)
+ const activeProvider = shallowRef<'npm' | 'algolia'>(searchProvider.value)
+
+ bridgeSearchSSRPayload('user-packages', username, searchProvider)
const cache = shallowRef<{
username: string
@@ -46,14 +44,14 @@ export function useUserPackages(username: MaybeRefOrGetter) {
const isLoadingMore = shallowRef(false)
const asyncData = useLazyAsyncData(
- () => `user-packages:${searchProviderValue.value}:${toValue(username)}`,
+ () => `user-packages:${searchProvider.value}:${toValue(username)}`,
async (_nuxtApp, { signal }) => {
const user = toValue(username)
if (!user) {
return emptySearchResponse()
}
- const provider = searchProviderValue.value
+ const provider = searchProvider.value
// --- Algolia: fetch all at once ---
if (provider === 'algolia') {
@@ -61,7 +59,7 @@ export function useUserPackages(username: MaybeRefOrGetter) {
const response = await searchByOwner(user)
// Guard against stale response (user/provider changed during await)
- if (user !== toValue(username) || provider !== searchProviderValue.value) {
+ if (user !== toValue(username) || provider !== searchProvider.value) {
return emptySearchResponse()
}
@@ -98,7 +96,7 @@ export function useUserPackages(username: MaybeRefOrGetter) {
)
// Guard against stale response (user/provider changed during await)
- if (user !== toValue(username) || provider !== searchProviderValue.value) {
+ if (user !== toValue(username) || provider !== searchProvider.value) {
return emptySearchResponse()
}
@@ -197,7 +195,7 @@ export function useUserPackages(username: MaybeRefOrGetter) {
// asyncdata will automatically rerun due to key, but we need to reset cache/page
// when provider changes
watch(
- () => searchProviderValue.value,
+ () => searchProvider.value,
newProvider => {
cache.value = null
currentPage.value = 1
@@ -231,8 +229,10 @@ export function useUserPackages(username: MaybeRefOrGetter) {
return fetched < available && fetched < MAX_RESULTS
})
+ const { data: _data, ...rest } = asyncData
+
return {
- ...asyncData,
+ ...rest,
/** Reactive package results */
data,
/** Whether currently loading more results */
diff --git a/app/composables/useCodeContainer.ts b/app/composables/useCodeContainer.ts
new file mode 100644
index 0000000000..a4201b5251
--- /dev/null
+++ b/app/composables/useCodeContainer.ts
@@ -0,0 +1,14 @@
+export function useCodeContainer() {
+ const { localSettings } = useUserLocalSettings()
+
+ const codeContainerFull = computed(() => localSettings.value.codeContainerFull)
+
+ function toggleCodeContainer() {
+ localSettings.value.codeContainerFull = !localSettings.value.codeContainerFull
+ }
+
+ return {
+ codeContainerFull,
+ toggleCodeContainer,
+ }
+}
diff --git a/app/composables/useGlobalSearch.ts b/app/composables/useGlobalSearch.ts
index 3bda8fbdad..aa122bc6bf 100644
--- a/app/composables/useGlobalSearch.ts
+++ b/app/composables/useGlobalSearch.ts
@@ -1,4 +1,3 @@
-import { normalizeSearchParam } from '#shared/utils/url'
import { debounce } from 'perfect-debounce'
// Pages that have their own local filter using ?q
@@ -9,11 +8,6 @@ const SEARCH_DEBOUNCE_MS = 100
export function useGlobalSearch(place: 'header' | 'content' = 'content') {
const instantSearch = useInstantSearchPreference()
const { searchProvider } = useSearchProvider()
- const searchProviderValue = computed(() => {
- const p = normalizeSearchParam(route.query.p)
- if (p === 'npm' || searchProvider.value === 'npm') return 'npm'
- return 'algolia'
- })
const router = useRouter()
const route = useRoute()
@@ -36,7 +30,7 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
// This is basically doing instant search as user types
watch(searchQuery, val => {
if (instantSearch.value) {
- commitSearchQuery(val)
+ void commitSearchQuery(val)
}
})
@@ -52,14 +46,15 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
// Updates URL when search query changes (immediately for instantSearch or after Enter hit otherwise)
const updateUrlQueryImpl = (value: string, provider: 'npm' | 'algolia') => {
- const isSameQuery = route.query.q === value && route.query.p === provider
+ const urlProvider = provider === 'npm' ? 'npm' : undefined
+ const isSameQuery = route.query.q === value && route.query.p === urlProvider
// Don't navigate away from pages that use ?q for local filtering
if ((pagesWithLocalFilter.has(route.name as string) && place === 'content') || isSameQuery) {
return
}
if (route.name === 'search') {
- router.replace({
+ void router.replace({
query: {
...route.query,
q: value || undefined,
@@ -68,7 +63,7 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
})
return
}
- router.push({
+ void router.push({
name: 'search',
query: {
q: value,
@@ -87,7 +82,7 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
if (!instantSearch.value) {
updateUrlQueryImpl(searchQuery.value, searchProvider.value)
} else {
- updateUrlQuery.flush()
+ void updateUrlQuery.flush()
}
}
@@ -104,14 +99,14 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
if (!updateUrlQuery.isPending()) {
updateUrlQueryImpl(value, searchProvider.value)
}
- updateUrlQuery(value, searchProvider.value)
+ void updateUrlQuery(value, searchProvider.value)
},
})
return {
model: searchQueryValue,
committedModel: committedSearchQuery,
- provider: searchProviderValue,
+ provider: searchProvider,
startSearch: flushUpdateUrlQuery,
}
}
diff --git a/app/composables/useUserLocalSettings.ts b/app/composables/useUserLocalSettings.ts
index 3ba3909007..444e81ae59 100644
--- a/app/composables/useUserLocalSettings.ts
+++ b/app/composables/useUserLocalSettings.ts
@@ -12,6 +12,7 @@ export interface UserLocalSettings {
anomaliesFixed: boolean
predictionPoints: number
}
+ codeContainerFull: boolean
}
const STORAGE_KEY = 'npmx-settings'
@@ -29,6 +30,7 @@ const DEFAULT_USER_LOCAL_SETTINGS: UserLocalSettings = {
anomaliesFixed: true,
predictionPoints: 4,
},
+ codeContainerFull: false,
}
let localSettingsRef: Ref | null = null
diff --git a/app/composables/userPreferences/useSearchProvider.ts b/app/composables/userPreferences/useSearchProvider.ts
index 0ad5b55a0e..e644145964 100644
--- a/app/composables/userPreferences/useSearchProvider.ts
+++ b/app/composables/userPreferences/useSearchProvider.ts
@@ -1,10 +1,16 @@
import type { SearchProvider } from '#shared/schemas/userPreferences'
+import { normalizeSearchParam } from '#shared/utils/url'
export function useSearchProvider() {
const { preferences } = useUserPreferencesState()
+ const route = useRoute()
const searchProvider = computed({
- get: () => preferences.value.searchProvider ?? 'algolia',
+ get: () => {
+ const p = normalizeSearchParam(route.query.p)
+ if (p === 'npm' || p === 'algolia') return p
+ return preferences.value.searchProvider ?? 'algolia'
+ },
set: (value: SearchProvider) => {
preferences.value.searchProvider = value
},
diff --git a/app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue b/app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue
index fc2bb60c63..b2c536515c 100644
--- a/app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue
+++ b/app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue
@@ -289,10 +289,10 @@ defineOgImageComponent('Default', {
})
onPrehydrate(el => {
- const settingsSaved = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
+ const localSettings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
const container = el.querySelector('#code-page-container')
- if (settingsSaved?.codeContainerFull === true && container) {
+ if (localSettings?.codeContainerFull === true && container) {
container.classList.add('container-full')
}
})
diff --git a/test/e2e/interactions.spec.ts b/test/e2e/interactions.spec.ts
index ebc67e99bb..1313d01a5c 100644
--- a/test/e2e/interactions.spec.ts
+++ b/test/e2e/interactions.spec.ts
@@ -102,15 +102,19 @@ test.describe('Search Pages', () => {
const firstResult = page.locator('[data-result-index="0"]').first()
await expect(firstResult).toBeVisible()
- // Global keyboard navigation works regardless of focus
- // ArrowDown selects the next result
+ // Wait for the @vue org suggestion card to appear
+ const orgSuggestion = page.locator('[data-suggestion-index="0"]')
+ await expect(orgSuggestion).toBeVisible({ timeout: 10000 })
+
+ // ArrowDown focuses the org suggestion card
await page.keyboard.press('ArrowDown')
- // ArrowUp selects the previous result
+ // ArrowUp returns to the search input
await page.keyboard.press('ArrowUp')
- // Enter navigates to the selected result
+ // ArrowDown again, then Enter navigates to the suggestion
// URL is /package/vue or /org/vue or /user/vue. Not /vue
+ await page.keyboard.press('ArrowDown')
await page.keyboard.press('Enter')
await expect(page).toHaveURL(/\/(package|org|user)\/vue/)
})
@@ -130,16 +134,24 @@ test.describe('Search Pages', () => {
await expect(firstResult).toBeVisible()
await expect(secondResult).toBeVisible()
- // ArrowDown from input focuses the first result
+ // Wait for the @vue org suggestion card to appear
+ const orgSuggestion = page.locator('[data-suggestion-index="0"]')
+ await expect(orgSuggestion).toBeVisible({ timeout: 10000 })
+
+ // ArrowDown focuses the org suggestion first
+ await page.keyboard.press('ArrowDown')
+ await expect(orgSuggestion).toBeFocused()
+
+ // Next ArrowDown focuses the first package result
await page.keyboard.press('ArrowDown')
await expect(firstResult).toBeFocused()
- // Second ArrowDown focuses the second result (not a keyword button within the first)
+ // Next ArrowDown focuses the second result (not a keyword button within the first)
await page.keyboard.press('ArrowDown')
await expect(secondResult).toBeFocused()
})
- test('/search?q=vue → ArrowUp from first result returns focus to search input', async ({
+ test('/search?q=vue → ArrowUp from first result navigates back through suggestions to input', async ({
page,
goto,
}) => {
@@ -149,11 +161,22 @@ test.describe('Search Pages', () => {
timeout: 15000,
})
- // Navigate to first result
+ // Wait for the @vue org suggestion card to appear
+ const orgSuggestion = page.locator('[data-suggestion-index="0"]')
+ await expect(orgSuggestion).toBeVisible({ timeout: 10000 })
+
+ // Navigate: suggestion → first package result
+ await page.keyboard.press('ArrowDown')
+ await expect(orgSuggestion).toBeFocused()
+
await page.keyboard.press('ArrowDown')
await expect(page.locator('[data-result-index="0"]').first()).toBeFocused()
- // ArrowUp returns to the search input
+ // ArrowUp goes back to the org suggestion
+ await page.keyboard.press('ArrowUp')
+ await expect(orgSuggestion).toBeFocused()
+
+ // ArrowUp from suggestion returns to the search input
await page.keyboard.press('ArrowUp')
await expect(page.locator('input[type="search"]')).toBeFocused()
})
diff --git a/test/fixtures/npm-registry/search/@vue.json b/test/fixtures/npm-registry/search/@vue.json
new file mode 100644
index 0000000000..f509c5488d
--- /dev/null
+++ b/test/fixtures/npm-registry/search/@vue.json
@@ -0,0 +1,276 @@
+{
+ "objects": [
+ {
+ "downloads": {
+ "monthly": 42528330,
+ "weekly": 10046250
+ },
+ "dependents": "353",
+ "updated": "2026-04-04T05:53:21.032Z",
+ "searchScore": 326.28113,
+ "package": {
+ "name": "@vue/reactivity",
+ "keywords": ["vue"],
+ "version": "3.5.32",
+ "description": "@vue/reactivity",
+ "sanitized_name": "@vue/reactivity",
+ "publisher": {
+ "email": "npm-oidc-no-reply@github.com",
+ "actor": {
+ "name": "yyx990803",
+ "type": "user",
+ "email": "user1@example.com"
+ },
+ "trustedPublisher": {
+ "oidcConfigId": "oidc:6c458fb2-e383-47c3-a8d5-9f222de480c7",
+ "id": "github"
+ },
+ "username": "GitHub Actions"
+ },
+ "maintainers": [
+ {
+ "email": "user1@example.com",
+ "username": "yyx990803"
+ }
+ ],
+ "license": "MIT",
+ "date": "2026-04-03T05:41:19.887Z",
+ "links": {
+ "homepage": "https://github.com/vuejs/core/tree/main/packages/reactivity#readme",
+ "repository": "git+https://github.com/vuejs/core.git",
+ "bugs": "https://github.com/vuejs/core/issues",
+ "npm": "https://www.npmjs.com/package/@vue/reactivity"
+ }
+ },
+ "score": {
+ "final": 326.28113,
+ "detail": {
+ "popularity": 1,
+ "quality": 1,
+ "maintenance": 1
+ }
+ },
+ "flags": {
+ "insecure": 0
+ }
+ },
+ {
+ "downloads": {
+ "monthly": 70415933,
+ "weekly": 16592714
+ },
+ "dependents": "1585",
+ "updated": "2026-04-04T05:54:16.908Z",
+ "searchScore": 323.52698,
+ "package": {
+ "name": "@vue/compiler-sfc",
+ "keywords": ["vue"],
+ "version": "3.5.32",
+ "description": "@vue/compiler-sfc",
+ "sanitized_name": "@vue/compiler-sfc",
+ "publisher": {
+ "email": "npm-oidc-no-reply@github.com",
+ "actor": {
+ "name": "yyx990803",
+ "type": "user",
+ "email": "user1@example.com"
+ },
+ "trustedPublisher": {
+ "oidcConfigId": "oidc:64385cf4-8269-4c1a-878c-1ac2242d2518",
+ "id": "github"
+ },
+ "username": "GitHub Actions"
+ },
+ "maintainers": [
+ {
+ "email": "user1@example.com",
+ "username": "yyx990803"
+ }
+ ],
+ "license": "MIT",
+ "date": "2026-04-03T05:41:12.043Z",
+ "links": {
+ "homepage": "https://github.com/vuejs/core/tree/main/packages/compiler-sfc#readme",
+ "repository": "git+https://github.com/vuejs/core.git",
+ "bugs": "https://github.com/vuejs/core/issues",
+ "npm": "https://www.npmjs.com/package/@vue/compiler-sfc"
+ }
+ },
+ "score": {
+ "final": 323.52698,
+ "detail": {
+ "popularity": 1,
+ "quality": 1,
+ "maintenance": 1
+ }
+ },
+ "flags": {
+ "insecure": 0
+ }
+ },
+ {
+ "downloads": {
+ "monthly": 76938866,
+ "weekly": 17911179
+ },
+ "dependents": "181",
+ "updated": "2026-04-04T05:53:29.454Z",
+ "searchScore": 319.66876,
+ "package": {
+ "name": "@vue/compiler-core",
+ "keywords": ["vue"],
+ "version": "3.5.32",
+ "description": "@vue/compiler-core",
+ "sanitized_name": "@vue/compiler-core",
+ "publisher": {
+ "email": "npm-oidc-no-reply@github.com",
+ "actor": {
+ "name": "yyx990803",
+ "type": "user",
+ "email": "user1@example.com"
+ },
+ "trustedPublisher": {
+ "oidcConfigId": "oidc:117f4cf5-a20b-4bd8-a187-4a12264b0950",
+ "id": "github"
+ },
+ "username": "GitHub Actions"
+ },
+ "maintainers": [
+ {
+ "email": "user1@example.com",
+ "username": "yyx990803"
+ }
+ ],
+ "license": "MIT",
+ "date": "2026-04-03T05:41:04.047Z",
+ "links": {
+ "homepage": "https://github.com/vuejs/core/tree/main/packages/compiler-core#readme",
+ "repository": "git+https://github.com/vuejs/core.git",
+ "bugs": "https://github.com/vuejs/core/issues",
+ "npm": "https://www.npmjs.com/package/@vue/compiler-core"
+ }
+ },
+ "score": {
+ "final": 319.66876,
+ "detail": {
+ "popularity": 1,
+ "quality": 1,
+ "maintenance": 1
+ }
+ },
+ "flags": {
+ "insecure": 0
+ }
+ },
+ {
+ "downloads": {
+ "monthly": 64990041,
+ "weekly": 15337637
+ },
+ "dependents": "18",
+ "updated": "2026-04-04T05:54:11.712Z",
+ "searchScore": 316.99716,
+ "package": {
+ "name": "@vue/compiler-ssr",
+ "keywords": ["vue"],
+ "version": "3.5.32",
+ "description": "@vue/compiler-ssr",
+ "sanitized_name": "@vue/compiler-ssr",
+ "publisher": {
+ "email": "npm-oidc-no-reply@github.com",
+ "actor": {
+ "name": "yyx990803",
+ "type": "user",
+ "email": "user1@example.com"
+ },
+ "trustedPublisher": {
+ "oidcConfigId": "oidc:ba968dda-7e8c-462c-bb5c-93dd5925b1fd",
+ "id": "github"
+ },
+ "username": "GitHub Actions"
+ },
+ "maintainers": [
+ {
+ "email": "user1@example.com",
+ "username": "yyx990803"
+ }
+ ],
+ "license": "MIT",
+ "date": "2026-04-03T05:41:16.007Z",
+ "links": {
+ "homepage": "https://github.com/vuejs/core/tree/main/packages/compiler-ssr#readme",
+ "repository": "git+https://github.com/vuejs/core.git",
+ "bugs": "https://github.com/vuejs/core/issues",
+ "npm": "https://www.npmjs.com/package/@vue/compiler-ssr"
+ }
+ },
+ "score": {
+ "final": 316.99716,
+ "detail": {
+ "popularity": 1,
+ "quality": 1,
+ "maintenance": 1
+ }
+ },
+ "flags": {
+ "insecure": 0
+ }
+ },
+ {
+ "downloads": {
+ "monthly": 76436400,
+ "weekly": 17802103
+ },
+ "dependents": "313",
+ "updated": "2026-04-04T05:53:13.980Z",
+ "searchScore": 316.62955,
+ "package": {
+ "name": "@vue/compiler-dom",
+ "keywords": ["vue"],
+ "version": "3.5.32",
+ "description": "@vue/compiler-dom",
+ "sanitized_name": "@vue/compiler-dom",
+ "publisher": {
+ "email": "npm-oidc-no-reply@github.com",
+ "actor": {
+ "name": "yyx990803",
+ "type": "user",
+ "email": "user1@example.com"
+ },
+ "trustedPublisher": {
+ "oidcConfigId": "oidc:0f300997-f0ce-43a2-9497-61c22541e3b0",
+ "id": "github"
+ },
+ "username": "GitHub Actions"
+ },
+ "maintainers": [
+ {
+ "email": "user1@example.com",
+ "username": "yyx990803"
+ }
+ ],
+ "license": "MIT",
+ "date": "2026-04-03T05:41:07.863Z",
+ "links": {
+ "homepage": "https://github.com/vuejs/core/tree/main/packages/compiler-dom#readme",
+ "repository": "git+https://github.com/vuejs/core.git",
+ "bugs": "https://github.com/vuejs/core/issues",
+ "npm": "https://www.npmjs.com/package/@vue/compiler-dom"
+ }
+ },
+ "score": {
+ "final": 316.62955,
+ "detail": {
+ "popularity": 1,
+ "quality": 1,
+ "maintenance": 1
+ }
+ },
+ "flags": {
+ "insecure": 0
+ }
+ }
+ ],
+ "total": 141849,
+ "time": "2026-04-04T09:30:28.192Z"
+}