Skip to content

feat: add server-synced user preferences infrastructure (#484)#1189

Open
gusa4grr wants to merge 12 commits intonpmx-dev:mainfrom
gusa4grr:feat-484-persist-user-preferences
Open

feat: add server-synced user preferences infrastructure (#484)#1189
gusa4grr wants to merge 12 commits intonpmx-dev:mainfrom
gusa4grr:feat-484-persist-user-preferences

Conversation

@gusa4grr
Copy link
Contributor

@gusa4grr gusa4grr commented Feb 8, 2026

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 useSettings composable backed by one npmx-settings localStorage key. This PR splits that into two concerns:

  • User preferences (synced) — theme, accent color, locale, search provider, keyboard shortcuts, etc. Stored in npmx-user-preferences localStorage + server API.
  • Local settings (device-only) — chart filters, sidebar collapse state, connector config. Remain in npmx-settings localStorage, never synced.

See app/composables/userPreferences/README.md for a developer guide on working with preferences going forward.


Breaking Changes

localStorage key migration

User-facing preferences moved from npmx-settings to npmx-user-preferences. A one-time pre-hydration migration runs automatically for existing users — no action required from end users. The migration:

  • Copies accentColorId, preferredBackgroundTheme, selectedLocale, relativeDates from legacy → new key
  • Does not overwrite values already present in the new key
  • Cleans migrated keys from the legacy store
  • Sets a npmx-prefs-migrated flag so it never runs again

Composable renames

Before After
useSettings() Removed — use individual composables (see below)
useKeyboardShortcuts() useKeyboardShortcutsPreference()
useRelativeDates() useRelativeDatesPreference()
useInstantSearch() useInstantSearchPreference()
useAccentColor() (from useSettings) useAccentColor() (from userPreferences/)
useBackgroundTheme() (from useSettings) useBackgroundTheme() (from userPreferences/)
useSearchProvider() (from useSettings) useSearchProvider() (from userPreferences/)

Core implementation details

Architecture

User preferences store factory (useUserPreferencesProvider)

  • Module-level singleton (useLocalStorage from VueUse) keyed on npmx-user-preferences.
  • Anonymous users: localStorage only. Authenticated users: localStorage as cache, server API as source of truth.
  • Provides reactive data ref, sync status signals (isSyncing, isSynced, hasError), and an initSync() entry point.
  • Includes __resetPreferencesForTest() to reset singleton state between test runs.

Server sync with debounced writes (useUserPreferencesSync.client.ts)

  • Debounces preference changes with a 2-second window before pushing to the server (PUT /api/user/preferences).
  • Route guard (router.beforeEach) flushes pending changes on navigation so no updates are lost during SPA transitions.
  • beforeunload listener uses navigator.sendBeacon to fire-and-forget the latest preferences when the tab closes.
  • Both the route guard and beforeunload listener are registered once via module-level flags (routeGuardRegistered, beforeUnloadRegistered) to prevent duplicate handlers.
  • Network errors during load set status to 'error' (not 'idle') so the UI can surface problems.
  • Singleton sync state ensures all consumers share the same status/error refs.

Preference merge strategy (preferences-merge.ts)

  • First login (new user): local preferences are preserved and pushed to the server, so anonymous customisations survive authentication.
  • Returning user: server preferences take precedence; local values fill in any keys the server doesn't have yet (handles schema evolution).
  • arePreferencesEqual compares only declared preference keys (ignores updatedAt).

Non-preference local settings (useUserLocalSettings)

  • Extracted chart-filter params, sidebar collapse state, connector config, and sparkline animation toggle into a separate useUserLocalSettings composable.
  • Keeps the npmx-settings localStorage key for backward compatibility but no longer stores user preferences there.
  • These settings are purely local and are not synced to the server.

Preferences sync plugin (preferences-sync.client.ts)

  • Registered as a Nuxt client plugin (runs before i18n-loader via dependsOn).
  • Applies the stored color mode preference early (before component mount) to avoid a flash of wrong theme.
  • Calls initSync() to kick off server sync for authenticated users at boot time.

Theme select hydration fix

  • Wrapped the theme <select> on the settings page in <ClientOnly> with a disabled fallback to prevent SSR hydration mismatches caused by @nuxtjs/color-mode injecting a value the server can't predict.

Sync status indicator on settings page

  • Shows cloud-upload (syncing), checkmark (synced), warning (error), or cloud (idle) icons for authenticated users.
  • Uses a <Transition> for smooth state changes; wrapped in <ClientOnly> since sync state is client-only.

Color mode preference composable (useColorModePreference)

  • Bidirectional bridge between the user-preferences store and @nuxtjs/color-mode.
  • setColorMode() updates both the preference ref and colorMode.preference atomically.
  • applyStoredColorMode() is called early by the sync plugin to restore the saved mode before components mount.

Individual preference composables (split into userPreferences/)

  • Each preference concern is a standalone file under 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 with data-kbd-shortcuts DOM 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 — imperative initSync wrapper for the plugin.

Legacy settings migration (prehydrate.ts)

  • Runs inside onPrehydrate (before Vue hydration) to avoid layout shifts.
  • One-time migration from npmx-settingsnpmx-user-preferences for keys: accentColorId, preferredBackgroundTheme, selectedLocale, relativeDates.
  • Does not overwrite keys that already exist in npmx-user-preferences.
  • Cleans migrated keys from npmx-settings and sets a npmx-prefs-migrated flag so the migration never runs again.

Shared schema (shared/schemas/userPreferences.ts)

  • Valibot schema (UserPreferencesSchema) used by both client and server for validation.
  • Exports types (UserPreferences, AccentColorId, BackgroundThemeId, ColorModePreference, SearchProvider), defaults (DEFAULT_USER_PREFERENCES), and the storage base key.

localStorage hash provider (useLocalStorageHashProvider)

  • Generic composable for localStorage-backed reactive objects with defu defaults merging.
  • Arrays are replaced (not deep-merged) to preserve user intent.
  • Used by usePackageListPreferences for managing list view/column/pagination settings independently of user preferences.

Server

API endpoints

  • GET /api/user/preferences — returns stored preferences or null for first-time users (allows client to distinguish "no prefs" from "default prefs").
  • PUT /api/user/preferences — validates body against UserPreferencesSchema, stores with server-managed updatedAt timestamp.
  • POST /api/user/preferences — re-exports PUT handler (supports sendBeacon which sends POST).
  • All endpoints require OAuth session; return 401 if session is invalid.

Storage (UserPreferencesStore)

  • Nitro useStorage under user-preferences namespace (Upstash in production, vercel-runtime-cache in preview, fsLite in dev).
  • set() returns the stored preferences (with updatedAt); merge() delegates to set() 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 use useUserPreferencesState and reset singleton between tests.
  • user-preferences-merge.spec.ts — first-login preservation, server-wins for returning users, schema migration fill-in, arePreferencesEqual edge cases.
  • Component specs (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 from npmx-settingsnpmx-user-preferences.
  • interactions.spec.ts — updated localStorage key for keyboard shortcuts disabled state.

How to test

Anonymous user flow

  1. Open settings, change accent color / theme / toggle keyboard shortcuts
  2. Refresh — settings persist via localStorage
  3. Open DevTools → Application → localStorage → verify npmx-user-preferences key

Authenticated user flow

  1. Sign in via Bluesky OAuth
  2. Change a preference on the settings page
  3. Observe sync indicator (cloud icon → syncing → checkmark)
  4. Open Network tab → confirm PUT /api/user/preferences fires after ~2s debounce
  5. Clear localStorage, refresh → preferences should reload from server

Legacy migration

  1. In DevTools, clear all localStorage
  2. Manually set legacy key:
    localStorage.setItem(
      'npmx-settings',
      JSON.stringify({
        accentColorId: 'violet',
        relativeDates: true,
      }),
    )
  3. Refresh the page
  4. Verify npmx-user-preferences now has those values, npmx-settings no longer contains them, and npmx-prefs-migrated is '1'

Navigation/unload flush

  1. Sign in, change a preference
  2. Immediately navigate to another page (before 2s debounce) → verify the PUT still fires (route guard)
  3. Change a preference and close the tab → verify sendBeacon fires in Network tab

Hydration (no flash)

  1. Set a non-default accent color and background theme
  2. Hard refresh — no flash of default colors before the custom ones appear

Automated tests

pnpm test:unit                    # Unit tests (merge logic, composables)
pnpm test:nuxt                    # Nuxt component tests
pnpm test:browser                 # E2E tests (legacy migration, hydration, interactions)

ToDo list [Done ✅]

  • add support for searchProvider, which was added while this MR was open
  • add support for connector, which was added while this MR was open
  • fully test locally all scenarios
  • properly add localization (if not yet proper 😅 )
  • preload and hydrate settings properly (advice needed. See questions below)
  • handle migration from old to new LS keys
  • handle properly saving data to KV storage on first login/signup

Outstanding questions (mostly due to the lack of experience in Nuxt ecosystem):

  • [Done ✅] - Do we want to cleanup "old" KV pairs from npmx-settings LS, as those will now live in separate place?
  • I used client.ts suffix for useUserPreferencesSync.client.ts to ensure it is client-side only. Is this the standard convention to prevent server-side execution?
  • with this new logic, we need to fetch user preferences immediately upon/before the app loads. what is the recommended approach for this? I created the preferences-sync.client.ts plugin for now

I 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:

image
  • npmx-color-mode - used for color mode. It is adjustable via the settings page, but is also evaluated by lighthouse and referenced in the nuxt.config colorMode property
  • npmx-list-prefs - used in search to modify the viewing experience
  • npmx-settings - contains settings found in /settings route as well as unrelated sidebar states on package page (see image below)
image

Based on the feature requirements, I decided to create the user-preferences schema specifically for the /settings page configuration. However, the current useSettings hook combines both user-preferences and "sidebar states".

I want to align with the team on the execution strategy before finalizing these changes.

Proposed plan:

  • color-mode - retain as-is (npmx-color-mode), but connect to user-preference service
  • npmx-list-prefs - retain as-is for now (or potentially merge into npmx-settings). We can add this to the user-preferences service as a follow-up and remove this LS key then.
  • npmx-settings - remove all user-preference related keys (leaving only the sidebar state). Merge with npmx-settings or leave as-is
  • npmx-user-preferences - new LS key for user preferences. This will serve as the source of truth for anonymous users. For logged-in users, it will sync with the server, where the server state takes precedence on load

The 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:

  • making it clear to the user what parts are persistent settings
  • defining the correct "sync" lifecycle

@vercel
Copy link

vercel bot commented Feb 8, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Mar 11, 2026 0:56am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Mar 11, 2026 0:56am
npmx-lunaria Ignored Ignored Mar 11, 2026 0:56am

Request Review

@43081j
Copy link
Contributor

43081j commented Feb 8, 2026

haven't reviewed the code yet, but on the proposed changes:

  • color-mode - i agree leave it where it is but possibly sync it
  • npmx-list-prefs - keep it clientside since it is likely to be changed often between views
  • npmx-settings - agree
  • npmx-user-preferences - agree

The usual pattern is this:

  • Anything that persists across pages, sync it to the backend
  • Anything that is one-time, temporary, or often toggled (like search filters), do not sync

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

@github-actions
Copy link

github-actions bot commented Feb 9, 2026

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
i18n/locales/en.json Source changed, localizations will be marked as outdated.
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@gusa4grr
Copy link
Contributor Author

gusa4grr commented Feb 9, 2026

@43081j 👋🏻

Updated the MR description and pushed one more commit.
I'll leave it as a draft for now, as I haven't validated all the scenarios, but the core things are working fine!

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 🤞🏻

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 15, 2026

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces 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

  • danielroe
  • alexdln
  • graphieros
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Out of Scope Changes check ❓ Inconclusive Whilst most changes align with #484 objectives, several additions extend beyond core requirements: ClientOnly SSR hydration fixes, sync status UI, colour mode composables, and localStorage adapter utilities represent enhancements or architectural refactoring. Clarify whether SSR hydration fixes, sync status UI indicators, and generic localStorage utilities are considered in-scope or separate follow-up work.
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements all core requirements from issue #484: server-side preference storage via KV store, user-ID binding, private storage, and cross-session/device persistence for authenticated users.
Description check ✅ Passed The pull request description thoroughly explains the changes, including architecture, breaking changes, implementation details, testing instructions, and developer questions. The description directly relates to the changeset which implements server-synced user preferences infrastructure across numerous files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 AccentColorId and BackgroundThemeId are derived from the constants, whilst ColorModePreference and SearchProvider are 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.

fetchServerPreferences catches all errors and returns null, 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 in merge.

Line 30 sets updatedAt, then line 33 calls this.set() which sets updatedAt again 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, assigning preferences.value triggers 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 suppressing scheduleSync while 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
           }
         }
       }
     })

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

