Skip to content

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

Open
Adebesin-Cell wants to merge 11 commits into
npmx-dev:mainfrom
Adebesin-Cell:fix/hydration
Open

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

Conversation

@Adebesin-Cell
Copy link
Copy Markdown
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.

  • Replaced useLocalStorage with useState + post-mount localStorage sync in useSettings

Closes #1948

AFTER FIX:

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

@vercel
Copy link
Copy Markdown
Contributor

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 May 18, 2026 5:31pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview May 18, 2026 5:31pm
npmx-lunaria Ignored Ignored May 18, 2026 5:31pm

Request Review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 5, 2026

Review Change Stack

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

This pull request consolidates search provider selection logic into a single SSR-safe composable. A new useEffectiveSearchProvider() hook handles hydration safety by deferring persisted settings access until after client hydration completes, preventing hydration-key mismatches. Three consumer composables replace duplicated provider-selection logic with calls to this centralised hook.

Changes

Search Provider SSR Hydration Fix

Layer / File(s) Summary
SSR-safe search provider composable
app/composables/useSettings.ts
useEffectiveSearchProvider() computes the effective search provider from the route query parameter (p), normalising the value, and defers consulting the persisted searchProvider preference until after hydration completes via the app:mounted hook, preventing SSR/hydration mismatches.
Consumer composables adopt centralised provider selection
app/composables/npm/useOrgPackages.ts, app/composables/npm/useUserPackages.ts, app/composables/useGlobalSearch.ts
Three composables replace their local computed logic for provider selection (which normalised route query and conditionally used persisted state) with calls to useEffectiveSearchProvider(), removing duplication and adopting the centralised hydration-safe pattern.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarises the main change: fixing hydration issues in settings using a prehydration approach, which aligns with the core objective of the changeset.
Linked Issues check ✅ Passed The PR directly addresses issue #1948 by introducing useEffectiveSearchProvider() to defer preference consultation until after hydration, ensuring server-client agreement on settings and preventing hydration mismatches.
Out of Scope Changes check ✅ Passed All changes are scoped to fixing hydration issues: new useEffectiveSearchProvider() composable and refactored useOrgPackages, useUserPackages, and useGlobalSearch to use it.
Description check ✅ Passed The PR description accurately identifies the hydration issue being fixed and describes the technical approach, directly relating to the changeset introducing useEffectiveSearchProvider().

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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
Copy Markdown
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

Comment thread app/composables/useSettings.ts Outdated
Copy link
Copy Markdown
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 thread app/composables/useSettings.ts Outdated
@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 5, 2026

Codecov Report

❌ Patch coverage is 37.50000% with 10 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 41.66% 4 Missing and 3 partials ⚠️
app/composables/useGlobalSearch.ts 25.00% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

Comment thread app/composables/useSettings.ts Outdated
Comment on lines +124 to +128
// 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).
Copy link
Copy Markdown
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
Copy Markdown
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.

…hydration

# Conflicts:
#	app/composables/useSettings.ts
#	app/utils/prehydrate.ts
Copy link
Copy Markdown
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.

🧹 Nitpick comments (2)
app/composables/useSettings.ts (2)

82-96: Docstring mismatches the function's behaviour.

The JSDoc for normaliseSettings describes reading from localStorage and merging with defaults, but that is what readFromLocalStorage below does. normaliseSettings only sanitises the searchProvider enum and the sidebar.collapsed array.

✏️ 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 redundant localStorage.setItem on every hydration.

The watch on line 149 is registered synchronously, then settings.value = stored runs inside the app:mounted hook on line 142. Because that reassignment is a mutation, the deep watcher fires and re-serialises the value back to localStorage on 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

📥 Commits

Reviewing files that changed from the base of the PR and between 4729a53 and afe15cc.

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

@ghostdevv ghostdevv requested a review from danielroe May 11, 2026 19:49
…atch on org/user/search

Revert the useSettings/prehydrate refactor and instead fix the actual cause of
the empty-state on /org/[name] when the user has 'npm registry' selected: the
async-data cache key embedded the localStorage-backed search provider, so SSR
keyed by 'algolia' while client hydration keyed by 'npm', missing the SSR
payload and dropping to the empty default.

Introduces useEffectiveSearchProvider which defers the stored preference until
app:mounted, keeping SSR and initial hydration on the same key. After mount the
reactive key flips and useLazyAsyncData refetches once with the user's
preference. URL ?p= still wins everywhere and remains cacheable per-URL.

Closes npmx-dev#2753
Copy link
Copy Markdown
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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/composables/useGlobalSearch.ts`:
- Line 12: The navigation/URL update calls are still passing the reactive stored
setting (searchProvider.value) instead of the effective provider
(searchProviderValue) returned by useEffectiveSearchProvider(), which causes the
?p= override to be clobbered; update every call to updateUrlQueryImpl (and any
navigation functions that build the query) to pass searchProviderValue instead
of searchProvider.value so the effective provider (including URL overrides) is
used when updating the URL.

In `@app/composables/useSettings.ts`:
- Around line 238-243: The computed provider currently defaults to 'algolia'
when storedProviderReady is false, bypassing the established
DEFAULT_SETTINGS.searchProvider; update the computed (the return computed block
using normalizeSearchParam, storedProviderReady and searchProvider) to return
DEFAULT_SETTINGS.searchProvider as the fallback instead of the hard-coded
'algolia' so that when p is not 'npm' or 'algolia' and storedProviderReady is
false it uses DEFAULT_SETTINGS.searchProvider (preserving prior test/mount
behavior and keeping useEffectiveSearchProvider in sync with useSearchProvider).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e27f591d-1b82-451c-ba01-9bc02fc3c7b5

📥 Commits

Reviewing files that changed from the base of the PR and between afe15cc and e1466f8.

📒 Files selected for processing (4)
  • app/composables/npm/useOrgPackages.ts
  • app/composables/npm/useUserPackages.ts
  • app/composables/useGlobalSearch.ts
  • app/composables/useSettings.ts

Comment thread app/composables/useGlobalSearch.ts
Comment thread app/composables/useSettings.ts Outdated
… URL writes

- useEffectiveSearchProvider falls back to DEFAULT_SETTINGS.searchProvider
  (not hardcoded 'algolia') so test env (default 'npm') no longer mismatches
  SSR vs hydration.
- useGlobalSearch URL-write paths now use the effective provider so a
  ?p= override survives subsequent keystrokes/Enter.
@Adebesin-Cell
Copy link
Copy Markdown
Contributor Author

Adebesin-Cell commented May 18, 2026

Hey @danielroe @serhalp, found some time to look into this again.

I reverted the initial useState + post-mount sync approach.

The fix is now scoped to the data-fetching composables (useOrgPackages, useUserPackages, useGlobalSearch).

A new useEffectiveSearchProvider defers the localStorage preference until app:mounted, so SSR and initial hydration share the same async-data cache key (DEFAULT_SETTINGS.searchProvider, or ?p= from the URL when present). After mount, the reactive key flips and useLazyAsyncData refetches once with the user's preference, basically your second suggestion from the issue thread (load with Algolia on first render, swap to npm on client). useSettings itself is unchanged, so pages stay cacheable per-URL.

There's still a bit of delay for the first navigation while the npm provider refetches, worth a separate follow-up to keep the SSR data visible during that swap. Happy to open it as a separate issue.

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

Labels

blocked help wanted Extra attention is needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

address settings related hydration issues using prehydrate

3 participants