Files
fusion/packages/dashboard/app/hooks/useLanguage.ts
gsxdsm 4f6013ff3a test+refactor(i18n): close code-review residuals
- Extract a shared normalizeToSupportedLocale helper in @fusion/i18n used by
  both the CLI env detection and the dashboard navigator detection
  (convertDetectedLanguage), fixing multi-subtag navigator tags (zh-Hans-CN)
  and unifying Chinese script resolution.
- Add a cross-tab storage listener to useLanguage so a language change in one
  tab propagates to others.
- Dedup the namespace lists into packages/i18n/namespaces.json, consumed by
  config.ts and all three build scripts (no more 3-way drift risk).
- Add tests: dashboard i18n/index.ts (document.lang mirror, init non-blocking),
  useLanguage concurrent-hydration race + cross-tab adoption,
  normalizeToSupportedLocale, namespace-source consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:03:16 -07:00

114 lines
3.9 KiB
TypeScript

import { DEFAULT_LOCALE, isLocale, type Locale, SUPPORTED_LOCALES } from "@fusion/core";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { fetchGlobalSettings, updateGlobalSettings } from "../api";
import { LANGUAGE_STORAGE_KEY } from "../i18n";
const isBrowser = typeof window !== "undefined";
function readCachedLanguage(): Locale | undefined {
if (!isBrowser) return undefined;
try {
const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY);
return isLocale(saved) ? saved : undefined;
} catch {
return undefined;
}
}
export interface UseLanguageReturn {
language: Locale;
supportedLocales: readonly Locale[];
setLanguage: (locale: Locale) => void;
}
/**
* Language preference hook. Mirrors useTheme's three-tier persistence:
* - the i18next browser detector seeds the active locale from localStorage,
* - the user's choice writes through to localStorage and server GlobalSettings,
* - server settings hydrate the locale on mount when no local choice exists.
*
* Switching applies in place via `i18n.changeLanguage` — no full-page reload,
* so unsaved state and in-flight agent views survive a language change.
*/
export function useLanguage(): UseLanguageReturn {
const { i18n } = useTranslation();
const resolved = i18n.resolvedLanguage ?? i18n.language;
const initial: Locale = isLocale(resolved) ? resolved : DEFAULT_LOCALE;
const [language, setLanguageState] = useState<Locale>(initial);
const userSetRef = useRef(false);
// Reflect any i18next language change (including programmatic ones) into state.
useEffect(() => {
const handler = (lng: string) => {
if (isLocale(lng)) setLanguageState(lng);
};
i18n.on("languageChanged", handler);
return () => {
i18n.off("languageChanged", handler);
};
}, [i18n]);
// Keep tabs in sync: when another tab changes the persisted language, adopt it
// here too (the storage event only fires in *other* tabs, so this never loops).
useEffect(() => {
if (!isBrowser) return;
const onStorage = (event: StorageEvent) => {
if (
event.key === LANGUAGE_STORAGE_KEY &&
isLocale(event.newValue) &&
event.newValue !== i18n.resolvedLanguage
) {
void i18n.changeLanguage(event.newValue);
}
};
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener("storage", onStorage);
};
}, [i18n]);
// Hydrate from server settings, but never override a local/user choice.
useEffect(() => {
if (!isBrowser) return;
let cancelled = false;
void fetchGlobalSettings()
.then((settings) => {
if (cancelled || userSetRef.current) return;
const serverLocale = isLocale(settings.language) ? settings.language : undefined;
if (serverLocale && !readCachedLanguage() && serverLocale !== i18n.resolvedLanguage) {
void i18n.changeLanguage(serverLocale);
}
})
.catch((error) => {
console.warn("[useLanguage] Failed to hydrate language from global settings", error);
});
return () => {
cancelled = true;
};
}, [i18n]);
const setLanguage = useCallback(
(locale: Locale) => {
userSetRef.current = true;
setLanguageState(locale);
// changeLanguage re-renders the tree in place and the detector caches the
// choice to localStorage; write it explicitly too in case caching is off.
void i18n.changeLanguage(locale);
try {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
} catch {
// localStorage unavailable — server write-through below still runs.
}
void updateGlobalSettings({ language: locale }).catch((error) => {
console.warn("[useLanguage] Failed to persist language to global settings", error);
});
},
[i18n],
);
return { language, supportedLocales: SUPPORTED_LOCALES, setLanguage };
}