Fix cookie banner disappearing when TinaCloud data loads

Refactored LanguageProvider to avoid remounting children when switching
from static to live translations. Previously, the switch from
LanguageContext.Provider to TinaConnectedProvider caused all children
to unmount/remount, resetting CookieConsent state and hiding the banner
if cookie_consent was already set in localStorage.

New approach: TinaLiveSync is a null-rendering component that calls useTina
and syncs live data back to LanguageProvider via a stable callback, while
LanguageContext.Provider remains the stable root wrapper — children never remount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-12 22:36:18 +00:00
parent 09943e9f5e
commit 2ab22e5efb

View file

@ -1,4 +1,4 @@
import { createContext, useContext, useState, useCallback, useMemo, useEffect, type ReactNode } from 'react';
import { createContext, useContext, useState, useCallback, useMemo, useEffect, useRef, type ReactNode } from 'react';
import { useTina } from 'tinacms/dist/react';
import client from '../../tina/__generated__/client';
import type { Lang, TranslationKey, Translations } from './types';
@ -9,7 +9,6 @@ function flattenObject(obj: Record<string, unknown>, prefix = ''): Record<string
return Object.entries(obj).reduce((acc, [key, val]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (key === '_') {
// Special case: _ maps the value to the parent key path
acc[prefix] = String(val);
} else if (typeof val === 'object' && val !== null) {
Object.assign(acc, flattenObject(val as Record<string, unknown>, fullKey));
@ -43,20 +42,17 @@ function getInitialLang(): Lang {
type TinaQueryResult = { query: string; variables: Record<string, unknown>; data: Record<string, unknown> };
// Inner component — only rendered when both TinaCloud queries are ready.
// useTina is never called with an empty query.
function TinaConnectedProvider({
// Headless component — calls useTina (which requires valid queries) and syncs
// live data back to LanguageProvider via a stable callback. Renders nothing,
// so swapping it in/out never remounts the page children.
function TinaLiveSync({
enResult,
ukResult,
lang,
setLang,
children,
onSync,
}: {
enResult: TinaQueryResult;
ukResult: TinaQueryResult;
lang: Lang;
setLang: (l: Lang) => void;
children: ReactNode;
onSync: (enData: unknown, ukData: unknown) => void;
}) {
const { data: enLiveData } = useTina({
query: enResult.query,
@ -70,6 +66,7 @@ function TinaConnectedProvider({
data: ukResult.data,
});
// Apply design tokens from EN translations
useEffect(() => {
const design = (enLiveData as any).translationsEn?.design;
if (!design) return;
@ -81,25 +78,19 @@ function TinaConnectedProvider({
if (design.colorText) s.setProperty('--light-grey-100', design.colorText);
}, [enLiveData]);
const liveTranslations = useMemo(() => ({
en: flattenObject((enLiveData as any).translationsEn ?? enResult.data) as unknown as Translations,
uk: flattenObject((ukLiveData as any).translationsUk ?? ukResult.data) as unknown as Translations,
}), [enLiveData, ukLiveData, enResult.data, ukResult.data]);
// Sync live translation data back to parent
useEffect(() => {
onSync(enLiveData, ukLiveData);
}, [enLiveData, ukLiveData, onSync]);
const t = useCallback(
(key: TranslationKey): string => liveTranslations[lang][key] ?? liveTranslations.en[key] ?? key,
[lang, liveTranslations]
);
const value = useMemo(() => ({ lang, setLang, t }), [lang, setLang, t]);
return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>;
return null;
}
export function LanguageProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>(getInitialLang);
const [enResult, setEnResult] = useState<TinaQueryResult | null>(null);
const [ukResult, setUkResult] = useState<TinaQueryResult | null>(null);
const [liveTranslations, setLiveTranslations] = useState<Record<Lang, Translations> | null>(null);
useEffect(() => {
client.queries.translationsEn({ relativePath: 'en.json' })
@ -118,22 +109,39 @@ export function LanguageProvider({ children }: { children: ReactNode }) {
document.documentElement.lang = lang;
// Before TinaCloud data loads — use static translations (no useTina calls)
const staticT = useCallback(
(key: TranslationKey): string => staticTranslations[lang][key] ?? staticTranslations.en[key] ?? key,
[lang]
// Stable callback — uses ref to avoid re-creating TinaLiveSync's effect deps
const setLiveRef = useRef(setLiveTranslations);
setLiveRef.current = setLiveTranslations;
const handleSync = useCallback((enData: unknown, ukData: unknown) => {
setLiveRef.current({
en: flattenObject((enData as any).translationsEn ?? {}) as unknown as Translations,
uk: flattenObject((ukData as any).translationsUk ?? {}) as unknown as Translations,
});
}, []);
const translations = liveTranslations ?? staticTranslations;
const t = useCallback(
(key: TranslationKey): string => translations[lang][key] ?? translations.en[key] ?? key,
[lang, translations]
);
const staticValue = useMemo(() => ({ lang, setLang, t: staticT }), [lang, setLang, staticT]);
if (enResult && ukResult) {
return (
<TinaConnectedProvider enResult={enResult} ukResult={ukResult} lang={lang} setLang={setLang}>
{children}
</TinaConnectedProvider>
);
}
const value = useMemo(() => ({ lang, setLang, t }), [lang, setLang, t]);
return <LanguageContext.Provider value={staticValue}>{children}</LanguageContext.Provider>;
return (
<LanguageContext.Provider value={value}>
{/* TinaLiveSync is null-rendering — mounts/unmounts without affecting children */}
{enResult && ukResult && (
<TinaLiveSync
enResult={enResult}
ukResult={ukResult}
onSync={handleSync}
/>
)}
{children}
</LanguageContext.Provider>
);
}
export function useTranslation(): LanguageContextValue {