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.
This commit is contained in:
Steven 2025-12-11 07:59:52 +08:00
parent 7479205e21
commit 3dc740c752
8 changed files with 134 additions and 74 deletions

View File

@ -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 <Outlet />;
});

View File

@ -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"]);
};

View File

@ -6,3 +6,5 @@ export * from "./useMemoFilters";
export * from "./useMemoSorting";
export * from "./useNavigateTo";
export * from "./useResponsiveWidth";
export * from "./useUserLocale";
export * from "./useUserTheme";

View File

@ -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]);
};

View File

@ -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]);
};

View File

@ -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(<Main />);

View File

@ -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,
};
})();

View File

@ -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 {