diff --git a/frontend/src/main.browserMock.test.ts b/frontend/src/main.browserMock.test.ts index a3710514..8d9e36a4 100644 --- a/frontend/src/main.browserMock.test.ts +++ b/frontend/src/main.browserMock.test.ts @@ -543,4 +543,79 @@ describe('main browser mock', () => { expect(dayjsLocaleMock).toHaveBeenCalledWith('ja'); expect(syncLanguageRuntimeMock.mock.calls.map(([language]) => language)).toEqual(['ja-JP']); }); + + it('updates the resolved locale when the system browser language changes', async () => { + const windowListeners = new Map>(); + const windowMock = { + addEventListener: vi.fn((type: string, listener: EventListener) => { + const listeners = windowListeners.get(type) ?? new Set(); + listeners.add(listener); + windowListeners.set(type, listeners); + }), + removeEventListener: vi.fn((type: string, listener: EventListener) => { + windowListeners.get(type)?.delete(listener); + }), + dispatchEvent: vi.fn((event: Event) => { + windowListeners.get(event.type)?.forEach((listener) => listener(event)); + return true; + }), + }; + vi.stubGlobal('window', windowMock); + + let navigatorLanguages = ['en-US']; + const navigatorMock = {}; + Object.defineProperty(navigatorMock, 'languages', { + configurable: true, + get: () => navigatorLanguages, + }); + Object.defineProperty(navigatorMock, 'language', { + configurable: true, + get: () => navigatorLanguages[0] ?? '', + }); + vi.stubGlobal('navigator', navigatorMock); + + const setLanguagePreference = vi.fn(); + vi.doMock('./store', () => ({ + useStore: Object.assign( + (selector: (state: { languagePreference: string; setLanguagePreference: (nextPreference: string) => void }) => T): T => + React.useSyncExternalStore( + () => () => {}, + () => selector({ languagePreference: 'system', setLanguagePreference }), + () => selector({ languagePreference: 'system', setLanguagePreference }), + ), + { + persist: { + hasHydrated: () => true, + onFinishHydration: () => () => {}, + }, + }, + ), + })); + + let renderer: TestRenderer.ReactTestRenderer | null = null; + renderRootImpl = (node) => { + act(() => { + renderer = TestRenderer.create(node as React.ReactElement); + }); + }; + + await importMain(); + const { getCurrentLanguage } = await import('./i18n'); + await act(async () => {}); + + expect(renderer).not.toBeNull(); + expect(getCurrentLanguage()).toBe('en-US'); + + dayjsLocaleMock.mockClear(); + syncLanguageRuntimeMock.mockClear(); + + await act(async () => { + navigatorLanguages = ['zh-CN']; + windowMock.dispatchEvent(new Event('languagechange')); + }); + + expect(getCurrentLanguage()).toBe('zh-CN'); + expect(dayjsLocaleMock).toHaveBeenCalledWith('zh-cn'); + expect(syncLanguageRuntimeMock.mock.calls.map(([language]) => language)).toEqual(['zh-CN']); + }); }); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9f3b48a0..51eee74f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -598,6 +598,25 @@ const readBrowserLanguages = (): string[] => { return navigator.language ? [navigator.language] : []; }; +const serializeBrowserLanguages = (languages: readonly string[]) => languages.join('\n'); + +const deserializeBrowserLanguages = (snapshot: string) => + snapshot ? snapshot.split('\n').filter(Boolean) : []; + +const getBrowserLanguageSnapshot = () => serializeBrowserLanguages(readBrowserLanguages()); + +const subscribeBrowserLanguageChange = (listener: () => void) => { + if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') { + return () => {}; + } + window.addEventListener('languagechange', listener); + return () => { + if (typeof window.removeEventListener === 'function') { + window.removeEventListener('languagechange', listener); + } + }; +}; + const subscribeStoreHydration = (listener: () => void) => { if (useStore.persist.hasHydrated()) { listener(); @@ -618,12 +637,17 @@ const Root = ({ rootComponent }: { rootComponent: React.ReactNode }) => { ); const languagePreference = useStore((state) => state.languagePreference); const setLanguagePreference = useStore((state) => state.setLanguagePreference); + const browserLanguageSnapshot = useSyncExternalStore( + subscribeBrowserLanguageChange, + getBrowserLanguageSnapshot, + getBrowserLanguageSnapshot, + ); if (!isStoreHydrated) { return null; } - const systemLanguages = readBrowserLanguages(); + const systemLanguages = deserializeBrowserLanguages(browserLanguageSnapshot); const resolvedLanguage = setCurrentLanguage(languagePreference, systemLanguages); applyDayjsLocale(resolvedLanguage);