Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 73 additions & 11 deletions app/composables/useSettings.ts
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'
Expand Down Expand Up @@ -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 }
}
Comment on lines +92 to +107
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== DEFAULT_SETTINGS definition ==="
rg -n "DEFAULT_SETTINGS\s*=" app/composables/useSettings.ts -A 15

echo -e "\n=== normaliseSettings function ==="
rg -n "normaliseSettings" app/composables/useSettings.ts -B 2 -A 10

echo -e "\n=== Full readFromLocalStorage function ==="
sed -n '90,106p' app/composables/useSettings.ts

echo -e "\n=== useState initialisation ==="
sed -n '117,125p' app/composables/useSettings.ts

echo -e "\n=== CollapsibleSection.vue mutation ==="
rg -n "sidebar\.collapsed\s*=" app/components/CollapsibleSection.vue -B 3 -A 3

Repository: npmx-dev/npmx.dev

Length of output: 96


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Locating useSettings.ts ==="
fd "useSettings" --type f

echo -e "\n=== Locating CollapsibleSection.vue ==="
fd "CollapsibleSection" --type f

echo -e "\n=== Repository structure ==="
ls -la | head -20

Repository: npmx-dev/npmx.dev

Length of output: 1437


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Full useSettings.ts content (checking size first) ==="
wc -l app/composables/useSettings.ts

echo -e "\n=== useSettings.ts - relevant sections ==="
cat -n app/composables/useSettings.ts

Repository: npmx-dev/npmx.dev

Length of output: 9614


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== CollapsibleSection.vue full content ==="
wc -l app/components/CollapsibleSection.vue

echo -e "\n=== CollapsibleSection.vue ==="
cat -n app/components/CollapsibleSection.vue

echo -e "\n=== Search for sidebar.collapsed mutations across codebase ==="
rg "sidebar\.collapsed" app --type=ts --type=vue -n

Repository: npmx-dev/npmx.dev

Length of output: 4904


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for sidebar.collapsed mutations across codebase ==="
rg "sidebar\.collapsed" app -n

echo -e "\n=== Check if normaliseSettings creates new objects ==="
sed -n '77,88p' app/composables/useSettings.ts

Repository: npmx-dev/npmx.dev

Length of output: 1095


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Verify mutations happen after hydration, not during ==="
rg "watch\|computed\|watcher" app/composables/useSettings.ts -n -A 2

echo -e "\n=== Check if any code path prevents the settings.value replacement ==="
sed -n '107,155p' app/composables/useSettings.ts

echo -e "\n=== Verify normaliseSettings creates independent objects ==="
python3 << 'EOF'
# Verify the flow logic
print("Flow analysis:")
print("1. readFromLocalStorage() with localStorage data:")
print("   - Creates NEW nested objects via spread")
print("   - Passes to normaliseSettings()")
print("   - normaliseSettings() creates NEW sidebar object")
print("   - Result: NO shared refs to DEFAULT_SETTINGS")
print()
print("2. readFromLocalStorage() without localStorage:")
print("   - Returns { ...DEFAULT_SETTINGS }")
print("   - Result: SHARED refs to DEFAULT_SETTINGS")
print()
print("3. useState init:")
print("   - Creates { ...DEFAULT_SETTINGS }")
print("   - Result: SHARED refs to DEFAULT_SETTINGS")
print()
print("4. After hydration:")
print("   - settings.value = stored (lines 134/137)")
print("   - Replaces the shallow copy completely")
print()
print("Risk window: During hydration, before lines 134/137")
print("But mutation handlers (like toggle) only run after hydration")
EOF

Repository: npmx-dev/npmx.dev

Length of output: 120


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Full sync section in useSettings.ts ==="
sed -n '118,150p' app/composables/useSettings.ts

echo -e "\n=== Check watchers/event handlers in CollapsibleSection.vue ==="
sed -n '40,62p' app/components/CollapsibleSection.vue

echo -e "\n=== Trace: Does shallow copy ever get mutated directly? ==="
echo "Looking for in-place array mutations on settings.value.sidebar.collapsed..."
rg "sidebar\.collapsed\.(push|pop|splice|shift)" app

Repository: npmx-dev/npmx.dev

Length of output: 1984


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Confirm: Does app:mounted hook fire before or after interactive code? ==="
echo "Checking Nuxt documentation expectations and SSR/hydration lifecycle..."
echo ""
echo "Vue/Nuxt SSR hydration lifecycle:"
echo "1. Server renders initial HTML with default values"
echo "2. Client loads, hydrates with same defaults"
echo "3. app:mounted hook fires"
echo "4. After mount, event listeners become active"
echo ""
echo "So toggle() event handler CANNOT fire before app:mounted hook"
echo "Therefore, settings.value = stored (line 134) executes BEFORE any toggle() calls"
echo ""
echo "=== Verify normaliseSettings in successful localStorage path ==="
sed -n '90,105p' app/composables/useSettings.ts | head -20

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 via app:mounted before 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.


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
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 useState + localStorage pattern. Though I can see some flashes (related to client-side sorting, I believe)

The core issue is that useLocalStorage from VueUse causes hydration mismatches because the server renders with defaults while the client initializes with stored values immediately. Cos right now, when we load https://npmx.dev/~antfu on page load with npm registry as a setting.

The onPrehydrate script handles the visual/DOM side (accent color, background theme, collapsed sections), but the reactive state still needs to reconcile somehow.

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,
}
}

Expand Down
5 changes: 5 additions & 0 deletions app/utils/prehydrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,10 @@ export function initPreferencesOnPrehydrate() {
if (settings.keyboardShortcuts === false) {
document.documentElement.dataset.kbdShortcuts = 'false'
}

// Search provider (default: algolia)
if (settings.searchProvider === 'npm') {
document.documentElement.dataset.searchProvider = 'npm'
}
})
}
Loading