♻️ Suggested tidy‑up
-        <!-- Sync status indicator for authenticated users -->
As per coding guidelines: “Add comments only to explain complex logic or non-obvious implementations”.
test/nuxt/components/HeaderConnectorModal.spec.ts (1)

104-106: Reset the whole mockUserLocalSettings object to avoid cross‑test leakage.
Only connector is reset; if future tests mutate sidebar, 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,
+    },
+  }
 }

Comment on lines +9 to +15
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)
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

false positive, it can't be undefined

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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') || '{}')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here too we should try/catch the parse call and probably just fall back to {} like we do if it isn't set

@gusa4grr gusa4grr force-pushed the feat-484-persist-user-preferences branch from 21882ac to 7bd9568 Compare February 16, 2026 14:08
@gusa4grr gusa4grr force-pushed the feat-484-persist-user-preferences branch 2 times, most recently from afffd5e to 15f2231 Compare February 28, 2026 21:27
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
test/nuxt/composables/use-install-command.spec.ts (1)

220-222: ⚠️ Potential issue | 🟡 Minor

Avoid leaking preference state across tests.

useUserPreferencesProvider caches a module-level ref, so mutating includeTypesInInstall here can persist into subsequent tests even if localStorage is cleared. Reset it after the assertion (or in an afterEach) 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 = true
app/utils/prehydrate.ts (1)

