109 lines
3.4 KiB
TypeScript
109 lines
3.4 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
} from "react";
|
|
import { I18nextProvider } from "react-i18next";
|
|
import { createI18nInstance } from "@/lib/i18n/createI18nInstance";
|
|
import {
|
|
getDirection,
|
|
type AppLanguage,
|
|
} from "@/lib/i18n/registry";
|
|
import {
|
|
persistLanguagePreference,
|
|
readStoredLanguagePreference,
|
|
} from "@/lib/i18n/clientLanguage";
|
|
import { syncDocumentLanguage } from "@/lib/i18n/syncDocumentLanguage";
|
|
|
|
type LanguageContextValue = {
|
|
language: AppLanguage;
|
|
dir: "rtl" | "ltr";
|
|
isRtl: boolean;
|
|
ready: boolean;
|
|
setLanguage: (lang: AppLanguage) => Promise<void>;
|
|
};
|
|
|
|
const LanguageContext = createContext<LanguageContextValue | null>(null);
|
|
|
|
export function LanguageProvider({
|
|
children,
|
|
initialLanguage,
|
|
}: {
|
|
children: React.ReactNode;
|
|
initialLanguage: AppLanguage;
|
|
}) {
|
|
// One i18next instance per render of this component — since Next.js renders
|
|
// "use client" components fresh on the server for each request (not from a
|
|
// shared module-level singleton), this naturally gives every request its own
|
|
// isolated instance instead of sharing one across the whole server process.
|
|
// That's what actually fixes the hydration mismatch: the old shared singleton
|
|
// was stuck on the server's default language for the life of the process,
|
|
// so every visitor's page content server-rendered in that language and then
|
|
// flipped to the real one after hydration.
|
|
const [instance] = useState(() => createI18nInstance(initialLanguage));
|
|
|
|
// initialLanguage is resolved server-side (cookie → Accept-Language → default)
|
|
// and passed down as a prop, so server and first client render agree from the
|
|
// start — no more guessing a different value on the client than what the
|
|
// server already committed to in the HTML.
|
|
const [language, setLanguageState] = useState<AppLanguage>(initialLanguage);
|
|
const [ready, setReady] = useState(true);
|
|
|
|
useEffect(() => {
|
|
// A stored preference (from an earlier explicit choice) can still disagree
|
|
// with initialLanguage — e.g. localStorage survived a cleared cookie. Only
|
|
// switch in that case; otherwise there's nothing to reconcile since the
|
|
// server already rendered the right language.
|
|
const stored = readStoredLanguagePreference();
|
|
if (stored && stored !== initialLanguage) {
|
|
void instance.changeLanguage(stored).then(() => {
|
|
setLanguageState(stored);
|
|
syncDocumentLanguage(stored);
|
|
});
|
|
} else if (!stored) {
|
|
persistLanguagePreference(initialLanguage);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const setLanguage = useCallback(
|
|
async (lang: AppLanguage) => {
|
|
await instance.changeLanguage(lang);
|
|
persistLanguagePreference(lang);
|
|
setLanguageState(lang);
|
|
syncDocumentLanguage(lang);
|
|
},
|
|
[instance]
|
|
);
|
|
|
|
const value = useMemo<LanguageContextValue>(
|
|
() => ({
|
|
language,
|
|
dir: getDirection(language),
|
|
isRtl: getDirection(language) === "rtl",
|
|
ready,
|
|
setLanguage,
|
|
}),
|
|
[language, ready, setLanguage]
|
|
);
|
|
|
|
return (
|
|
<I18nextProvider i18n={instance}>
|
|
<LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>
|
|
</I18nextProvider>
|
|
);
|
|
}
|
|
|
|
export function useAppLanguage() {
|
|
const ctx = useContext(LanguageContext);
|
|
if (!ctx) {
|
|
throw new Error("useAppLanguage must be used within LanguageProvider");
|
|
}
|
|
return ctx;
|
|
}
|