fix: address settings-related hydration issues using prehydrate#1952
fix: address settings-related hydration issues using prehydrate#1952Adebesin-Cell wants to merge 8 commits intonpmx-dev:mainfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
📝 WalkthroughSummary by CodeRabbitRelease NotesBug Fixes
New Features
WalkthroughReplaces an SSR-unsafe Changes
Sequence Diagram(s)sequenceDiagram
rect rgba(0,128,255,0.5)
participant Server
end
rect rgba(0,200,83,0.5)
participant Browser
end
rect rgba(255,193,7,0.5)
participant LocalStorage
end
rect rgba(156,39,176,0.5)
participant RootDOM
end
Server->>Server: run prehydrate()
Server-->>RootDOM: set data-search-provider='npm' (if persisted setting == 'npm')
Browser->>Browser: Nuxt app initializes (SSR -> Hydration)
Browser->>LocalStorage: read persisted settings (readFromLocalStorage)
Browser->>Browser: normaliseSettings + merge with DEFAULT_SETTINGS
Browser->>Browser: wait for isHydrating false and app:mounted
Browser-->>Browser: apply merged settings to useState Ref
Browser->>LocalStorage: on settings changes (deep watch) persist updates
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 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: 1
🧹 Nitpick comments (1)
app/composables/useSettings.ts (1)
77-88: Add explicit normalisation after merging persisted settings.The merge preserves stale/legacy values as-is. Please normalise constrained fields (for example
searchProvider,sidebar.collapsed, and numeric config bounds) before returning, so invalid old payloads cannot leak into runtime state.♻️ Suggested hardening
+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 { + 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 } }Based on learnings: "defu only fills in null/undefined keys, so a stale persisted value survives the merge unchanged. Normalisation/migration of stale stored values must be done explicitly after hydration, not via defu defaults."
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: fb8c5219-621a-43fb-9a9f-661ca5a5dd57
📒 Files selected for processing (2)
app/composables/useSettings.tsapp/utils/prehydrate.ts
| 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 } | ||
| } |
There was a problem hiding this comment.
🧩 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 3Repository: 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 -20Repository: 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.tsRepository: 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 -nRepository: 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.tsRepository: 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")
EOFRepository: 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)" appRepository: 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 -20Repository: 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.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
| // 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). |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…hydration # Conflicts: # app/composables/useSettings.ts # app/utils/prehydrate.ts
There was a problem hiding this comment.
🧹 Nitpick comments (2)
app/composables/useSettings.ts (2)
82-96: Docstring mismatches the function's behaviour.The JSDoc for
normaliseSettingsdescribes reading fromlocalStorageand merging with defaults, but that is whatreadFromLocalStoragebelow does.normaliseSettingsonly sanitises thesearchProviderenum and thesidebar.collapsedarray.✏️ Proposed docstring
-/** - * Read settings from localStorage and merge with defaults. - */ +/** + * Sanitise potentially invalid persisted values (e.g. unknown + * `searchProvider` strings, non-string entries in `sidebar.collapsed`). + */ function normaliseSettings(input: AppSettings): AppSettings {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/composables/useSettings.ts` around lines 82 - 96, The JSDoc for normaliseSettings is incorrect: it does not read from localStorage or merge defaults but only sanitises fields; update the comment for the normaliseSettings function to state that it validates and normalises the provided AppSettings (ensuring searchProvider is 'npm' or 'algolia' and that sidebar.collapsed is a string[]), and remove any mention of localStorage or merging defaults (those belong to readFromLocalStorage); keep references to the same symbols (normaliseSettings, AppSettings, sidebar.collapsed, searchProvider) so reviewers can find the change.
140-157: Watcher triggers a redundantlocalStorage.setItemon every hydration.The
watchon line 149 is registered synchronously, thensettings.value = storedruns inside theapp:mountedhook on line 142. Because that reassignment is a mutation, the deep watcher fires and re-serialises the value back tolocalStorageon every page load — even when nothing has actually changed.You can avoid the round-trip by installing the watcher after the stored value has been applied, for example:
♻️ Proposed change
- 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 }, - ) + const startPersisting = () => { + watch( + settings, + value => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(value)) + } catch {} + }, + { deep: true }, + ) + } + + if (nuxtApp.isHydrating) { + nuxtApp.hook('app:mounted', () => { + settings.value = stored + startPersisting() + }) + } else { + settings.value = stored + startPersisting() + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/composables/useSettings.ts` around lines 140 - 157, The watcher on settings is being registered before the stored value is applied during hydration, causing the initial assignment (in nuxtApp.hook('app:mounted') when nuxtApp.isHydrating is true) to trigger an unnecessary localStorage.setItem; to fix, register the watch only after the stored value has been applied — i.e. move the call to watch(settings, ...) so it runs inside the app:mounted hook (when using nuxtApp.hook('app:mounted', () => { settings.value = stored; /* then install watcher */ })) and in the non-hydration branch install the watcher after settings.value = stored; keep using STORAGE_KEY and the same try/catch serialization logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@app/composables/useSettings.ts`:
- Around line 82-96: The JSDoc for normaliseSettings is incorrect: it does not
read from localStorage or merge defaults but only sanitises fields; update the
comment for the normaliseSettings function to state that it validates and
normalises the provided AppSettings (ensuring searchProvider is 'npm' or
'algolia' and that sidebar.collapsed is a string[]), and remove any mention of
localStorage or merging defaults (those belong to readFromLocalStorage); keep
references to the same symbols (normaliseSettings, AppSettings,
sidebar.collapsed, searchProvider) so reviewers can find the change.
- Around line 140-157: The watcher on settings is being registered before the
stored value is applied during hydration, causing the initial assignment (in
nuxtApp.hook('app:mounted') when nuxtApp.isHydrating is true) to trigger an
unnecessary localStorage.setItem; to fix, register the watch only after the
stored value has been applied — i.e. move the call to watch(settings, ...) so it
runs inside the app:mounted hook (when using nuxtApp.hook('app:mounted', () => {
settings.value = stored; /* then install watcher */ })) and in the non-hydration
branch install the watcher after settings.value = stored; keep using STORAGE_KEY
and the same try/catch serialization logic.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 3e0c76ba-3e55-41d2-948f-81f491605b81
📒 Files selected for processing (2)
app/composables/useSettings.tsapp/utils/prehydrate.ts
This is an attempt to fix a sudden flash and weird page display behavior, as seen here
Screen.Recording.2026-03-05.at.22.32.44.mov
The fix swaps useLocalStorage for useState (SSR-safe), paired with a localStorage sync after mount. During hydration, server and client agree on the same default values.
Closes #1948
AFTER FIX:
Screen.Recording.2026-03-05.at.22.32.58.mov