59-64: ⚠️ Potential issue | 🟡 Minor

Guard against non-array collapsed value.

If localSettings.sidebar.collapsed exists but isn't an array (e.g., corrupted or migrated data), calling .join(' ') will throw. Consider adding an Array.isArray check 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 connector is reset here, whilst sidebar retains any mutations from previous tests. Although no current tests modify sidebar, 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 using removeButton.

Array.prototype.find() returns T | 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.

arePreferencesEqual only iterates over keys from DEFAULT_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

📥 Commits

Reviewing files that changed from the base of the PR and between eec29e5 and 15f2231.

📒 Files selected for processing (51)
  • app/components/CollapsibleSection.vue
  • app/components/Header/ConnectorModal.vue
  • app/components/Package/TrendsChart.vue
  • app/components/Package/WeeklyDownloadStats.vue
  • app/components/Settings/AccentColorPicker.vue
  • app/components/Settings/BgThemePicker.vue
  • app/composables/npm/useSearch.ts
  • app/composables/useConnector.ts
  • app/composables/useInstallCommand.ts
  • app/composables/useLocalStorageHashProvider.ts
  • app/composables/usePackageListPreferences.ts
  • app/composables/usePreferencesProvider.ts
  • app/composables/useSettings.ts
  • app/composables/useUserLocalSettings.ts
  • app/composables/useUserPreferencesProvider.ts
  • app/composables/useUserPreferencesSync.client.ts
  • app/composables/userPreferences/useAccentColor.ts
  • app/composables/userPreferences/useBackgroundTheme.ts
  • app/composables/userPreferences/useColorModePreference.ts
  • app/composables/userPreferences/useInitUserPreferencesSync.ts
  • app/composables/userPreferences/useKeyboardShortcuts.ts
  • app/composables/userPreferences/useRelativeDates.ts
  • app/composables/userPreferences/useSearchProvider.ts
  • app/composables/userPreferences/useUserPreferencesState.ts
  • app/composables/userPreferences/useUserPreferencesSyncStatus.ts
  • app/pages/search.vue
  • app/pages/settings.vue
  • app/plugins/i18n-loader.client.ts
  • app/plugins/preferences-sync.client.ts
  • app/utils/prehydrate.ts
  • app/utils/storage.ts
  • i18n/locales/en.json
  • i18n/schema.json
  • lunaria/files/en-GB.json
  • lunaria/files/en-US.json
  • modules/cache.ts
  • nuxt.config.ts
  • server/api/user/preferences.get.ts
  • server/api/user/preferences.post.ts
  • server/api/user/preferences.put.ts
  • server/utils/preferences/user-preferences-store.ts
  • shared/schemas/userPreferences.ts
  • test/e2e/interactions.spec.ts
  • test/nuxt/components/DateTime.spec.ts
  • test/nuxt/components/HeaderConnectorModal.spec.ts
  • test/nuxt/components/compare/FacetRow.spec.ts
  • test/nuxt/components/compare/PackageSelector.spec.ts
  • test/nuxt/composables/use-install-command.spec.ts
  • test/nuxt/composables/use-keyboard-shortcuts.spec.ts
  • test/nuxt/composables/use-package-list-preferences.spec.ts
  • test/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

@gusa4grr
Copy link
Contributor Author

here is the GD link with the recording on preview, how it works
https://drive.google.com/file/d/1C7Lq_QyvwzyN7qjZfnjFyVga6nUVvu_C/view?usp=sharing

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe this isnt necessary anymore since isHydrated doesn't exist?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch! removed

@43081j
Copy link
Contributor

43081j commented Mar 9, 2026

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
@gusa4grr
Copy link
Contributor Author

gusa4grr commented Mar 10, 2026

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

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 ⬆️ ).
still will appreciate you spreading the word ❤️

@gusa4grr
Copy link
Contributor Author

@43081j addressed comments. Updated MR description so you can read the changes summarised

@gusa4grr
Copy link
Contributor Author

there is a test failed, looked unrelated at start, but I have an idea what may went wrong. Will check hopefully tomorrow

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Persisted user preferences

2 participants