feat: add server-synced user preferences infrastructure (#484)#1189
feat: add server-synced user preferences infrastructure (#484)#1189gusa4grr wants to merge 12 commits intonpmx-dev:mainfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
haven't reviewed the code yet, but on the proposed changes:
The usual pattern is this:
we should then end up in a state where our prefs save to local storage and take effect immediately, but meanwhile get synced to the server in the background. i think we probably also need a toggle somewhere to disable sync, in case you want different prefs per machine. same way chrome works today |
Lunaria Status Overview🌕 This pull request will trigger status changes. Learn moreBy default, every PR changing files present in the Lunaria configuration's You can change this by adding one of the keywords present in the Tracked Files
Warnings reference
|
|
@43081j 👋🏻 Updated the MR description and pushed one more commit. if you have time - please look throught the code 🙂 Much appreciated! I will try to find time during the work week to finalise this, as not much is left 🤞🏻 |
96ad934 to
55c4157
Compare
55c4157 to
262f23d
Compare
add3eba to
3c8b850
Compare
3c8b850 to
955381d
Compare
955381d to
e56d0d9
Compare
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughReplaces the legacy useSettings composable with two new patterns: a client-only useUserLocalSettings for ephemeral UI state and a server-backed user preferences system (provider, client sync, server APIs, KV-backed store, shared schema). Adds localStorage/storage abstractions and many userPreferences composables (accent color, background theme, color mode, search provider, relative dates, keyboard shortcuts), moves components/tests to the new APIs, removes useSettings and usePreferencesProvider, and adds a client plugin to initialise preference sync and apply stored colour mode. Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (6)
app/composables/userPreferences/useUserPreferencesState.ts (1)
1-9: Align the “read-only” wording with the mutable return value.The docstring promises read-only access, but the returned ref is writable. Either clarify the wording or enforce immutability so expectations are consistent.
shared/schemas/userPreferences.ts (1)
35-38: Type definitions could drift from schema.The types
AccentColorIdandBackgroundThemeIdare derived from the constants, whilstColorModePreferenceandSearchProviderare manually defined literals. Consider deriving these from the schema for consistency:♻️ Suggested approach
+import type { InferOutput } from 'valibot' + +// Derive types from schemas to prevent drift +export type ColorModePreference = InferOutput<typeof ColorModePreferenceSchema> +export type SearchProvider = InferOutput<typeof SearchProviderSchema> 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'app/composables/useUserPreferencesSync.client.ts (2)
137-144: Route guard uses fire-and-forget pattern which may lose data.The
void flushPendingSync()call allows navigation to proceed without awaiting the save. If the network request fails or is slow, the user may navigate away before their preferences are persisted.Consider awaiting the flush to ensure data is saved before navigation:
♻️ Suggested fix
function setupRouteGuard(getPreferences: () => UserPreferences): void { router.beforeEach(async (_to, _from, next) => { if (hasPendingChanges && isAuthenticated.value) { - void flushPendingSync(getPreferences()) + await flushPendingSync(getPreferences()) } next() }) }
32-41: Silent error handling may mask connectivity issues.
fetchServerPreferencescatches all errors and returnsnull, making it indistinguishable from "user has no saved preferences" vs "network/server error". Consider logging errors or updating the sync state to reflect fetch failures.server/utils/preferences/user-preferences-store.ts (1)
23-35: Minor: Double timestamp assignment inmerge.Line 30 sets
updatedAt, then line 33 callsthis.set()which setsupdatedAtagain on line 18. This is functionally correct but slightly redundant.♻️ Optional simplification
async merge(did: string, partial: Partial<UserPreferences>): Promise<UserPreferences> { 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) + await this.storage.setItem(did, { + ...merged, + updatedAt: new Date().toISOString(), + }) return merged }app/composables/useUserPreferencesProvider.ts (1)
77-95: Avoid echoing server‑loaded prefs back to the server.
In the auth watcher, assigningpreferences.valuetriggers the deep watch and schedules a sync, even though the data just came from the server. This creates redundant PUTs and can churn timestamps. Consider suppressingscheduleSyncwhile applying server prefs.♻️ Suggested refactor
const isSyncing = computed(() => status.value === 'syncing') const isSynced = computed(() => status.value === 'synced') const hasError = computed(() => status.value === 'error') + let isApplyingServerPrefs = false async function initSync(): Promise<void> { @@ watch( preferences, newPrefs => { - if (isAuthenticated.value) { + if (isAuthenticated.value && !isApplyingServerPrefs) { scheduleSync(newPrefs) } }, { deep: true }, ) watch(isAuthenticated, async newIsAuth => { if (newIsAuth) { const serverPrefs = await loadFromServer() if (serverPrefs) { const merged = { ...defaultValue, ...preferences.value, ...serverPrefs } if (!arePreferencesEqual(preferences.value, merged)) { - preferences.value = merged + isApplyingServerPrefs = true + preferences.value = merged + isApplyingServerPrefs = false } } } })
e56d0d9 to
21882ac
Compare
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (2)
app/pages/settings.vue (1)
66-100: Remove the non‑essential template comment.
Line 66 is descriptive rather than explaining complex logic; consider dropping it to keep templates lean.As per coding guidelines: “Add comments only to explain complex logic or non-obvious implementations”.♻️ Suggested tidy‑up
- <!-- Sync status indicator for authenticated users -->test/nuxt/components/HeaderConnectorModal.spec.ts (1)
104-106: Reset the wholemockUserLocalSettingsobject to avoid cross‑test leakage.
Onlyconnectoris reset; if future tests mutatesidebar, state will bleed between tests.Proposed fix
function resetMockState() { mockState.value = { connected: false, connecting: false, npmUser: null, avatar: null, operations: [], error: null, lastExecutionTime: null, } - mockUserLocalSettings.value.connector = { - autoOpenURL: false, - } + mockUserLocalSettings.value = { + sidebar: { + collapsed: [], + }, + connector: { + autoOpenURL: false, + }, + } }
| const { preferences } = useUserPreferencesState() | ||
| 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) |
There was a problem hiding this comment.
Guard against unset preferences during early boot.
If preferences.value can be null/undefined before hydration, Line 10 will throw and prevent locale initialisation. A small null-guard keeps this safe.
Proposed fix
- const settingsLocale = preferences.value.selectedLocale
+ const settingsLocale = preferences.value?.selectedLocale📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const { preferences } = useUserPreferencesState() | |
| 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) | |
| const { preferences } = useUserPreferencesState() | |
| const settingsLocale = preferences.value?.selectedLocale | |
| const matchedLocale = locales.value.map(l => l.code).find(code => code === settingsLocale) | |
| if (matchedLocale && matchedLocale !== locale.value) { | |
| setLocale(matchedLocale) |
There was a problem hiding this comment.
false positive, it can't be undefined
There was a problem hiding this comment.
@gusa4grr, you're right — my apologies for the noise. useUserPreferencesProvider initialises dataRef via useLocalStorage with DEFAULT_USER_PREFERENCES as the default and mergeDefaults: true, so preferences.value is always a fully-hydrated HydratedUserPreferences object, never null or undefined. The optional-chaining guard was unnecessary.
✏️ Learnings added
Learnt from: gusa4grr
Repo: npmx-dev/npmx.dev PR: 1189
File: app/plugins/i18n-loader.client.ts:9-15
Timestamp: 2026-03-11T00:06:31.110Z
Learning: In npmx-dev/npmx.dev, `useUserPreferencesState()` returns `{ preferences }` where `preferences` is a `RemovableRef<HydratedUserPreferences>` backed by `useLocalStorage` with `DEFAULT_USER_PREFERENCES` as the default value and `mergeDefaults: true`. Therefore `preferences.value` is always a fully-initialised object and can never be `null` or `undefined`. Do not suggest optional chaining (`preferences.value?.`) on accesses to this ref.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
🧠 Learnings used
Learnt from: userquin
Repo: npmx-dev/npmx.dev PR: 900
File: app/pages/cookie-policy.vue:6-14
Timestamp: 2026-02-05T03:17:35.184Z
Learning: In this Nuxt project with nuxtjs/i18n v8, `$t()` is auto-imported and works correctly inside `<script setup>` callbacks (including `useSeoMeta` and `defineOgImageComponent`). It's the established pattern across all pages (index.vue, about.vue, settings.vue, search.vue, cookie-policy.vue) and does not require explicitly destructuring `t` from `useI18n()`.
Learnt from: userquin
Repo: npmx-dev/npmx.dev PR: 900
File: app/pages/cookie-policy.vue:6-14
Timestamp: 2026-02-05T03:23:48.153Z
Learning: In this Nuxt 4 project with nuxtjs/i18n v10, `$t()` (and `$n`, `$d`, etc.) are exposed as globals and work correctly inside `<script setup>` callbacks, including those passed to `useSeoMeta` and `defineOgImageComponent`. This is the established pattern across all pages (index.vue, about.vue, settings.vue, search.vue, cookie-policy.vue) and does not require explicitly destructuring `t` from `useI18n()`.
Learnt from: userquin
Repo: npmx-dev/npmx.dev PR: 1096
File: i18n/locales/es-419.json:34-41
Timestamp: 2026-02-06T14:53:29.361Z
Learning: In the npmx.dev project, when using country locale variants (e.g., es-419, es-ES), the i18n configuration in config/i18n.ts uses vue-i18n's multiple files feature. Country variant files like es-419.json only need to contain translations that differ from the base language file (es.json). The base file is loaded first, then the variant file overlays any keys it defines. This is documented in CONTRIBUTING.md under "Country variants (advanced)".
Learnt from: serhalp
Repo: npmx-dev/npmx.dev PR: 1922
File: shared/types/preferences.ts:282-283
Timestamp: 2026-03-05T00:49:41.549Z
Learning: In npmx-dev/npmx.dev, the preferences hydration in app/composables/usePreferencesProvider.ts uses defu(stored, defaultValue) on onMounted. defu only fills in null/undefined keys, so a stale persisted value (e.g. legacy 'all' pageSize) survives the merge unchanged. Normalisation/migration of stale stored values must be done explicitly after hydration, not via defu defaults.
Learnt from: alexdln
Repo: npmx-dev/npmx.dev PR: 1845
File: app/components/InstantSearch.vue:6-11
Timestamp: 2026-03-03T09:42:56.622Z
Learning: In the npmx.dev project, `onPrehydrate` callbacks consistently use `JSON.parse(localStorage.getItem('npmx-settings') || '{}')` without try-catch error handling. This is the established pattern across all components that read settings during prehydration (e.g., app/components/Settings/BgThemePicker.vue, app/components/Settings/AccentColorPicker.vue, app/components/CollapsibleSection.vue, app/components/InstantSearch.vue). Do not suggest adding try-catch blocks to this pattern unless refactoring all instances project-wide.
| onPrehydrate(el => { | ||
| const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}') | ||
| const id = settings.preferredBackgroundTheme | ||
| const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}') |
There was a problem hiding this comment.
here too we should try/catch the parse call and probably just fall back to {} like we do if it isn't set
21882ac to
7bd9568
Compare
afffd5e to
15f2231
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (2)
test/nuxt/composables/use-install-command.spec.ts (1)
220-222:⚠️ Potential issue | 🟡 MinorAvoid leaking preference state across tests.
useUserPreferencesProvidercaches a module-level ref, so mutatingincludeTypesInInstallhere can persist into subsequent tests even if localStorage is cleared. Reset it after the assertion (or in anafterEach) to keep tests isolated.🧹 Suggested fix
expect(showTypesInInstall.value).toBe(false) expect(fullInstallCommand.value).toBe('npm install express') + + // Reset to default to avoid leaking state to other tests + preferences.value.includeTypesInInstall = trueapp/utils/prehydrate.ts (1)
59-64:⚠️ Potential issue | 🟡 MinorGuard against non-array
collapsedvalue.If
localSettings.sidebar.collapsedexists but isn't an array (e.g., corrupted or migrated data), calling.join(' ')will throw. Consider adding anArray.isArraycheck for defensive coding.🛠️ Suggested fix
let localSettings: Partial<UserLocalSettings> = {} try { localSettings = JSON.parse(localStorage.getItem('npmx-settings') || '{}') } catch {} - document.documentElement.dataset.collapsed = localSettings.sidebar?.collapsed?.join(' ') ?? '' + const collapsed = localSettings.sidebar?.collapsed + document.documentElement.dataset.collapsed = Array.isArray(collapsed) ? collapsed.join(' ') : ''
🧹 Nitpick comments (3)
test/nuxt/components/HeaderConnectorModal.spec.ts (1)
104-107: Consider resetting full mock state for test isolation.Only
connectoris reset here, whilstsidebarretains any mutations from previous tests. Although no current tests modifysidebar, resetting the entire object ensures isolation if tests are added later.♻️ Suggested change
- mockUserLocalSettings.value.connector = { - autoOpenURL: false, - } + mockUserLocalSettings.value = { + sidebar: { + collapsed: [], + }, + connector: { + autoOpenURL: false, + }, + }test/nuxt/components/compare/PackageSelector.spec.ts (1)
112-113: Add existence assertion before usingremoveButton.
Array.prototype.find()returnsT | undefined. The non-null assertion on line 113 will cause a runtime error if the button isn't found. Consider adding an explicit assertion to improve test diagnostics.♻️ Proposed fix
const removeButton = component.findAll('button').find(b => b.find('.i-lucide\\:x').exists()) + expect(removeButton).toBeDefined() await removeButton!.trigger('click')app/composables/useUserPreferencesProvider.ts (1)
20-23: Consider checking object key length for equality.
arePreferencesEqualonly iterates over keys fromDEFAULT_USER_PREFERENCES. If preferences objects contain additional keys (e.g., after schema changes or server-side additions), they won't be compared. Consider also checking that both objects have the same number of relevant keys, or use a more robust deep comparison.function arePreferencesEqual(a: UserPreferences, b: UserPreferences): boolean { const keys = Object.keys(DEFAULT_USER_PREFERENCES) as (keyof typeof DEFAULT_USER_PREFERENCES)[] - return keys.every(key => a[key] === b[key]) + return keys.length === Object.keys(a).filter(k => k in DEFAULT_USER_PREFERENCES).length + && keys.length === Object.keys(b).filter(k => k in DEFAULT_USER_PREFERENCES).length + && keys.every(key => a[key] === b[key]) }
ℹ️ Review info
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (51)
app/components/CollapsibleSection.vueapp/components/Header/ConnectorModal.vueapp/components/Package/TrendsChart.vueapp/components/Package/WeeklyDownloadStats.vueapp/components/Settings/AccentColorPicker.vueapp/components/Settings/BgThemePicker.vueapp/composables/npm/useSearch.tsapp/composables/useConnector.tsapp/composables/useInstallCommand.tsapp/composables/useLocalStorageHashProvider.tsapp/composables/usePackageListPreferences.tsapp/composables/usePreferencesProvider.tsapp/composables/useSettings.tsapp/composables/useUserLocalSettings.tsapp/composables/useUserPreferencesProvider.tsapp/composables/useUserPreferencesSync.client.tsapp/composables/userPreferences/useAccentColor.tsapp/composables/userPreferences/useBackgroundTheme.tsapp/composables/userPreferences/useColorModePreference.tsapp/composables/userPreferences/useInitUserPreferencesSync.tsapp/composables/userPreferences/useKeyboardShortcuts.tsapp/composables/userPreferences/useRelativeDates.tsapp/composables/userPreferences/useSearchProvider.tsapp/composables/userPreferences/useUserPreferencesState.tsapp/composables/userPreferences/useUserPreferencesSyncStatus.tsapp/pages/search.vueapp/pages/settings.vueapp/plugins/i18n-loader.client.tsapp/plugins/preferences-sync.client.tsapp/utils/prehydrate.tsapp/utils/storage.tsi18n/locales/en.jsoni18n/schema.jsonlunaria/files/en-GB.jsonlunaria/files/en-US.jsonmodules/cache.tsnuxt.config.tsserver/api/user/preferences.get.tsserver/api/user/preferences.post.tsserver/api/user/preferences.put.tsserver/utils/preferences/user-preferences-store.tsshared/schemas/userPreferences.tstest/e2e/interactions.spec.tstest/nuxt/components/DateTime.spec.tstest/nuxt/components/HeaderConnectorModal.spec.tstest/nuxt/components/compare/FacetRow.spec.tstest/nuxt/components/compare/PackageSelector.spec.tstest/nuxt/composables/use-install-command.spec.tstest/nuxt/composables/use-keyboard-shortcuts.spec.tstest/nuxt/composables/use-package-list-preferences.spec.tstest/nuxt/composables/use-preferences-provider.spec.ts
💤 Files with no reviewable changes (3)
- app/composables/usePreferencesProvider.ts
- app/composables/useSettings.ts
- test/nuxt/composables/use-preferences-provider.spec.ts
🚧 Files skipped from review as they are similar to previous changes (28)
- server/utils/preferences/user-preferences-store.ts
- app/composables/userPreferences/useAccentColor.ts
- app/composables/userPreferences/useRelativeDates.ts
- app/pages/search.vue
- shared/schemas/userPreferences.ts
- nuxt.config.ts
- app/components/CollapsibleSection.vue
- app/plugins/preferences-sync.client.ts
- app/components/Settings/AccentColorPicker.vue
- app/composables/useUserLocalSettings.ts
- app/composables/userPreferences/useColorModePreference.ts
- test/nuxt/components/compare/FacetRow.spec.ts
- server/api/user/preferences.put.ts
- app/components/Package/WeeklyDownloadStats.vue
- app/composables/userPreferences/useUserPreferencesState.ts
- test/nuxt/composables/use-package-list-preferences.spec.ts
- app/composables/usePackageListPreferences.ts
- server/api/user/preferences.post.ts
- server/api/user/preferences.get.ts
- app/composables/npm/useSearch.ts
- app/plugins/i18n-loader.client.ts
- i18n/locales/en.json
- i18n/schema.json
- test/nuxt/components/DateTime.spec.ts
- app/composables/userPreferences/useUserPreferencesSyncStatus.ts
- lunaria/files/en-GB.json
- app/components/Header/ConnectorModal.vue
- modules/cache.ts
|
here is the GD link with the recording on preview, how it works |
07bfb10 to
6bf0fd1
Compare
There was a problem hiding this comment.
maybe this isnt necessary anymore since isHydrated doesn't exist?
There was a problem hiding this comment.
good catch! removed
|
overall looks good to me can you check each of the coderabbit comments? and we could do with a 2nd maintainer review on this - ill ping the other maintainers |
- 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
- 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 `<ClientOnly>` 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`
- Clean migrated keys from legacy storage after migration - Guard migration with `npmx-prefs-migrated` flag to run only once - Update hydration tests to use `npmx-user-preferences` storage key - Add E2E tests for legacy settings migration
ebb3212 to
1c63c45
Compare
thanks for the review @43081j ! I just added the LS migration from old to new key and E2E tests I am fixing the pipeline and coderabbit issues now. Was planning to ask people in Discord to help with review (which I will do after I make those fixes ⬆️ ). |
|
@43081j addressed comments. Updated MR description so you can read the changes summarised |
|
there is a test failed, looked unrelated at start, but I have an idea what may went wrong. Will check hopefully tomorrow |
Note: I came from a React background, quite a newbie to the Nuxt/Vue ecosystem. Please let me know if any patterns are misplaced. Happy to learn and adjust!
Summary
Closes #484
Persist user preferences to the server for authenticated users so settings follow the user across devices and browsers. Anonymous users continue to use localStorage only.
Previously, all settings lived in a single
useSettingscomposable backed by onenpmx-settingslocalStorage key. This PR splits that into two concerns:npmx-user-preferenceslocalStorage + server API.npmx-settingslocalStorage, never synced.See
app/composables/userPreferences/README.mdfor a developer guide on working with preferences going forward.Breaking Changes
localStorage key migration
User-facing preferences moved from
npmx-settingstonpmx-user-preferences. A one-time pre-hydration migration runs automatically for existing users — no action required from end users. The migration:accentColorId,preferredBackgroundTheme,selectedLocale,relativeDatesfrom legacy → new keynpmx-prefs-migratedflag so it never runs againComposable renames
useSettings()useKeyboardShortcuts()useKeyboardShortcutsPreference()useRelativeDates()useRelativeDatesPreference()useInstantSearch()useInstantSearchPreference()useAccentColor()(from useSettings)useAccentColor()(fromuserPreferences/)useBackgroundTheme()(from useSettings)useBackgroundTheme()(fromuserPreferences/)useSearchProvider()(from useSettings)useSearchProvider()(fromuserPreferences/)Core implementation details
Architecture
User preferences store factory (
useUserPreferencesProvider)useLocalStoragefrom VueUse) keyed onnpmx-user-preferences.dataref, sync status signals (isSyncing,isSynced,hasError), and aninitSync()entry point.__resetPreferencesForTest()to reset singleton state between test runs.Server sync with debounced writes (
useUserPreferencesSync.client.ts)PUT /api/user/preferences).router.beforeEach) flushes pending changes on navigation so no updates are lost during SPA transitions.beforeunloadlistener usesnavigator.sendBeaconto fire-and-forget the latest preferences when the tab closes.beforeunloadlistener are registered once via module-level flags (routeGuardRegistered,beforeUnloadRegistered) to prevent duplicate handlers.'error'(not'idle') so the UI can surface problems.Preference merge strategy (
preferences-merge.ts)arePreferencesEqualcompares only declared preference keys (ignoresupdatedAt).Non-preference local settings (
useUserLocalSettings)useUserLocalSettingscomposable.npmx-settingslocalStorage key for backward compatibility but no longer stores user preferences there.Preferences sync plugin (
preferences-sync.client.ts)i18n-loaderviadependsOn).initSync()to kick off server sync for authenticated users at boot time.Theme select hydration fix
<select>on the settings page in<ClientOnly>with a disabled fallback to prevent SSR hydration mismatches caused by@nuxtjs/color-modeinjecting a value the server can't predict.Sync status indicator on settings page
<Transition>for smooth state changes; wrapped in<ClientOnly>since sync state is client-only.Color mode preference composable (
useColorModePreference)@nuxtjs/color-mode.setColorMode()updates both the preference ref andcolorMode.preferenceatomically.applyStoredColorMode()is called early by the sync plugin to restore the saved mode before components mount.Individual preference composables (split into
userPreferences/)app/composables/userPreferences/:useAccentColor— accent swatch with SSR guard for DOM mutations.useBackgroundTheme— background shade with SSR guard.useColorModePreference— color mode sync (see above).useInstantSearchPreference— togglable instant search (createSharedComposable).useKeyboardShortcutsPreference— togglable keyboard shortcuts withdata-kbd-shortcutsDOM attribute sync (createSharedComposable).useRelativeDatesPreference— read-only computed for relative date display.useSearchProvider— npm/algolia toggle.useUserPreferencesState— read-only access to the reactive preferences ref.useUserPreferencesSyncStatus— exposes sync status signals for UI consumption.useInitUserPreferencesSync— imperativeinitSyncwrapper for the plugin.Legacy settings migration (
prehydrate.ts)onPrehydrate(before Vue hydration) to avoid layout shifts.npmx-settings→npmx-user-preferencesfor keys:accentColorId,preferredBackgroundTheme,selectedLocale,relativeDates.npmx-user-preferences.npmx-settingsand sets anpmx-prefs-migratedflag so the migration never runs again.Shared schema (
shared/schemas/userPreferences.ts)UserPreferencesSchema) used by both client and server for validation.UserPreferences,AccentColorId,BackgroundThemeId,ColorModePreference,SearchProvider), defaults (DEFAULT_USER_PREFERENCES), and the storage base key.localStorage hash provider (
useLocalStorageHashProvider)defudefaults merging.usePackageListPreferencesfor managing list view/column/pagination settings independently of user preferences.Server
API endpoints
GET /api/user/preferences— returns stored preferences ornullfor first-time users (allows client to distinguish "no prefs" from "default prefs").PUT /api/user/preferences— validates body againstUserPreferencesSchema, stores with server-managedupdatedAttimestamp.POST /api/user/preferences— re-exports PUT handler (supportssendBeaconwhich sends POST).Storage (
UserPreferencesStore)useStorageunderuser-preferencesnamespace (Upstash in production,vercel-runtime-cachein preview,fsLitein dev).set()returns the stored preferences (withupdatedAt);merge()delegates toset()after resolving the base.Tests
Unit tests (vitest)
use-keyboard-shortcuts.spec.ts— reactivity, DOM attribute sync.use-package-list-preferences.spec.ts— defaults, localStorage merge, array replacement.use-install-command.spec.ts— updated to useuseUserPreferencesStateand reset singleton between tests.user-preferences-merge.spec.ts— first-login preservation, server-wins for returning users, schema migration fill-in,arePreferencesEqualedge cases.DateTime,FacetRow,HeaderConnectorModal,PackageSelector) updated for new mock paths and composable names.E2E tests (Playwright)
legacy-settings-migration.spec.ts— 10 scenarios covering migration, flag gating, DOM application, edge cases (empty/missing storage, pre-existing user prefs).hydration.spec.ts— updated localStorage key fromnpmx-settings→npmx-user-preferences.interactions.spec.ts— updated localStorage key for keyboard shortcuts disabled state.How to test
Anonymous user flow
npmx-user-preferenceskeyAuthenticated user flow
PUT /api/user/preferencesfires after ~2s debounceLegacy migration
npmx-user-preferencesnow has those values,npmx-settingsno longer contains them, andnpmx-prefs-migratedis'1'Navigation/unload flush
sendBeaconfires in Network tabHydration (no flash)
Automated tests
ToDo list [Done ✅]
searchProvider, which was added while this MR was openconnector, which was added while this MR was openOutstanding questions (mostly due to the lack of experience in Nuxt ecosystem):
npmx-settingsLS, as those will now live in separate place?client.tssuffix for useUserPreferencesSync.client.ts to ensure it is client-side only. Is this the standard convention to prevent server-side execution?preferences-sync.client.tsplugin for nowI noticed initPreferencesOnPrehydrate, which retrieves some settings from LS on the client, but it doesn't appear to support data fetching. Few other places also using
onPrehydrate.I am curious as we can load preferences during SSR too and can hydrate client with the preferences right away (if logged in). What files/places should I look at, any suggestions?
Needs to be discussed [Done ✅]
During the implementation, I identified inconsistent local storage usage across the app:
npmx-color-mode- used for color mode. It is adjustable via the settings page, but is also evaluated bylighthouseand referenced in thenuxt.configcolorModepropertynpmx-list-prefs- used in search to modify the viewing experiencenpmx-settings- contains settings found in/settingsroute as well as unrelated sidebar states on package page (see image below)Based on the feature requirements, I decided to create the
user-preferencesschema specifically for the/settingspage configuration. However, the currentuseSettingshook combines both user-preferences and "sidebar states".I want to align with the team on the execution strategy before finalizing these changes.
Proposed plan:
npmx-color-mode), but connect to user-preference serviceThe solution I am drafting centralizes these options into a single user-preference service. However, if we include items outside of /settings, we need to consider: