-
-
Notifications
You must be signed in to change notification settings - Fork 329
fix: address settings-related hydration issues using prehydrate #1952
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2c22012
70eb969
5da2e86
a682a43
9bcf33c
885aec6
4729a53
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,3 @@ | ||
| import type { RemovableRef } from '@vueuse/core' | ||
| import { useLocalStorage } from '@vueuse/core' | ||
| import { ACCENT_COLORS } from '#shared/utils/constants' | ||
| import type { LocaleObject } from '@nuxtjs/i18n' | ||
| import { BACKGROUND_THEMES } from '#shared/utils/constants' | ||
|
|
@@ -75,22 +73,86 @@ const DEFAULT_SETTINGS: AppSettings = { | |
|
|
||
| const STORAGE_KEY = 'npmx-settings' | ||
|
|
||
| // Shared settings instance (singleton per app) | ||
| let settingsRef: RemovableRef<AppSettings> | null = null | ||
| /** | ||
| * Read settings from localStorage and merge with defaults. | ||
| */ | ||
| function normaliseSettings(input: AppSettings): AppSettings { | ||
| return { | ||
| ...input, | ||
| searchProvider: input.searchProvider === 'npm' ? 'npm' : 'algolia', | ||
| sidebar: { | ||
| ...input.sidebar, | ||
| collapsed: Array.isArray(input.sidebar?.collapsed) | ||
| ? input.sidebar.collapsed.filter((v): v is string => typeof v === 'string') | ||
| : [], | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| function readFromLocalStorage(): AppSettings { | ||
| try { | ||
| const raw = localStorage.getItem(STORAGE_KEY) | ||
| if (raw) { | ||
| const stored = JSON.parse(raw) | ||
| return normaliseSettings({ | ||
| ...DEFAULT_SETTINGS, | ||
| ...stored, | ||
| connector: { ...DEFAULT_SETTINGS.connector, ...stored.connector }, | ||
| sidebar: { ...DEFAULT_SETTINGS.sidebar, ...stored.sidebar }, | ||
| chartFilter: { ...DEFAULT_SETTINGS.chartFilter, ...stored.chartFilter }, | ||
| }) | ||
| } | ||
| } catch {} | ||
| return { ...DEFAULT_SETTINGS } | ||
| } | ||
|
|
||
| let syncInitialized = false | ||
|
|
||
| /** | ||
| * Composable for managing application settings with localStorage persistence. | ||
| * Settings are shared across all components that use this composable. | ||
| * Composable for managing application settings. | ||
| * | ||
| * Uses useState for SSR-safe hydration (server and client agree on initial | ||
| * values during hydration) and syncs with localStorage on the client. | ||
| * The onPrehydrate script in prehydrate.ts handles DOM-level patches | ||
| * (accent color, bg theme, collapsed sections, etc.) to prevent visual | ||
| * flash before hydration. | ||
| */ | ||
| export function useSettings() { | ||
| if (!settingsRef) { | ||
| settingsRef = useLocalStorage<AppSettings>(STORAGE_KEY, DEFAULT_SETTINGS, { | ||
| mergeDefaults: true, | ||
| }) | ||
| const settings = useState<AppSettings>(STORAGE_KEY, () => ({ ...DEFAULT_SETTINGS })) | ||
|
|
||
| if (import.meta.client && !syncInitialized) { | ||
| syncInitialized = true | ||
|
|
||
| // Read localStorage eagerly but apply after mount to prevent hydration | ||
| // mismatch. During hydration, useState provides server-matching defaults. | ||
| // After mount, we swap in the user's actual preferences from localStorage. | ||
| // Uses nuxtApp.hook('app:mounted') instead of onMounted so it works even | ||
| // when useSettings() is first called from a plugin (no component context). | ||
|
Comment on lines
+126
to
+130
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think this is the right fix. right now, with onPrehydrate, in many cases we're able to reflect the user's preferences immediately without any flash after mounting.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, good point. What approach do you think we could look toward here? I tried a few iterations but was only able to get satisfactory results using the The core issue is that The Open to suggestions if you have a different pattern in mind, I might be missing something in the Nuxt hydration lifecycle that could help here. |
||
| const stored = readFromLocalStorage() | ||
| const nuxtApp = useNuxtApp() | ||
|
|
||
| if (nuxtApp.isHydrating) { | ||
| nuxtApp.hook('app:mounted', () => { | ||
| settings.value = stored | ||
| }) | ||
| } else { | ||
| settings.value = stored | ||
| } | ||
|
|
||
| // Persist future changes back to localStorage | ||
| watch( | ||
| settings, | ||
| value => { | ||
| try { | ||
| localStorage.setItem(STORAGE_KEY, JSON.stringify(value)) | ||
| } catch {} | ||
| }, | ||
| { deep: true }, | ||
| ) | ||
| } | ||
|
|
||
| return { | ||
| settings: settingsRef, | ||
| settings, | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 96
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 1437
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 9614
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 4904
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 1095
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 120
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 1984
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 1149
Use deep copies or factory functions for default settings initialisation.
Lines 104 and 119 create shallow copies of
DEFAULT_SETTINGS, leaving nested objects (connector,sidebar,chartFilter) with shared references. Whilst the current hydration strategy replaces these copies viaapp:mountedbefore mutation handlers execute, this design is unnecessarily fragile and depends on strict sequencing of the lifecycle.The fallback path in
readFromLocalStorage()(when localStorage is empty or errors) is particularly vulnerable. A dedicated factory function would ensure all code paths consistently create independent default instances, improving defensive robustness.