Skip to content

fix: address settings-related hydration issues using prehydrate#1952

Open
Adebesin-Cell wants to merge 3 commits intonpmx-dev:mainfrom
Adebesin-Cell:fix/hydration
Open

fix: address settings-related hydration issues using prehydrate#1952
Adebesin-Cell wants to merge 3 commits intonpmx-dev:mainfrom
Adebesin-Cell:fix/hydration

Conversation

@Adebesin-Cell
Copy link
Contributor

@Adebesin-Cell Adebesin-Cell commented Mar 5, 2026

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. After mounting, user preferences are read from localStorage and applied.
The existing onPrehydrate script handles DOM-level patches (accent color, bg theme, collapsed sections, etc.) to prevent visual flash before hydration, and now also sets a data-search-provider attribute for the search provider.

  • Replaced useLocalStorage with useState + post-mount localStorage sync in useSettings
  • Deep-merges stored settings with defaults (replicates mergeDefaults behavior)
  • Added searchProvider to the onPrehydrate data attributes in prehydrate.ts

Closes #1948

Test plan

  • Change data source to npm in settings, navigate to ~antfu — no hydration errors or page flashing
  • Verify accent color, background theme, and collapsed sections still apply before hydration (no flash)
  • Confirm settings persist across page reloads
  • Check that toggling settings on the settings page works reactively

AFTER FIX:

Screen.Recording.2026-03-05.at.22.32.58.mov

@vercel
Copy link

vercel bot commented Mar 5, 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 5, 2026 10:50pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Mar 5, 2026 10:50pm
npmx-lunaria Ignored Ignored Mar 5, 2026 10:50pm

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 5, 2026

📝 Walkthrough

Walkthrough

Refactors the useSettings composable to replace an SSR-unsafe useLocalStorage singleton with a useState-backed, hydration-safe approach. Adds normaliseSettings and readFromLocalStorage helpers, a syncInitialized guard, client-side eager read/apply after mount, and a deep watch to persist changes to localStorage. Changes the public API: useSettings() now returns { settings: Ref<AppSettings> }. Also updates prehydration to set dataset.searchProvider = 'npm' when the stored searchProvider is 'npm'.

Possibly related PRs

Suggested reviewers

  • danielroe
  • ghostdevv
  • serhalp
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The PR description clearly explains the changes made to fix hydration issues and references the specific problem addressed.
Linked Issues check ✅ Passed The PR successfully addresses issue #1948 by implementing SSR-safe settings handling and prehydration patches to prevent hydration mismatches and flashing.
Out of Scope Changes check ✅ Passed All changes are directly related to resolving the hydration issues described in #1948; no out-of-scope modifications detected.

✏️ 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: 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

📥 Commits

Reviewing files that changed from the base of the PR and between 58da597 and 70eb969.

📒 Files selected for processing (2)
  • app/composables/useSettings.ts
  • app/utils/prehydrate.ts

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


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 93b45d3c-a3c6-4c57-94fb-0ffc36ea4eea

📥 Commits

Reviewing files that changed from the base of the PR and between 70eb969 and 5da2e86.

📒 Files selected for processing (1)
  • app/composables/useSettings.ts

Comment on lines +90 to +105
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 }
}
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.

@codecov
Copy link

codecov bot commented Mar 5, 2026

Codecov Report

❌ Patch coverage is 88.88889% with 3 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/composables/useSettings.ts 88.88% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

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.

address settings related hydration issues using prehydrate

1 participant