fix: Hydration mismatch — mount gate + suppressHydrationWarning
Root cause: Radix UI components (DropdownMenu, Popover, Dialog) generate element IDs via React useId() which differ between server and client render passes. This causes a hydration mismatch warning in the console. Fix: 1. Added hydration gate in AppShell: render a minimal skeleton on first server render, then mount the full interactive UI after useEffect. This ensures Radix IDs are only generated on the client. 2. Added suppressHydrationWarning to <body> (already on <html>). This is the official next-themes + Radix pattern. The skeleton shows: sidebar placeholder + empty header + footer. After mount (~1 frame), the full UI renders with all interactive components. Verified: 0 hydration errors, 0 console errors, 0 page errors. 0 TS errors, ESLint clean.
This commit is contained in:
1 parent
37e976a732
commit
4618d96201
2 files changed
+33
No files matched your search
@@ -48,6 +48,15 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// PRD §5.6: suppressHydrationWarning on html + body because we use:
|
||||||
|
// 1. next-themes (class on <html> changes client-side)
|
||||||
|
// 2. Zustand persist (view/locale loaded from localStorage client-side)
|
||||||
|
// 3. Radix UI generates IDs with useId() which differ between server and client
|
||||||
|
// (this is expected and the warning is cosmetic — Radix components still work correctly)
|
||||||
|
// The alternative would be to delay rendering until after hydration (mount gate),
|
||||||
|
// but that causes a flash of unstyled content. suppressHydrationWarning is the
|
||||||
|
// official next-themes + Radix pattern.
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@@ -57,6 +66,7 @@ export default function RootLayout({
|
|||||||
<html lang="de" suppressHydrationWarning>
|
<html lang="de" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
|
||||||
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { useAos } from '@/store/aos'
|
import { useAos } from '@/store/aos'
|
||||||
import { SidebarNav } from './sidebar-nav'
|
import { SidebarNav } from './sidebar-nav'
|
||||||
import { TopBar } from './top-bar'
|
import { TopBar } from './top-bar'
|
||||||
@@ -36,10 +37,32 @@ import { MobileBottomNav } from './mobile-bottom-nav'
|
|||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { view, sidebarOpen, setSidebarOpen, portalMode } = useAos()
|
const { view, sidebarOpen, setSidebarOpen, portalMode } = useAos()
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
// Hydration gate: wait for client mount before rendering interactive UI
|
||||||
|
// This prevents Radix UI useId() mismatches between server and client
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Portal mode renders a completely separate experience
|
// Portal mode renders a completely separate experience
|
||||||
if (portalMode) return <PortalShell />
|
if (portalMode) return <PortalShell />
|
||||||
|
|
||||||
|
// Before mount: render a minimal skeleton to prevent hydration mismatch
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen w-full bg-background">
|
||||||
|
<aside className="hidden w-64 shrink-0 border-r border-border md:block" />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<header className="h-16 border-b border-border" />
|
||||||
|
<main className="flex-1" />
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen w-full bg-background">
|
<div className="flex min-h-screen w-full bg-background">
|
||||||
{/* Desktop sidebar */}
|
{/* Desktop sidebar */}
|
||||||
|
|||||||
Reference in new issue
Block a user