diff --git a/packages/runtime/plugin-i18n/src/runtime/i18n/backend/middleware.common.ts b/packages/runtime/plugin-i18n/src/runtime/i18n/backend/middleware.common.ts index b5cc7247539a..c3ef0428b3d2 100644 --- a/packages/runtime/plugin-i18n/src/runtime/i18n/backend/middleware.common.ts +++ b/packages/runtime/plugin-i18n/src/runtime/i18n/backend/middleware.common.ts @@ -4,6 +4,7 @@ import type { ChainedBackendConfig, } from '../../../shared/type'; import type { I18nInstance } from '../instance'; +import { getActualI18nextInstance } from '../instance'; import { SdkBackend } from './sdk-backend'; type BackendConfigWithChained = BaseBackendOptions & @@ -53,6 +54,13 @@ function setupChainedBackend( BackendWithSave: new (...args: any[]) => any, ) { i18nInstance.use(ChainedBackend); + const actualInstance = getActualI18nextInstance(i18nInstance); + if (actualInstance?.options) { + actualInstance.options.backend = buildChainedBackendConfig( + backend, + BackendWithSave, + ); + } if (i18nInstance.options) { i18nInstance.options.backend = buildChainedBackendConfig( backend, @@ -97,8 +105,10 @@ export function useI18nextBackendCommon( return i18nInstance.use(SdkBackend); } - // For non-chained backend, we still need to set the backend config - // so that init() can use it to load resources + const actualInstance = getActualI18nextInstance(i18nInstance); + if (actualInstance?.options) { + actualInstance.options.backend = cleanBackendConfig(backend); + } if (i18nInstance.options) { i18nInstance.options.backend = cleanBackendConfig(backend); } diff --git a/packages/runtime/plugin-i18n/src/runtime/i18n/backend/sdk-backend.ts b/packages/runtime/plugin-i18n/src/runtime/i18n/backend/sdk-backend.ts index c51a05b7317a..3e848639ee96 100644 --- a/packages/runtime/plugin-i18n/src/runtime/i18n/backend/sdk-backend.ts +++ b/packages/runtime/plugin-i18n/src/runtime/i18n/backend/sdk-backend.ts @@ -6,12 +6,31 @@ interface BackendOptions { [key: string]: unknown; } +interface I18nextServices { + resourceStore?: { + data?: { + [language: string]: { + [namespace: string]: Record; + }; + }; + }; + store?: { + data?: { + [language: string]: { + [namespace: string]: Record; + }; + }; + }; + [key: string]: any; +} + export class SdkBackend { static type = 'backend'; type = 'backend' as const; sdk?: I18nSdkLoader; private allResourcesCache: Resources | null = null; private loadingPromises = new Map>(); + private services?: I18nextServices; constructor(_services: unknown, _options: Record) { void _services; @@ -19,11 +38,11 @@ export class SdkBackend { } init( - _services: unknown, + services: I18nextServices, backendOptions: BackendOptions, _i18nextOptions: unknown, ): void { - void _services; + this.services = services; void _i18nextOptions; this.sdk = backendOptions?.sdk; if (!this.sdk) { @@ -48,7 +67,13 @@ export class SdkBackend { ? this.extractFromCache(language, namespace) : null; if (cached !== null) { - callback(null, cached); + // Merge cached data with existing store data to preserve HTTP backend data + const mergedData = this.mergeWithExistingResources( + language, + namespace, + cached, + ); + callback(null, mergedData); return; } @@ -125,11 +150,18 @@ export class SdkBackend { promise .then(data => { const formattedData = this.formatResources(data, language, namespace); + // Merge with existing resources in store to preserve data from other backends (e.g., HTTP backend) + // This is important when using refreshAndUpdateStore mode in chained backend + const mergedData = this.mergeWithExistingResources( + language, + namespace, + formattedData, + ); if (shouldUpdateCache) { - this.updateCache(language, namespace, formattedData); + this.updateCache(language, namespace, mergedData); this.loadingPromises.delete(cacheKey); } - callback(null, formattedData); + callback(null, mergedData); this.triggerI18nextUpdate(language, namespace); }) .catch(error => { @@ -231,6 +263,24 @@ export class SdkBackend { return value !== null && typeof value === 'object'; } + private mergeWithExistingResources( + language: string, + namespace: string, + sdkData: Record, + ): Record { + // Get existing resources from store (may contain data from HTTP backend) + const store = this.services?.resourceStore || this.services?.store; + const existingData = + store?.data?.[language]?.[namespace] || ({} as Record); + + // Merge: preserve existing data (from HTTP backend), add/update with SDK data + // This ensures that when using refreshAndUpdateStore, HTTP backend data is not lost + return { + ...existingData, + ...sdkData, + }; + } + private triggerI18nextUpdate(language: string, namespace: string): void { if (typeof window !== 'undefined') { const event = new CustomEvent('i18n-sdk-resources-loaded', { diff --git a/packages/runtime/plugin-i18n/src/runtime/i18n/detection/index.ts b/packages/runtime/plugin-i18n/src/runtime/i18n/detection/index.ts index a88717b53d25..cd059fe0281c 100644 --- a/packages/runtime/plugin-i18n/src/runtime/i18n/detection/index.ts +++ b/packages/runtime/plugin-i18n/src/runtime/i18n/detection/index.ts @@ -5,8 +5,13 @@ import type { I18nInstance, LanguageDetectorOptions, } from '../instance'; +import { isI18nWrapperInstance } from '../instance'; import { mergeDetectionOptions as mergeDetectionOptionsUtil } from './config'; -import { cacheUserLanguage, detectLanguage } from './middleware'; +import { + cacheUserLanguage, + detectLanguage, + readLanguageFromStorage, +} from './middleware'; // Re-export cacheUserLanguage for use in context export { cacheUserLanguage }; @@ -16,8 +21,19 @@ export function exportServerLngToWindow(context: TRuntimeContext, lng: string) { } export const getLanguageFromSSRData = (window: Window): string | undefined => { - const ssrData = window._SSR_DATA; - return ssrData?.data?.i18nData?.lng as string | undefined; + try { + const ssrData = window._SSR_DATA; + // Check if SSR data exists and has valid structure + if (!ssrData || !ssrData.data || !ssrData.data.i18nData) { + return undefined; + } + const lng = ssrData.data.i18nData.lng; + // Return language only if it's a non-empty string + return typeof lng === 'string' && lng.trim() !== '' ? lng : undefined; + } catch (error) { + // If accessing window._SSR_DATA throws an error, return undefined + return undefined; + } }; export interface BaseLanguageDetectionOptions { @@ -40,8 +56,21 @@ export interface LanguageDetectionResult { finalLanguage: string; } +/** + * Normalize language code (e.g., 'zh-CN' -> 'zh', 'en-US' -> 'en') + */ +const normalizeLanguageCode = (language: string): string => { + if (!language) { + return language; + } + // Extract base language code (before hyphen) + const baseLang = language.split('-')[0]; + return baseLang; +}; + /** * Check if a language is supported + * Also checks the base language code (e.g., 'zh-CN' matches 'zh') */ const isLanguageSupported = ( language: string | undefined, @@ -50,13 +79,52 @@ const isLanguageSupported = ( if (!language) { return false; } - return ( - supportedLanguages.length === 0 || supportedLanguages.includes(language) - ); + if (supportedLanguages.length === 0) { + return true; + } + // Check exact match first + if (supportedLanguages.includes(language)) { + return true; + } + // Check base language code match (e.g., 'zh-CN' matches 'zh') + const baseLang = normalizeLanguageCode(language); + if (baseLang !== language && supportedLanguages.includes(baseLang)) { + return true; + } + return false; +}; + +/** + * Get the supported language that matches the given language + * Returns the exact match if available, otherwise returns the base language code match + * Returns undefined if no match is found + */ +const getSupportedLanguage = ( + language: string | undefined, + supportedLanguages: string[], +): string | undefined => { + if (!language) { + return undefined; + } + if (supportedLanguages.length === 0) { + return language; + } + // Check exact match first + if (supportedLanguages.includes(language)) { + return language; + } + // Check base language code match (e.g., 'zh-CN' matches 'zh') + const baseLang = normalizeLanguageCode(language); + if (baseLang !== language && supportedLanguages.includes(baseLang)) { + return baseLang; + } + return undefined; }; /** * Priority 1: Detect language from SSR data + * Try to get language from window._SSR_DATA first (both SSR and CSR projects) + * Returns undefined if SSR data is not available or invalid */ const detectLanguageFromSSR = (languages: string[]): string | undefined => { if (!isBrowser()) { @@ -65,7 +133,7 @@ const detectLanguageFromSSR = (languages: string[]): string | undefined => { try { const ssrLanguage = getLanguageFromSSRData(window); - if (isLanguageSupported(ssrLanguage, languages)) { + if (ssrLanguage && isLanguageSupported(ssrLanguage, languages)) { return ssrLanguage; } } catch (error) { @@ -77,6 +145,7 @@ const detectLanguageFromSSR = (languages: string[]): string | undefined => { /** * Priority 2: Detect language from URL path + * Only returns a language if the path explicitly contains a language prefix */ const detectLanguageFromPathPriority = ( pathname: string, @@ -87,17 +156,28 @@ const detectLanguageFromPathPriority = ( return undefined; } + // If no languages are configured, cannot detect from path + if (!languages || languages.length === 0) { + return undefined; + } + + // If pathname is empty or invalid, no language in path + if (!pathname || pathname.trim() === '') { + return undefined; + } + try { const pathDetection = detectLanguageFromPath( pathname, languages, localePathRedirect, ); - if (pathDetection.detected && pathDetection.language) { + // Only return language if explicitly detected in path + if (pathDetection.detected === true && pathDetection.language) { return pathDetection.language; } } catch (error) { - // Silently ignore errors + // Silently ignore errors, return undefined } return undefined; @@ -209,8 +289,16 @@ const detectLanguageFromI18nextDetector = async ( mergedDetection, ); - if (detectorLang && isLanguageSupported(detectorLang, options.languages)) { - return detectorLang; + // Use getSupportedLanguage to get the matching supported language + // This handles both exact match and base language code match (e.g., 'zh-CN' -> 'zh') + if (detectorLang) { + const supportedLang = getSupportedLanguage( + detectorLang, + options.languages, + ); + if (supportedLang) { + return supportedLang; + } } // Fallback to instance's current language if detector didn't detect @@ -228,7 +316,11 @@ const detectLanguageFromI18nextDetector = async ( }; /** - * Detect language with priority: SSR data > path > i18next detector > fallback + * Detect language with priority: + * Priority 1: SSR data (try window._SSR_DATA first, works for both SSR and CSR) + * Priority 2: Path detection + * Priority 3: i18next detector (reads from cookie/localStorage) + * Priority 4: User config language or fallback */ export const detectLanguageWithPriority = async ( i18nInstance: I18nInstance, @@ -245,8 +337,11 @@ export const detectLanguageWithPriority = async ( ssrContext, } = options; - // Priority 1: SSR data - let detectedLanguage = detectLanguageFromSSR(languages); + let detectedLanguage: string | undefined; + + // Priority 1: Try SSR data first (works for both SSR and CSR projects) + // For CSR projects, if SSR data exists in window, use it; otherwise continue to next priority + detectedLanguage = detectLanguageFromSSR(languages); // Priority 2: Path detection if (!detectedLanguage) { @@ -257,18 +352,29 @@ export const detectLanguageWithPriority = async ( ); } - // Priority 3: i18next detector - if (!detectedLanguage) { - detectedLanguage = await detectLanguageFromI18nextDetector(i18nInstance, { - languages, - fallbackLanguage, - localePathRedirect, - i18nextDetector, - detection, - userInitOptions, - mergedBackend: options.mergedBackend, - ssrContext, - }); + // Priority 3: i18next detector (reads from cookie/localStorage) + if (!detectedLanguage && i18nextDetector) { + if (isI18nWrapperInstance(i18nInstance)) { + detectedLanguage = readLanguageFromStorage( + mergeDetectionOptions( + i18nextDetector, + detection, + localePathRedirect, + userInitOptions, + ), + ); + } else { + detectedLanguage = await detectLanguageFromI18nextDetector(i18nInstance, { + languages, + fallbackLanguage, + localePathRedirect, + i18nextDetector, + detection, + userInitOptions, + mergedBackend: options.mergedBackend, + ssrContext, + }); + } } // Priority 4: Use user config language or fallback @@ -328,10 +434,24 @@ export const mergeDetectionOptions = ( userInitOptions?: I18nInitOptions, ) => { // Exclude 'path' from detection order to avoid conflict with manual path detection - const mergedDetection = i18nextDetector - ? mergeDetectionOptionsUtil(detection, userInitOptions?.detection) - : userInitOptions?.detection; - if (localePathRedirect && mergedDetection?.order) { + let mergedDetection: LanguageDetectorOptions; + if (i18nextDetector) { + // mergeDetectionOptionsUtil always returns an object with default options + mergedDetection = mergeDetectionOptionsUtil( + detection, + userInitOptions?.detection, + ); + } else { + // If detector is disabled, use user options or empty object + mergedDetection = userInitOptions?.detection || {}; + } + + // Ensure mergedDetection is always an object (should not be undefined after above) + if (!mergedDetection || typeof mergedDetection !== 'object') { + mergedDetection = {}; + } + + if (localePathRedirect && mergedDetection.order) { mergedDetection.order = mergedDetection.order.filter( (item: string) => item !== 'path', ); diff --git a/packages/runtime/plugin-i18n/src/runtime/i18n/detection/middleware.node.ts b/packages/runtime/plugin-i18n/src/runtime/i18n/detection/middleware.node.ts index 366cf9e955e9..e78cab98647c 100644 --- a/packages/runtime/plugin-i18n/src/runtime/i18n/detection/middleware.node.ts +++ b/packages/runtime/plugin-i18n/src/runtime/i18n/detection/middleware.node.ts @@ -8,6 +8,17 @@ export const cacheUserLanguage = ( ): void => { return; }; + +/** + * Read language directly from storage (localStorage/cookie) + * Not available in Node.js environment, returns undefined + */ +export const readLanguageFromStorage = ( + _detectionOptions?: any, +): string | undefined => { + // In Node.js environment, storage-based detection is not available + return undefined; +}; /** * Register LanguageDetector plugin to i18n instance * Must be called before init() to properly register the detector diff --git a/packages/runtime/plugin-i18n/src/runtime/i18n/detection/middleware.ts b/packages/runtime/plugin-i18n/src/runtime/i18n/detection/middleware.ts index 9ce18f9072d3..307db7c3f58f 100644 --- a/packages/runtime/plugin-i18n/src/runtime/i18n/detection/middleware.ts +++ b/packages/runtime/plugin-i18n/src/runtime/i18n/detection/middleware.ts @@ -1,12 +1,22 @@ +import { isBrowser } from '@modern-js/runtime'; import LanguageDetector from 'i18next-browser-languagedetector'; import type { I18nInstance } from '../instance'; +import { getActualI18nextInstance, isI18nWrapperInstance } from '../instance'; /** * Register LanguageDetector plugin to i18n instance * Must be called before init() to properly register the detector + * For wrapper instances, ensure detector is registered on the underlying i18next instance */ export const useI18nextLanguageDetector = (i18nInstance: I18nInstance) => { if (!i18nInstance.isInitialized) { + // For wrapper instances, also register on the underlying instance + if (isI18nWrapperInstance(i18nInstance)) { + const actualInstance = getActualI18nextInstance(i18nInstance); + if (actualInstance && !actualInstance.isInitialized) { + actualInstance.use(LanguageDetector); + } + } return i18nInstance.use(LanguageDetector); } return i18nInstance; @@ -16,7 +26,7 @@ export const useI18nextLanguageDetector = (i18nInstance: I18nInstance) => { * Read language directly from localStorage/cookie * Fallback when detector is not available in services */ -const readLanguageFromStorage = ( +export const readLanguageFromStorage = ( detectionOptions?: any, ): string | undefined => { try { @@ -98,6 +108,7 @@ const readLanguageFromStorage = ( /** * Detect language using i18next-browser-languagedetector * For initialized instances without detector in services, manually create a detector instance + * For wrapper instances, access the underlying i18next instance's services */ export const detectLanguage = ( i18nInstance: I18nInstance, @@ -105,7 +116,19 @@ export const detectLanguage = ( detectionOptions?: any, ): string | undefined => { try { - const detector = i18nInstance.services?.languageDetector; + // For wrapper instances, get the underlying i18next instance + const actualInstance = isI18nWrapperInstance(i18nInstance) + ? getActualI18nextInstance(i18nInstance) + : i18nInstance; + + // Check if either instance is initialized + const isInitialized = + i18nInstance.isInitialized || actualInstance?.isInitialized; + + // Try to get detector from services (prefer actual instance for wrapper) + const detector = + actualInstance?.services?.languageDetector || + i18nInstance.services?.languageDetector; if (detector && typeof detector.detect === 'function') { const result = detector.detect(); if (typeof result === 'string') { @@ -114,21 +137,30 @@ export const detectLanguage = ( if (Array.isArray(result) && result.length > 0) { return result[0]; } - return undefined; + // If detector exists but returns undefined, continue to fallback logic } - if (i18nInstance.isInitialized) { + // Fallback: read directly from storage (always try this in browser) + // This is important for wrapper instances where detector might not be properly initialized + if (isBrowser()) { const directRead = readLanguageFromStorage(detectionOptions); if (directRead) { return directRead; } + } + + // If instance is initialized, try creating manual detector + if (isInitialized) { + // Use actual instance's services/options for wrapper, otherwise use wrapper's + const servicesToUse = actualInstance?.services || i18nInstance.services; + const optionsToUse = actualInstance?.options || i18nInstance.options; - if (i18nInstance.services && i18nInstance.options) { + if (servicesToUse && optionsToUse) { const manualDetector = new LanguageDetector(); - const optionsToUse = detectionOptions - ? { ...i18nInstance.options, detection: detectionOptions } - : i18nInstance.options; - manualDetector.init(i18nInstance.services, optionsToUse as any); + const mergedOptions = detectionOptions + ? { ...optionsToUse, detection: detectionOptions } + : optionsToUse; + manualDetector.init(servicesToUse, mergedOptions as any); const result = manualDetector.detect(); if (typeof result === 'string') { @@ -149,6 +181,7 @@ export const detectLanguage = ( /** * Cache user language to localStorage/cookie * Uses LanguageDetector's cacheUserLanguage method when available + * For wrapper instances, access the underlying i18next instance's services */ export const cacheUserLanguage = ( i18nInstance: I18nInstance, @@ -160,8 +193,16 @@ export const cacheUserLanguage = ( } try { + // For wrapper instances, get the underlying i18next instance + const actualInstance = isI18nWrapperInstance(i18nInstance) + ? getActualI18nextInstance(i18nInstance) + : i18nInstance; + // Try to use detector's cacheUserLanguage method first - const detector = i18nInstance.services?.languageDetector; + // Prefer actual instance's detector for wrapper instances + const detector = + actualInstance?.services?.languageDetector || + i18nInstance.services?.languageDetector; if (detector && typeof detector.cacheUserLanguage === 'function') { try { detector.cacheUserLanguage(language); @@ -177,19 +218,20 @@ export const cacheUserLanguage = ( } // Fallback: manually create detector instance if i18n is initialized - if ( - i18nInstance.isInitialized && - i18nInstance.services && - i18nInstance.options - ) { + const isInitialized = + i18nInstance.isInitialized || actualInstance?.isInitialized; + const servicesToUse = actualInstance?.services || i18nInstance.services; + const optionsToUse = actualInstance?.options || i18nInstance.options; + + if (isInitialized && servicesToUse && optionsToUse) { try { - const userOptions = detectionOptions || i18nInstance.options?.detection; - const optionsToUse = userOptions - ? { ...i18nInstance.options, detection: userOptions } - : i18nInstance.options; + const userOptions = detectionOptions || optionsToUse?.detection; + const mergedOptions = userOptions + ? { ...optionsToUse, detection: userOptions } + : optionsToUse; const manualDetector = new LanguageDetector(); - manualDetector.init(i18nInstance.services, optionsToUse as any); + manualDetector.init(servicesToUse, mergedOptions as any); if (typeof manualDetector.cacheUserLanguage === 'function') { manualDetector.cacheUserLanguage(language); diff --git a/packages/runtime/plugin-i18n/src/runtime/i18n/instance.ts b/packages/runtime/plugin-i18n/src/runtime/i18n/instance.ts index 061eda6fd85c..06fb144014ad 100644 --- a/packages/runtime/plugin-i18n/src/runtime/i18n/instance.ts +++ b/packages/runtime/plugin-i18n/src/runtime/i18n/instance.ts @@ -1,8 +1,5 @@ import type { BaseBackendOptions } from '../../shared/type'; -/** - * Resource store interface for i18next - */ export interface I18nResourceStore { data?: { [language: string]: { @@ -18,6 +15,37 @@ export interface I18nResourceStore { ) => void; } +export function isI18nWrapperInstance(obj: any): boolean { + if (!obj || typeof obj !== 'object') { + return false; + } + if (!obj.i18nInstance || typeof obj.i18nInstance !== 'object') { + return false; + } + if (!obj.i18nInstance.instance) { + return false; + } + if (typeof obj.init !== 'function' || typeof obj.use !== 'function') { + return false; + } + return true; +} + +export function getI18nWrapperI18nextInstance(wrapperInstance: any): any { + if (isI18nWrapperInstance(wrapperInstance)) { + return wrapperInstance.i18nInstance?.instance; + } + return null; +} + +export function getActualI18nextInstance(instance: I18nInstance | any): any { + if (isI18nWrapperInstance(instance)) { + const i18nextInstance = getI18nWrapperI18nextInstance(instance); + return i18nextInstance || instance; + } + return instance; +} + export interface I18nInstance { language: string; isInitialized: boolean; @@ -54,6 +82,7 @@ export interface LanguageDetectorOptions { order?: LanguageDetectorOrder; lookupQuerystring?: string; lookupCookie?: string; + lookupLocalStorage?: string; lookupSession?: string; lookupFromPathIndex?: number; caches?: LanguageDetectorCaches; @@ -91,12 +120,15 @@ export type I18nInitOptions = { }; export function isI18nInstance(obj: any): obj is I18nInstance { - return ( - obj && - typeof obj === 'object' && - typeof obj.init === 'function' && - typeof obj.use === 'function' - ); + if (!obj || typeof obj !== 'object') { + return false; + } + + if (isI18nWrapperInstance(obj)) { + return true; + } + + return typeof obj.init === 'function' && typeof obj.use === 'function'; } async function tryImportI18next(): Promise { @@ -114,8 +146,6 @@ async function createI18nextInstance(): Promise { if (!i18next) { return null; } - // Create a new instance without auto-initialization - // Use initImmediate: false to prevent auto-init return i18next.createInstance({ initImmediate: false, }) as unknown as I18nInstance; @@ -133,10 +163,27 @@ async function tryImportReactI18next() { } } +export function getI18nextInstanceForProvider( + instance: I18nInstance | any, +): any { + if (isI18nWrapperInstance(instance)) { + const i18nextInstance = getI18nWrapperI18nextInstance(instance); + if (i18nextInstance) { + return i18nextInstance; + } + } + + return instance; +} + export async function getI18nInstance( - userInstance?: I18nInstance, + userInstance?: I18nInstance | any, ): Promise { if (userInstance) { + if (isI18nWrapperInstance(userInstance)) { + return userInstance as I18nInstance; + } + if (isI18nInstance(userInstance)) { return userInstance; } diff --git a/packages/runtime/plugin-i18n/src/runtime/i18n/utils.ts b/packages/runtime/plugin-i18n/src/runtime/i18n/utils.ts index 45b0cbdef51b..fa2a880ac7b8 100644 --- a/packages/runtime/plugin-i18n/src/runtime/i18n/utils.ts +++ b/packages/runtime/plugin-i18n/src/runtime/i18n/utils.ts @@ -6,7 +6,11 @@ import { useI18nextBackend } from './backend/middleware'; import { SdkBackend } from './backend/sdk-backend'; import { mergeDetectionOptions } from './detection'; import type { I18nInitOptions, I18nInstance } from './instance'; -import { isI18nInstance } from './instance'; +import { + getActualI18nextInstance, + isI18nInstance, + isI18nWrapperInstance, +} from './instance'; export function assertI18nInstance(obj: any): asserts obj is I18nInstance { if (!isI18nInstance(obj)) { @@ -156,39 +160,46 @@ export const initializeI18nInstance = async ( // For i18next, backend configuration must be passed to init() via initOptions.backend // The backend class is already registered via useI18nextBackend, but the config (loadPath, etc.) // needs to be in initOptions.backend for init() to use it - // Save backend config before init for chained backend (needs backends array) - const savedBackendConfig = i18nInstance.options?.backend; + const actualInstance = getActualI18nextInstance(i18nInstance); + const savedBackendConfig = + actualInstance?.options?.backend || i18nInstance.options?.backend; const isChainedBackendFromSaved = savedBackendConfig?.backends && Array.isArray(savedBackendConfig.backends); await i18nInstance.init(initOptions); - // After init(), ensure backend config is correctly set in i18nInstance.options.backend - // For chained backend, merge saved config (backends array) with initOptions.backend (backendOptions) - // For non-chained backend, ensure initOptions.backend (with loadPath) is set - if (hasOptions(i18nInstance) && mergedBackend) { - if (isChainedBackendFromSaved && initOptions.backend) { - // Merge saved config (which has backends array) with initOptions.backend (which has backendOptions) - i18nInstance.options.backend = { - ...initOptions.backend, - backends: savedBackendConfig.backends, - }; - } else if (initOptions.backend) { - // For non-chained backend, ensure initOptions.backend (with loadPath) is set - i18nInstance.options.backend = { - ...i18nInstance.options.backend, - ...initOptions.backend, - }; + if (mergedBackend) { + if (isI18nWrapperInstance(i18nInstance) && actualInstance?.options) { + if (isChainedBackendFromSaved && initOptions.backend) { + actualInstance.options.backend = { + ...initOptions.backend, + backends: savedBackendConfig.backends, + }; + } else if (initOptions.backend) { + actualInstance.options.backend = { + ...actualInstance.options.backend, + ...initOptions.backend, + }; + } + } + + if (hasOptions(i18nInstance)) { + if (isChainedBackendFromSaved && initOptions.backend) { + i18nInstance.options.backend = { + ...initOptions.backend, + backends: savedBackendConfig.backends, + }; + } else if (initOptions.backend) { + i18nInstance.options.backend = { + ...i18nInstance.options.backend, + ...initOptions.backend, + }; + } } } if (mergedBackend && hasOptions(i18nInstance)) { - const isChainedBackend = - mergedBackend._useChainedBackend && mergedBackend._chainedBackendConfig; - const cacheHitMode = - mergedBackend.cacheHitMode || 'refreshAndUpdateStore'; - // For chained backend with cacheHitMode: 'refreshAndUpdateStore', // i18next-chained-backend automatically: // 1. Loads from the first backend (HTTP/FS) and displays immediately @@ -204,7 +215,9 @@ export const initializeI18nInstance = async ( let retries = 20; while (retries > 0) { - const store = (i18nInstance as any).store; + // Get the actual i18next instance to access store property + const actualInstance = getActualI18nextInstance(i18nInstance); + const store = (actualInstance as any).store; if (store?.data?.[finalLanguage]?.[ns]) { break; } @@ -239,8 +252,13 @@ export const setupClonedInstance = async ( localePathRedirect: boolean, userInitOptions: I18nInitOptions | undefined, ): Promise => { - if (backendEnabled) { - const mergedBackend = mergeBackendOptions(backend, userInitOptions); + const mergedBackend = mergeBackendOptions(backend, userInitOptions); + // Check if SDK is configured (allows standalone SDK usage even without locales directory) + const hasSdkConfig = + typeof userInitOptions?.backend?.sdk === 'function' || + (mergedBackend?.sdk && typeof mergedBackend.sdk === 'function'); + + if (backendEnabled || hasSdkConfig) { useI18nextBackend(i18nInstance, mergedBackend); if (mergedBackend && hasOptions(i18nInstance)) { i18nInstance.options.backend = { diff --git a/packages/runtime/plugin-i18n/src/runtime/index.tsx b/packages/runtime/plugin-i18n/src/runtime/index.tsx index 6aceccbdfe5d..bbf5c9b17381 100644 --- a/packages/runtime/plugin-i18n/src/runtime/index.tsx +++ b/packages/runtime/plugin-i18n/src/runtime/index.tsx @@ -27,7 +27,11 @@ import { mergeDetectionOptions, } from './i18n/detection'; import { useI18nextLanguageDetector } from './i18n/detection/middleware'; -import { getI18nextProvider, getInitReactI18next } from './i18n/instance'; +import { + getI18nextInstanceForProvider, + getI18nextProvider, + getInitReactI18next, +} from './i18n/instance'; import { ensureLanguageMatch, initializeI18nInstance, @@ -101,8 +105,13 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({ // Register Backend BEFORE detectLanguageWithPriority // This is critical because detectLanguageWithPriority may trigger init() // through i18next detector, and backend must be registered before init() - // Only register backend if enabled is true (explicitly or auto-detected) - if (mergedBackend && backendEnabled) { + // Register backend if: + // 1. enabled is true (explicitly or auto-detected), OR + // 2. SDK is configured (allows standalone SDK usage even without locales directory) + const hasSdkConfig = + typeof userInitOptions?.backend?.sdk === 'function' || + (mergedBackend?.sdk && typeof mergedBackend.sdk === 'function'); + if (mergedBackend && (backendEnabled || hasSdkConfig)) { useI18nextBackend(i18nInstance, mergedBackend); } @@ -225,11 +234,17 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({ return appContent; } - return I18nextProvider ? ( - {appContent} - ) : ( - appContent - ); + if (I18nextProvider) { + const i18nextInstanceForProvider = + getI18nextInstanceForProvider(i18nInstance); + return ( + + {appContent} + + ); + } + + return appContent; }; }); }, diff --git a/tests/integration/i18n/routes-csr/modern.config.ts b/tests/integration/i18n/routes-csr/modern.config.ts index 6da0c885b531..982e5a86752e 100644 --- a/tests/integration/i18n/routes-csr/modern.config.ts +++ b/tests/integration/i18n/routes-csr/modern.config.ts @@ -20,9 +20,6 @@ export default defineConfig({ enabled: true, sdk: true, }, - performance: { - buildCache: false, - }, }), ], });