From 84c167248bdf62e2cb19cc070613d059e98d7b71 Mon Sep 17 00:00:00 2001 From: Noel Chou Date: Wed, 17 Jun 2026 10:12:03 -0400 Subject: [PATCH] TT-6225 fix: re-render UI when language changes a second time The localized strings live as react-localization LocalizedStrings instances that are mutated in place by setLanguage, so their reference never changes on a language switch and useSelector skips the re-render, leaving stale labels until a page refresh. The localStrings selector now returns a fresh, prototype-preserving snapshot keyed on (source, language), giving consumers a new identity exactly when the displayed strings change while caching one snapshot per layout to keep references stable between renders. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/renderer/src/selector/localize.tsx | 56 ++++++++++++++++++++------ 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/selector/localize.tsx b/src/renderer/src/selector/localize.tsx index a3d1b71f..e1c67839 100644 --- a/src/renderer/src/selector/localize.tsx +++ b/src/renderer/src/selector/localize.tsx @@ -1,23 +1,53 @@ -import { createSelector } from 'reselect'; import { IState } from '../model/state'; interface IStringsSelectorProps { layout: string; } -const langSelector = (state: IState) => state.strings.lang || 'en'; -const layoutSelector = (state: IState, props: IStringsSelectorProps) => - state.strings[props.layout]; +/* + * The localized strings live in redux as react-localization `LocalizedStrings` + * instances. Changing the UI language only flips `state.strings.lang`; the per + * layout instances keep the same reference and are updated by *mutating* them + * in place via `setLanguage`. That means returning the instance directly leaves + * its reference unchanged across a language switch, so `useSelector` (which + * compares by reference / shallowEqual) sees "no change" and skips the + * re-render, leaving stale strings on screen. + * + * To fix that we return a fresh, prototype-preserving snapshot whenever the + * (source, language) pair changes, so the reference changes exactly when the + * displayed strings change. We cache one snapshot per layout so the reference + * stays stable between renders for the same language -- important because some + * consumers call useSelector without shallowEqual and would otherwise re-render + * on every dispatch. + */ +interface ICacheEntry { + source: any; + lang: string; + value: any; +} +const snapshotCache = new Map(); + +export const localStrings = (state: IState, props: IStringsSelectorProps) => { + const source = state.strings[props.layout]; + const lang = state.strings.lang || 'en'; -export const localStrings = createSelector( - layoutSelector, - langSelector, - (layout, lang) => { - if (lang) { - layout.setLanguage(lang); - } - return layout; + const cached = snapshotCache.get(props.layout); + if (cached && cached.source === source && cached.lang === lang) { + return cached.value; } -); + + // Keep the shared instance in sync with the current language for any code + // that reads it directly, then snapshot it into a new object that keeps the + // LocalizedStrings prototype (so getString/formatString still work) but has a + // new identity so consumers re-render when the language changes. + source.setLanguage(lang); + const value = Object.create( + Object.getPrototypeOf(source), + Object.getOwnPropertyDescriptors(source) + ); + + snapshotCache.set(props.layout, { source, lang, value }); + return value; +}; export default localStrings;