diff --git a/app/components/ConsentBanner.tsx b/app/components/ConsentBanner.tsx index 57682cb..1d6f8a9 100644 --- a/app/components/ConsentBanner.tsx +++ b/app/components/ConsentBanner.tsx @@ -5,12 +5,14 @@ import { useConsent, type ConsentState } from "./ConsentProvider"; import { useTranslations } from "next-intl"; export default function ConsentBanner() { - const { consent, setConsent } = useConsent(); + const { consent, ready, setConsent } = useConsent(); const [draft, setDraft] = useState({ analytics: false, chat: false }); const [minimized, setMinimized] = useState(false); const t = useTranslations("consent"); - const shouldShow = consent === null; + // Avoid hydration mismatch + avoid "flash then disappear": + // Only decide whether to show the banner after consent has been read client-side. + const shouldShow = ready && consent === null; if (!shouldShow) return null; const s = { diff --git a/app/components/ConsentProvider.tsx b/app/components/ConsentProvider.tsx index 804ea62..2f1246d 100644 --- a/app/components/ConsentProvider.tsx +++ b/app/components/ConsentProvider.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { createContext, useCallback, useContext, useMemo, useState } from "react"; +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; export type ConsentState = { analytics: boolean; @@ -37,17 +37,28 @@ function writeConsentCookie(value: ConsentState) { const ConsentContext = createContext<{ consent: ConsentState | null; + ready: boolean; setConsent: (next: ConsentState) => void; resetConsent: () => void; }>({ consent: null, + ready: false, setConsent: () => {}, resetConsent: () => {}, }); export function ConsentProvider({ children }: { children: React.ReactNode }) { - // Read cookie synchronously so we don't flash the banner on every reload. - const [consent, setConsentState] = useState(() => readConsentFromCookie()); + // IMPORTANT: + // Don't read `document.cookie` during SSR render (document is undefined), otherwise the + // server will render the banner while the client immediately hides it -> hydration mismatch. + // We resolve consent on the client after mount and only render the banner once `ready=true`. + const [consent, setConsentState] = useState(null); + const [ready, setReady] = useState(false); + + useEffect(() => { + setConsentState(readConsentFromCookie()); + setReady(true); + }, []); const setConsent = useCallback((next: ConsentState) => { setConsentState(next); @@ -61,8 +72,8 @@ export function ConsentProvider({ children }: { children: React.ReactNode }) { }, []); const value = useMemo( - () => ({ consent, setConsent, resetConsent }), - [consent, setConsent, resetConsent], + () => ({ consent, ready, setConsent, resetConsent }), + [consent, ready, setConsent, resetConsent], ); return {children};