From 3dc740c75241560f467f3070d527e66e8bc31acc Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 11 Dec 2025 07:59:52 +0800 Subject: [PATCH] refactor(web): improve locale/theme preference initialization - Extract preference logic into dedicated hooks (useUserLocale, useUserTheme) - Add applyLocaleEarly() for consistent early application - Remove applyUserPreferences() from user store (now redundant) - Simplify App.tsx by moving effects to custom hooks - Make locale/theme handling consistent and reactive - Clean up manual preference calls from sign-in flows Fixes locale not overriding localStorage on user login. Improves maintainability with better separation of concerns. --- web/src/App.tsx | 51 +++---------------- .../Settings/PreferencesSection.tsx | 7 ++- web/src/hooks/index.ts | 2 + web/src/hooks/useUserLocale.ts | 35 +++++++++++++ web/src/hooks/useUserTheme.ts | 37 ++++++++++++++ web/src/main.tsx | 10 ++-- web/src/store/user.ts | 18 ------- web/src/utils/i18n.ts | 48 ++++++++++++++++- 8 files changed, 134 insertions(+), 74 deletions(-) create mode 100644 web/src/hooks/useUserLocale.ts create mode 100644 web/src/hooks/useUserTheme.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 58b7179a4..fac304da5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,19 +1,21 @@ import { observer } from "mobx-react-lite"; import { useEffect } from "react"; -import { useTranslation } from "react-i18next"; import { Outlet } from "react-router-dom"; import useNavigateTo from "./hooks/useNavigateTo"; -import { instanceStore, userStore } from "./store"; +import { useUserLocale } from "./hooks/useUserLocale"; +import { useUserTheme } from "./hooks/useUserTheme"; +import { instanceStore } from "./store"; import { cleanupExpiredOAuthState } from "./utils/oauth"; -import { getThemeWithFallback, loadTheme, setupSystemThemeListener } from "./utils/theme"; const App = observer(() => { - const { i18n } = useTranslation(); const navigateTo = useNavigateTo(); const instanceProfile = instanceStore.state.profile; - const userGeneralSetting = userStore.state.userGeneralSetting; const instanceGeneralSetting = instanceStore.state.generalSetting; + // Apply user preferences reactively + useUserLocale(); + useUserTheme(); + // Clean up expired OAuth states on app initialization useEffect(() => { cleanupExpiredOAuthState(); @@ -54,45 +56,6 @@ const App = observer(() => { link.href = instanceGeneralSetting.customProfile.logoUrl || "/logo.webp"; }, [instanceGeneralSetting.customProfile]); - // Update HTML lang and dir attributes based on current locale - useEffect(() => { - const currentLocale = i18n.language; - document.documentElement.setAttribute("lang", currentLocale); - if (["ar", "fa"].includes(currentLocale)) { - document.documentElement.setAttribute("dir", "rtl"); - } else { - document.documentElement.setAttribute("dir", "ltr"); - } - }, [i18n.language]); - - // Apply theme when user setting changes - useEffect(() => { - if (!userGeneralSetting) { - return; - } - const theme = getThemeWithFallback(userGeneralSetting.theme); - loadTheme(theme); - }, [userGeneralSetting?.theme]); - - // Listen for system theme changes when using "system" theme - useEffect(() => { - const theme = getThemeWithFallback(userGeneralSetting?.theme); - - // Only set up listener if theme is "system" - if (theme !== "system") { - return; - } - - // Set up listener for OS theme preference changes - const cleanup = setupSystemThemeListener(() => { - // Reload theme when system preference changes - loadTheme(theme); - }); - - // Cleanup listener on unmount or when theme changes - return cleanup; - }, [userGeneralSetting?.theme]); - return ; }); diff --git a/web/src/components/Settings/PreferencesSection.tsx b/web/src/components/Settings/PreferencesSection.tsx index 78326e274..d6f2ac72b 100644 --- a/web/src/components/Settings/PreferencesSection.tsx +++ b/web/src/components/Settings/PreferencesSection.tsx @@ -1,10 +1,9 @@ import { observer } from "mobx-react-lite"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import i18n from "@/i18n"; import { userStore } from "@/store"; import { Visibility } from "@/types/proto/api/v1/memo_service"; import { UserSetting_GeneralSetting } from "@/types/proto/api/v1/user_service"; -import { useTranslate } from "@/utils/i18n"; +import { loadLocale, useTranslate } from "@/utils/i18n"; import { convertVisibilityFromString, convertVisibilityToString } from "@/utils/memo"; import { loadTheme } from "@/utils/theme"; import LocaleSelect from "../LocaleSelect"; @@ -20,8 +19,8 @@ const PreferencesSection = observer(() => { const generalSetting = userStore.state.userGeneralSetting; const handleLocaleSelectChange = async (locale: Locale) => { - // Apply locale immediately for instant UI feedback - i18n.changeLanguage(locale); + // Apply locale immediately for instant UI feedback and persist to localStorage + loadLocale(locale); // Persist to user settings await userStore.updateUserGeneralSetting({ locale }, ["locale"]); }; diff --git a/web/src/hooks/index.ts b/web/src/hooks/index.ts index 17d90c0fa..bfd97a59b 100644 --- a/web/src/hooks/index.ts +++ b/web/src/hooks/index.ts @@ -6,3 +6,5 @@ export * from "./useMemoFilters"; export * from "./useMemoSorting"; export * from "./useNavigateTo"; export * from "./useResponsiveWidth"; +export * from "./useUserLocale"; +export * from "./useUserTheme"; diff --git a/web/src/hooks/useUserLocale.ts b/web/src/hooks/useUserLocale.ts new file mode 100644 index 000000000..280752adf --- /dev/null +++ b/web/src/hooks/useUserLocale.ts @@ -0,0 +1,35 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { userStore } from "@/store"; +import { getLocaleWithFallback, loadLocale } from "@/utils/i18n"; + +/** + * Hook that reactively applies user locale preference. + * Priority: User setting → localStorage → browser language + */ +export const useUserLocale = () => { + const { i18n } = useTranslation(); + const userGeneralSetting = userStore.state.userGeneralSetting; + + // Apply locale when user setting changes or user logs in + useEffect(() => { + if (!userGeneralSetting) { + return; + } + const locale = getLocaleWithFallback(userGeneralSetting.locale); + loadLocale(locale); + }, [userGeneralSetting?.locale]); + + // Update HTML lang and dir attributes based on current locale + useEffect(() => { + const currentLocale = i18n.language; + document.documentElement.setAttribute("lang", currentLocale); + + // RTL languages + if (["ar", "fa"].includes(currentLocale)) { + document.documentElement.setAttribute("dir", "rtl"); + } else { + document.documentElement.setAttribute("dir", "ltr"); + } + }, [i18n.language]); +}; diff --git a/web/src/hooks/useUserTheme.ts b/web/src/hooks/useUserTheme.ts new file mode 100644 index 000000000..e1b9aef70 --- /dev/null +++ b/web/src/hooks/useUserTheme.ts @@ -0,0 +1,37 @@ +import { useEffect } from "react"; +import { userStore } from "@/store"; +import { getThemeWithFallback, loadTheme, setupSystemThemeListener } from "@/utils/theme"; + +/** + * Hook that reactively applies user theme preference. + * Priority: User setting → localStorage → system preference + */ +export const useUserTheme = () => { + const userGeneralSetting = userStore.state.userGeneralSetting; + + // Apply theme when user setting changes or user logs in + useEffect(() => { + if (!userGeneralSetting) { + return; + } + const theme = getThemeWithFallback(userGeneralSetting.theme); + loadTheme(theme); + }, [userGeneralSetting?.theme]); + + // Listen for system theme changes when using "system" theme + useEffect(() => { + const theme = getThemeWithFallback(userGeneralSetting?.theme); + + // Only set up listener if theme is "system" + if (theme !== "system") { + return; + } + + // Set up listener for OS theme preference changes + const cleanup = setupSystemThemeListener(() => { + loadTheme(theme); + }); + + return cleanup; + }, [userGeneralSetting?.theme]); +}; diff --git a/web/src/main.tsx b/web/src/main.tsx index 782769c93..0152f7edc 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -9,13 +9,15 @@ import router from "./router"; // Configure MobX before importing any stores import "./store/config"; import { initialInstanceStore } from "./store/instance"; -import userStore, { initialUserStore } from "./store/user"; +import { initialUserStore } from "./store/user"; +import { applyLocaleEarly } from "./utils/i18n"; import { applyThemeEarly } from "./utils/theme"; import "leaflet/dist/leaflet.css"; -// Apply theme early to prevent flash of wrong theme +// Apply theme and locale early to prevent flash of wrong theme/language // This uses localStorage as the source before user settings are loaded applyThemeEarly(); +applyLocaleEarly(); const Main = observer(() => ( <> @@ -29,10 +31,6 @@ const Main = observer(() => ( await initialInstanceStore(); await initialUserStore(); - // Apply user preferences (theme & locale) after user settings are loaded - // This will override the early theme with user's actual preference - userStore.applyUserPreferences(); - const container = document.getElementById("root"); const root = createRoot(container as HTMLElement); root.render(
); diff --git a/web/src/store/user.ts b/web/src/store/user.ts index 88f93fd5b..bf80bce74 100644 --- a/web/src/store/user.ts +++ b/web/src/store/user.ts @@ -1,7 +1,6 @@ import { uniqueId } from "lodash-es"; import { computed, makeAutoObservable } from "mobx"; import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/grpcweb"; -import i18n from "@/i18n"; import { Shortcut } from "@/types/proto/api/v1/shortcut_service"; import { User, @@ -14,8 +13,6 @@ import { UserSetting_WebhooksSetting, UserStats, } from "@/types/proto/api/v1/user_service"; -import { getLocaleWithFallback } from "@/utils/i18n"; -import { getThemeWithFallback, loadTheme } from "@/utils/theme"; import { createRequestKey, RequestDeduplicator, StoreError } from "./store-utils"; class LocalState { @@ -284,20 +281,6 @@ const userStore = (() => { state.statsStateId = id; }; - // Applies user preferences (theme and locale) with proper fallbacks - // This should be called after user settings are loaded - const applyUserPreferences = () => { - const generalSetting = state.userGeneralSetting; - - // Apply theme with fallback: user setting -> localStorage -> system - const theme = getThemeWithFallback(generalSetting?.theme); - loadTheme(theme); - - // Apply locale with fallback: user setting -> browser language - const locale = getLocaleWithFallback(generalSetting?.locale); - i18n.changeLanguage(locale); - }; - return { state, getOrFetchUserByName, @@ -314,7 +297,6 @@ const userStore = (() => { deleteNotification, fetchUserStats, setStatsStateId, - applyUserPreferences, }; })(); diff --git a/web/src/utils/i18n.ts b/web/src/utils/i18n.ts index c50a174be..d6f641d0d 100644 --- a/web/src/utils/i18n.ts +++ b/web/src/utils/i18n.ts @@ -3,6 +3,25 @@ import { useTranslation } from "react-i18next"; import i18n, { locales, TLocale } from "@/i18n"; import enTranslation from "@/locales/en.json"; +const LOCALE_STORAGE_KEY = "memos-locale"; + +const getStoredLocale = (): Locale | null => { + try { + const stored = localStorage.getItem(LOCALE_STORAGE_KEY); + return stored && locales.includes(stored) ? (stored as Locale) : null; + } catch { + return null; + } +}; + +const setStoredLocale = (locale: Locale): void => { + try { + localStorage.setItem(LOCALE_STORAGE_KEY, locale); + } catch { + // localStorage might not be available + } +}; + export const findNearestMatchedLanguage = (language: string): Locale => { if (locales.includes(language as TLocale)) { return language as Locale; @@ -54,17 +73,42 @@ export const isValidateLocale = (locale: string | undefined | null): boolean => // Gets the locale to use with proper priority: // 1. User setting (if logged in and has preference) -// 2. Browser language preference +// 2. localStorage (from previous session) +// 3. Browser language preference export const getLocaleWithFallback = (userLocale?: string): Locale => { // Priority 1: User setting (if logged in and valid) if (userLocale && isValidateLocale(userLocale)) { return userLocale as Locale; } - // Priority 2: Browser language + // Priority 2: localStorage + const stored = getStoredLocale(); + if (stored) { + return stored; + } + + // Priority 3: Browser language return findNearestMatchedLanguage(navigator.language); }; +// Applies and persists a locale setting +export const loadLocale = (locale: string): Locale => { + const validLocale = isValidateLocale(locale) ? (locale as Locale) : findNearestMatchedLanguage(navigator.language); + setStoredLocale(validLocale); + i18n.changeLanguage(validLocale); + return validLocale; +}; + +/** + * Applies locale early during initial page load to prevent language flash. + * Uses only localStorage and browser language (no user settings yet). + */ +export const applyLocaleEarly = (): void => { + const stored = getStoredLocale(); + const locale = stored ?? findNearestMatchedLanguage(navigator.language); + loadLocale(locale); +}; + // Get the display name for a locale in its native language export const getLocaleDisplayName = (locale: string): string => { try {