Skip to content

Commit 3dc740c

Browse files
committed
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.
1 parent 7479205 commit 3dc740c

File tree

8 files changed

+134
-74
lines changed

8 files changed

+134
-74
lines changed

web/src/App.tsx

Lines changed: 7 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import { observer } from "mobx-react-lite";
22
import { useEffect } from "react";
3-
import { useTranslation } from "react-i18next";
43
import { Outlet } from "react-router-dom";
54
import useNavigateTo from "./hooks/useNavigateTo";
6-
import { instanceStore, userStore } from "./store";
5+
import { useUserLocale } from "./hooks/useUserLocale";
6+
import { useUserTheme } from "./hooks/useUserTheme";
7+
import { instanceStore } from "./store";
78
import { cleanupExpiredOAuthState } from "./utils/oauth";
8-
import { getThemeWithFallback, loadTheme, setupSystemThemeListener } from "./utils/theme";
99

1010
const App = observer(() => {
11-
const { i18n } = useTranslation();
1211
const navigateTo = useNavigateTo();
1312
const instanceProfile = instanceStore.state.profile;
14-
const userGeneralSetting = userStore.state.userGeneralSetting;
1513
const instanceGeneralSetting = instanceStore.state.generalSetting;
1614

15+
// Apply user preferences reactively
16+
useUserLocale();
17+
useUserTheme();
18+
1719
// Clean up expired OAuth states on app initialization
1820
useEffect(() => {
1921
cleanupExpiredOAuthState();
@@ -54,45 +56,6 @@ const App = observer(() => {
5456
link.href = instanceGeneralSetting.customProfile.logoUrl || "/logo.webp";
5557
}, [instanceGeneralSetting.customProfile]);
5658

57-
// Update HTML lang and dir attributes based on current locale
58-
useEffect(() => {
59-
const currentLocale = i18n.language;
60-
document.documentElement.setAttribute("lang", currentLocale);
61-
if (["ar", "fa"].includes(currentLocale)) {
62-
document.documentElement.setAttribute("dir", "rtl");
63-
} else {
64-
document.documentElement.setAttribute("dir", "ltr");
65-
}
66-
}, [i18n.language]);
67-
68-
// Apply theme when user setting changes
69-
useEffect(() => {
70-
if (!userGeneralSetting) {
71-
return;
72-
}
73-
const theme = getThemeWithFallback(userGeneralSetting.theme);
74-
loadTheme(theme);
75-
}, [userGeneralSetting?.theme]);
76-
77-
// Listen for system theme changes when using "system" theme
78-
useEffect(() => {
79-
const theme = getThemeWithFallback(userGeneralSetting?.theme);
80-
81-
// Only set up listener if theme is "system"
82-
if (theme !== "system") {
83-
return;
84-
}
85-
86-
// Set up listener for OS theme preference changes
87-
const cleanup = setupSystemThemeListener(() => {
88-
// Reload theme when system preference changes
89-
loadTheme(theme);
90-
});
91-
92-
// Cleanup listener on unmount or when theme changes
93-
return cleanup;
94-
}, [userGeneralSetting?.theme]);
95-
9659
return <Outlet />;
9760
});
9861

web/src/components/Settings/PreferencesSection.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { observer } from "mobx-react-lite";
22
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
3-
import i18n from "@/i18n";
43
import { userStore } from "@/store";
54
import { Visibility } from "@/types/proto/api/v1/memo_service";
65
import { UserSetting_GeneralSetting } from "@/types/proto/api/v1/user_service";
7-
import { useTranslate } from "@/utils/i18n";
6+
import { loadLocale, useTranslate } from "@/utils/i18n";
87
import { convertVisibilityFromString, convertVisibilityToString } from "@/utils/memo";
98
import { loadTheme } from "@/utils/theme";
109
import LocaleSelect from "../LocaleSelect";
@@ -20,8 +19,8 @@ const PreferencesSection = observer(() => {
2019
const generalSetting = userStore.state.userGeneralSetting;
2120

2221
const handleLocaleSelectChange = async (locale: Locale) => {
23-
// Apply locale immediately for instant UI feedback
24-
i18n.changeLanguage(locale);
22+
// Apply locale immediately for instant UI feedback and persist to localStorage
23+
loadLocale(locale);
2524
// Persist to user settings
2625
await userStore.updateUserGeneralSetting({ locale }, ["locale"]);
2726
};

web/src/hooks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@ export * from "./useMemoFilters";
66
export * from "./useMemoSorting";
77
export * from "./useNavigateTo";
88
export * from "./useResponsiveWidth";
9+
export * from "./useUserLocale";
10+
export * from "./useUserTheme";

web/src/hooks/useUserLocale.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { useEffect } from "react";
2+
import { useTranslation } from "react-i18next";
3+
import { userStore } from "@/store";
4+
import { getLocaleWithFallback, loadLocale } from "@/utils/i18n";
5+
6+
/**
7+
* Hook that reactively applies user locale preference.
8+
* Priority: User setting → localStorage → browser language
9+
*/
10+
export const useUserLocale = () => {
11+
const { i18n } = useTranslation();
12+
const userGeneralSetting = userStore.state.userGeneralSetting;
13+
14+
// Apply locale when user setting changes or user logs in
15+
useEffect(() => {
16+
if (!userGeneralSetting) {
17+
return;
18+
}
19+
const locale = getLocaleWithFallback(userGeneralSetting.locale);
20+
loadLocale(locale);
21+
}, [userGeneralSetting?.locale]);
22+
23+
// Update HTML lang and dir attributes based on current locale
24+
useEffect(() => {
25+
const currentLocale = i18n.language;
26+
document.documentElement.setAttribute("lang", currentLocale);
27+
28+
// RTL languages
29+
if (["ar", "fa"].includes(currentLocale)) {
30+
document.documentElement.setAttribute("dir", "rtl");
31+
} else {
32+
document.documentElement.setAttribute("dir", "ltr");
33+
}
34+
}, [i18n.language]);
35+
};

web/src/hooks/useUserTheme.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useEffect } from "react";
2+
import { userStore } from "@/store";
3+
import { getThemeWithFallback, loadTheme, setupSystemThemeListener } from "@/utils/theme";
4+
5+
/**
6+
* Hook that reactively applies user theme preference.
7+
* Priority: User setting → localStorage → system preference
8+
*/
9+
export const useUserTheme = () => {
10+
const userGeneralSetting = userStore.state.userGeneralSetting;
11+
12+
// Apply theme when user setting changes or user logs in
13+
useEffect(() => {
14+
if (!userGeneralSetting) {
15+
return;
16+
}
17+
const theme = getThemeWithFallback(userGeneralSetting.theme);
18+
loadTheme(theme);
19+
}, [userGeneralSetting?.theme]);
20+
21+
// Listen for system theme changes when using "system" theme
22+
useEffect(() => {
23+
const theme = getThemeWithFallback(userGeneralSetting?.theme);
24+
25+
// Only set up listener if theme is "system"
26+
if (theme !== "system") {
27+
return;
28+
}
29+
30+
// Set up listener for OS theme preference changes
31+
const cleanup = setupSystemThemeListener(() => {
32+
loadTheme(theme);
33+
});
34+
35+
return cleanup;
36+
}, [userGeneralSetting?.theme]);
37+
};

web/src/main.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import router from "./router";
99
// Configure MobX before importing any stores
1010
import "./store/config";
1111
import { initialInstanceStore } from "./store/instance";
12-
import userStore, { initialUserStore } from "./store/user";
12+
import { initialUserStore } from "./store/user";
13+
import { applyLocaleEarly } from "./utils/i18n";
1314
import { applyThemeEarly } from "./utils/theme";
1415
import "leaflet/dist/leaflet.css";
1516

16-
// Apply theme early to prevent flash of wrong theme
17+
// Apply theme and locale early to prevent flash of wrong theme/language
1718
// This uses localStorage as the source before user settings are loaded
1819
applyThemeEarly();
20+
applyLocaleEarly();
1921

2022
const Main = observer(() => (
2123
<>
@@ -29,10 +31,6 @@ const Main = observer(() => (
2931
await initialInstanceStore();
3032
await initialUserStore();
3133

32-
// Apply user preferences (theme & locale) after user settings are loaded
33-
// This will override the early theme with user's actual preference
34-
userStore.applyUserPreferences();
35-
3634
const container = document.getElementById("root");
3735
const root = createRoot(container as HTMLElement);
3836
root.render(<Main />);

web/src/store/user.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { uniqueId } from "lodash-es";
22
import { computed, makeAutoObservable } from "mobx";
33
import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/grpcweb";
4-
import i18n from "@/i18n";
54
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
65
import {
76
User,
@@ -14,8 +13,6 @@ import {
1413
UserSetting_WebhooksSetting,
1514
UserStats,
1615
} from "@/types/proto/api/v1/user_service";
17-
import { getLocaleWithFallback } from "@/utils/i18n";
18-
import { getThemeWithFallback, loadTheme } from "@/utils/theme";
1916
import { createRequestKey, RequestDeduplicator, StoreError } from "./store-utils";
2017

2118
class LocalState {
@@ -284,20 +281,6 @@ const userStore = (() => {
284281
state.statsStateId = id;
285282
};
286283

287-
// Applies user preferences (theme and locale) with proper fallbacks
288-
// This should be called after user settings are loaded
289-
const applyUserPreferences = () => {
290-
const generalSetting = state.userGeneralSetting;
291-
292-
// Apply theme with fallback: user setting -> localStorage -> system
293-
const theme = getThemeWithFallback(generalSetting?.theme);
294-
loadTheme(theme);
295-
296-
// Apply locale with fallback: user setting -> browser language
297-
const locale = getLocaleWithFallback(generalSetting?.locale);
298-
i18n.changeLanguage(locale);
299-
};
300-
301284
return {
302285
state,
303286
getOrFetchUserByName,
@@ -314,7 +297,6 @@ const userStore = (() => {
314297
deleteNotification,
315298
fetchUserStats,
316299
setStatsStateId,
317-
applyUserPreferences,
318300
};
319301
})();
320302

web/src/utils/i18n.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,25 @@ import { useTranslation } from "react-i18next";
33
import i18n, { locales, TLocale } from "@/i18n";
44
import enTranslation from "@/locales/en.json";
55

6+
const LOCALE_STORAGE_KEY = "memos-locale";
7+
8+
const getStoredLocale = (): Locale | null => {
9+
try {
10+
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
11+
return stored && locales.includes(stored) ? (stored as Locale) : null;
12+
} catch {
13+
return null;
14+
}
15+
};
16+
17+
const setStoredLocale = (locale: Locale): void => {
18+
try {
19+
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
20+
} catch {
21+
// localStorage might not be available
22+
}
23+
};
24+
625
export const findNearestMatchedLanguage = (language: string): Locale => {
726
if (locales.includes(language as TLocale)) {
827
return language as Locale;
@@ -54,17 +73,42 @@ export const isValidateLocale = (locale: string | undefined | null): boolean =>
5473

5574
// Gets the locale to use with proper priority:
5675
// 1. User setting (if logged in and has preference)
57-
// 2. Browser language preference
76+
// 2. localStorage (from previous session)
77+
// 3. Browser language preference
5878
export const getLocaleWithFallback = (userLocale?: string): Locale => {
5979
// Priority 1: User setting (if logged in and valid)
6080
if (userLocale && isValidateLocale(userLocale)) {
6181
return userLocale as Locale;
6282
}
6383

64-
// Priority 2: Browser language
84+
// Priority 2: localStorage
85+
const stored = getStoredLocale();
86+
if (stored) {
87+
return stored;
88+
}
89+
90+
// Priority 3: Browser language
6591
return findNearestMatchedLanguage(navigator.language);
6692
};
6793

94+
// Applies and persists a locale setting
95+
export const loadLocale = (locale: string): Locale => {
96+
const validLocale = isValidateLocale(locale) ? (locale as Locale) : findNearestMatchedLanguage(navigator.language);
97+
setStoredLocale(validLocale);
98+
i18n.changeLanguage(validLocale);
99+
return validLocale;
100+
};
101+
102+
/**
103+
* Applies locale early during initial page load to prevent language flash.
104+
* Uses only localStorage and browser language (no user settings yet).
105+
*/
106+
export const applyLocaleEarly = (): void => {
107+
const stored = getStoredLocale();
108+
const locale = stored ?? findNearestMatchedLanguage(navigator.language);
109+
loadLocale(locale);
110+
};
111+
68112
// Get the display name for a locale in its native language
69113
export const getLocaleDisplayName = (locale: string): string => {
70114
try {

0 commit comments

Comments
 (0)