Skip to content

Commit dace4e8

Browse files
authored
feat: optimize i18n plugin detection && add custom i18n wrapper test case (#7943)
1 parent 55629dc commit dace4e8

File tree

12 files changed

+596
-53
lines changed

12 files changed

+596
-53
lines changed

packages/runtime/plugin-i18n/src/runtime/i18n/detection/index.ts

Lines changed: 225 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,125 @@ import {
1111
cacheUserLanguage,
1212
detectLanguage,
1313
readLanguageFromStorage,
14+
useI18nextLanguageDetector,
1415
} from './middleware';
1516

1617
// Re-export cacheUserLanguage for use in context
1718
export { cacheUserLanguage };
1819

20+
interface DetectorCacheEntry {
21+
instance: I18nInstance;
22+
isTemporary: boolean;
23+
configKey: string;
24+
}
25+
26+
const detectorInstanceCache = new WeakMap<I18nInstance, DetectorCacheEntry>();
27+
28+
const DETECTOR_SAFE_OPTION_KEYS: string[] = [
29+
'lowerCaseLng',
30+
'nonExplicitSupportedLngs',
31+
'load',
32+
'partialBundledLanguages',
33+
'returnNull',
34+
'returnEmptyString',
35+
'returnObjects',
36+
'joinArrays',
37+
'keySeparator',
38+
'nsSeparator',
39+
'pluralSeparator',
40+
'contextSeparator',
41+
'fallbackNS',
42+
'ns',
43+
'defaultNS',
44+
'debug',
45+
];
46+
47+
/**
48+
* Stable stringify that sorts object keys to ensure consistent output
49+
* regardless of property order
50+
*/
51+
const stableStringify = (value: any): string => {
52+
if (value === null || value === undefined) {
53+
return JSON.stringify(value);
54+
}
55+
56+
if (typeof value !== 'object') {
57+
return JSON.stringify(value);
58+
}
59+
60+
if (Array.isArray(value)) {
61+
// Arrays maintain their order
62+
return `[${value.map(item => stableStringify(item)).join(',')}]`;
63+
}
64+
65+
// For objects, sort keys and recursively stringify values
66+
const sortedKeys = Object.keys(value).sort();
67+
const sortedEntries = sortedKeys.map(key => {
68+
const stringifiedValue = stableStringify(value[key]);
69+
return `${JSON.stringify(key)}:${stringifiedValue}`;
70+
});
71+
72+
return `{${sortedEntries.join(',')}}`;
73+
};
74+
75+
const buildDetectorConfigKey = (
76+
languages: string[],
77+
fallbackLanguage: string,
78+
mergedDetection: LanguageDetectorOptions,
79+
): string => {
80+
return stableStringify({
81+
languages,
82+
fallbackLanguage,
83+
detection: mergedDetection,
84+
});
85+
};
86+
87+
const pickSafeDetectionOptions = (
88+
userInitOptions?: I18nInitOptions,
89+
): Partial<I18nInitOptions> & Record<string, any> => {
90+
if (!userInitOptions) {
91+
return {};
92+
}
93+
const safeOptions: Partial<I18nInitOptions> & Record<string, any> = {};
94+
for (const key of DETECTOR_SAFE_OPTION_KEYS) {
95+
const value = (userInitOptions as any)[key];
96+
if (value !== undefined) {
97+
safeOptions[key] = value;
98+
}
99+
}
100+
if ((userInitOptions as any).interpolation) {
101+
safeOptions.interpolation = { ...(userInitOptions as any).interpolation };
102+
}
103+
return safeOptions;
104+
};
105+
106+
const cleanupDetectorCacheEntry = (entry?: DetectorCacheEntry) => {
107+
if (!entry || !entry.isTemporary) {
108+
return;
109+
}
110+
const instance = entry.instance as any;
111+
try {
112+
instance?.removeAllListeners?.();
113+
} catch (error) {
114+
void error;
115+
}
116+
try {
117+
instance?.off?.('*');
118+
} catch (error) {
119+
void error;
120+
}
121+
try {
122+
instance?.services?.backendConnector?.backend?.stop?.();
123+
} catch (error) {
124+
void error;
125+
}
126+
try {
127+
instance?.services?.backendConnector?.backend?.close?.();
128+
} catch (error) {
129+
void error;
130+
}
131+
};
132+
19133
export function exportServerLngToWindow(context: TRuntimeContext, lng: string) {
20134
context.__i18nData__ = { lng };
21135
}
@@ -186,74 +300,111 @@ const detectLanguageFromPathPriority = (
186300
/**
187301
* Initialize i18n instance for detector if needed
188302
*/
303+
interface DetectorInitResult {
304+
detectorInstance: I18nInstance;
305+
isTemporary: boolean;
306+
}
307+
308+
const createDetectorInstance = (
309+
baseInstance: I18nInstance,
310+
configKey: string,
311+
): { instance: I18nInstance; isTemporary: boolean } => {
312+
const cached = detectorInstanceCache.get(baseInstance);
313+
if (cached && cached.configKey === configKey) {
314+
return { instance: cached.instance, isTemporary: cached.isTemporary };
315+
}
316+
317+
if (cached) {
318+
cleanupDetectorCacheEntry(cached);
319+
detectorInstanceCache.delete(baseInstance);
320+
}
321+
322+
const createNewInstance = (): {
323+
instance: I18nInstance;
324+
isTemporary: boolean;
325+
} => {
326+
if (typeof baseInstance.createInstance === 'function') {
327+
try {
328+
const created = baseInstance.createInstance();
329+
if (created) {
330+
return { instance: created, isTemporary: true };
331+
}
332+
} catch (error) {
333+
void error;
334+
}
335+
}
336+
337+
if (typeof baseInstance.cloneInstance === 'function') {
338+
try {
339+
const cloned = baseInstance.cloneInstance();
340+
if (cloned) {
341+
return { instance: cloned, isTemporary: true };
342+
}
343+
} catch (error) {
344+
void error;
345+
}
346+
}
347+
348+
return { instance: baseInstance, isTemporary: false };
349+
};
350+
351+
const created = createNewInstance();
352+
if (created.isTemporary) {
353+
detectorInstanceCache.set(baseInstance, {
354+
instance: created.instance,
355+
isTemporary: true,
356+
configKey,
357+
});
358+
}
359+
return created;
360+
};
361+
189362
const initializeI18nForDetector = async (
190363
i18nInstance: I18nInstance,
191364
options: BaseLanguageDetectionOptions,
192-
): Promise<void> => {
193-
if (i18nInstance.isInitialized) {
194-
return;
195-
}
196-
365+
): Promise<DetectorInitResult> => {
197366
const mergedDetection = mergeDetectionOptions(
198367
options.i18nextDetector,
199368
options.detection,
200369
options.localePathRedirect,
201370
options.userInitOptions,
202371
);
203372

204-
// Don't set lng explicitly when detector is enabled, let the detector find the language
205-
const userLng = options.userInitOptions?.lng;
206-
// Exclude backend from userInitOptions to avoid overriding mergedBackend
207-
// Backend should be set via mergedBackend which contains the properly merged configuration
208-
const {
209-
lng: _,
210-
backend: _removedBackend,
211-
...restUserOptions
212-
} = options.userInitOptions || {};
213-
const initOptions: any = {
214-
...restUserOptions,
215-
...(userLng ? { lng: userLng } : {}),
373+
const configKey = buildDetectorConfigKey(
374+
options.languages,
375+
options.fallbackLanguage,
376+
mergedDetection,
377+
);
378+
379+
const { instance, isTemporary } = createDetectorInstance(
380+
i18nInstance,
381+
configKey,
382+
);
383+
384+
const safeUserOptions = pickSafeDetectionOptions(options.userInitOptions);
385+
386+
// Only initialize detection capability, don't load any resources to avoid conflicts with subsequent backend initialization
387+
const initOptions: I18nInitOptions = {
388+
...safeUserOptions,
216389
fallbackLng: options.fallbackLanguage,
217390
supportedLngs: options.languages,
218391
detection: mergedDetection,
392+
initImmediate: true,
219393
react: {
220-
...((options.userInitOptions as any)?.react || {}),
221-
useSuspense: isBrowser()
222-
? ((options.userInitOptions as any)?.react?.useSuspense ?? true)
223-
: false,
394+
useSuspense: false,
224395
},
225396
};
226397

227-
// Set backend config from mergedBackend if available
228-
// This ensures default backend config (like loadPath) is preserved when user only provides sdk
229-
if (options.mergedBackend) {
230-
const isChainedBackend = !!options.mergedBackend?._useChainedBackend;
231-
if (isChainedBackend && options.mergedBackend._chainedBackendConfig) {
232-
// For chained backend, we need to get backend classes from i18nInstance.options.backend.backends
233-
// which were set by useI18nextBackend
234-
const savedBackendConfig = i18nInstance.options?.backend;
235-
if (
236-
savedBackendConfig?.backends &&
237-
Array.isArray(savedBackendConfig.backends)
238-
) {
239-
initOptions.backend = {
240-
backends: savedBackendConfig.backends,
241-
backendOptions:
242-
options.mergedBackend._chainedBackendConfig.backendOptions,
243-
cacheHitMode:
244-
options.mergedBackend.cacheHitMode || 'refreshAndUpdateStore',
245-
};
246-
}
247-
} else {
248-
// For non-chained backend, pass the backend config directly
249-
// Remove internal properties before passing to init()
250-
const { _useChainedBackend, _chainedBackendConfig, ...cleanBackend } =
251-
options.mergedBackend || {};
252-
initOptions.backend = cleanBackend;
253-
}
398+
// Ensure the detector instance has the language detection plugin loaded
399+
useI18nextLanguageDetector(instance);
400+
401+
if (!instance.isInitialized) {
402+
await instance.init(initOptions);
403+
} else if (isTemporary) {
404+
await instance.init(initOptions);
254405
}
255406

256-
await i18nInstance.init(initOptions);
407+
return { detectorInstance: instance, isTemporary };
257408
};
258409

259410
/**
@@ -275,7 +426,10 @@ const detectLanguageFromI18nextDetector = async (
275426
options.userInitOptions,
276427
);
277428

278-
await initializeI18nForDetector(i18nInstance, options);
429+
const { detectorInstance, isTemporary } = await initializeI18nForDetector(
430+
i18nInstance,
431+
options,
432+
);
279433

280434
try {
281435
const request = options.ssrContext?.request;
@@ -284,7 +438,7 @@ const detectLanguageFromI18nextDetector = async (
284438
}
285439

286440
const detectorLang = detectLanguage(
287-
i18nInstance,
441+
detectorInstance,
288442
request as any,
289443
mergedDetection,
290444
);
@@ -302,14 +456,32 @@ const detectLanguageFromI18nextDetector = async (
302456
}
303457

304458
// Fallback to instance's current language if detector didn't detect
305-
if (i18nInstance.isInitialized && i18nInstance.language) {
306-
const currentLang = i18nInstance.language;
459+
if (detectorInstance.isInitialized && detectorInstance.language) {
460+
const currentLang = detectorInstance.language;
307461
if (isLanguageSupported(currentLang, options.languages)) {
308462
return currentLang;
309463
}
310464
}
311465
} catch (error) {
312466
// Silently ignore errors
467+
} finally {
468+
// Clean up temporary instance to avoid affecting subsequent formal initialization
469+
if (isTemporary && detectorInstance !== i18nInstance) {
470+
// Temporary instance is saved in cache for reuse
471+
detectorInstanceCache.set(i18nInstance, {
472+
instance: detectorInstance,
473+
isTemporary: true,
474+
configKey: buildDetectorConfigKey(
475+
options.languages,
476+
options.fallbackLanguage,
477+
mergedDetection,
478+
),
479+
});
480+
} else if (detectorInstance === i18nInstance) {
481+
// As a fallback, prevent i18nInstance from being polluted by detector init
482+
(i18nInstance as any).isInitialized = false;
483+
delete (i18nInstance as any).language;
484+
}
313485
}
314486

315487
return undefined;

pnpm-lock.yaml

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)