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 {