🐛 fix(i18n): preload default language from JSON to avoid Suspense on first render (#12895)

* 🐛 fix(i18n): preload default language from JSON to avoid Suspense on first render

- Sync load en-US common/error/chat from locales/en-US/*.json
- Use JSON (not locales/default/*.ts) as runtime values - TS source is type-only
- Prevents useTranslation from suspending, avoids CLS from 44px skeleton fallback

Made-with: Cursor

*  feat(i18n): enable partial loading of languages and add tests for dynamic namespace loading

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2026-03-11 14:00:39 +08:00 committed by GitHub
parent 4988413d58
commit 874c2dd706
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 35 additions and 0 deletions

View file

@ -4,6 +4,11 @@ import resourcesToBackend from 'i18next-resources-to-backend';
import { initReactI18next } from 'react-i18next';
import { isRtlLang } from 'rtl-detect';
// Sync load default language (en-US) from JSON to avoid Suspense on first render.
// locales/default/*.ts is for type inference only, not used as runtime values.
import chat from '@/../locales/en-US/chat.json';
import common from '@/../locales/en-US/common.json';
import error from '@/../locales/en-US/error.json';
import { DEFAULT_LANG } from '@/const/locale';
import { getDebugConfig } from '@/envs/debug';
import { normalizeLocale } from '@/locales/resources';
@ -12,6 +17,8 @@ import { unwrapESMModule } from '@/utils/esm/unwrapESMModule';
import { loadI18nNamespaceModule } from '../utils/i18n/loadI18nNamespaceModule';
const defaultResources = { chat, common, error };
const { I18N_DEBUG, I18N_DEBUG_BROWSER, I18N_DEBUG_SERVER } = getDebugConfig();
const debugMode = (I18N_DEBUG ?? isOnServerSide) ? I18N_DEBUG_SERVER : I18N_DEBUG_BROWSER;
@ -49,6 +56,13 @@ export const createI18nNext = (lang?: string) => {
initAsync,
// Preload default language (en-US) synchronously to avoid Suspense on first render
resources: {
[DEFAULT_LANG]: defaultResources,
},
// Keep backend loading enabled for namespaces that are not preloaded above.
partialBundledLanguages: true,
interpolation: {
escapeValue: false,
},

View file

@ -0,0 +1,21 @@
import { afterEach, describe, expect, it } from 'vitest';
import { createI18nNext } from '@/locales/create';
describe('createI18nNext', () => {
afterEach(() => {
localStorage.clear();
});
it('dynamically loads missing namespaces after preloading bundled defaults', async () => {
const i18n = createI18nNext('en-US');
await i18n.init({ initAsync: false });
expect(i18n.instance.hasResourceBundle('en-US', 'setting')).toBe(false);
await i18n.instance.loadNamespaces(['setting']);
expect(i18n.instance.hasResourceBundle('en-US', 'setting')).toBe(true);
expect(i18n.instance.t('tab.common', { ns: 'setting' })).toBe('Appearance');
});
});