From a56ec97ef90fad9d9c10fa68a2aff9d8a2b2ee6d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 14 Jan 2026 21:55:35 +0000 Subject: [PATCH] fix(consent): prevent hydration mismatch + banner flash Do not decide consent during SSR. Read consent cookie after mount and only render the banner once consent is loaded, avoiding both hydration errors and the brief banner flash on reload. Co-authored-by: dennis --- app/components/ConsentBanner.tsx | 6 ++++-- app/components/ConsentProvider.tsx | 21 ++++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) 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};