From a8709b447add4bfd8a29f020d9f5cb089d76ec49 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 16:27:58 +0000 Subject: [PATCH] perf(i18n): load the English fallback lazily, off the startup critical path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `initI18n` runs in the client `init()` hook and blocks the first render. For every non-English user it awaited TWO locale dictionaries back to back — the active locale AND `en` (the fallback) — so first paint waited on two sequential round-trips + JSON parses. Now it awaits only the active locale, then warms `en` in the background (non-blocking). `t()` only consults `dicts.en` for keys the active locale is missing, and most call sites already pass an inline English fallback, so the deferred `en` doesn't change what users see; when it arrives `dicts.en` is reactive, so any key that fell through re-renders. English users are unchanged (no second fetch was ever needed). Net: non-English startup drops from two blocking locale fetches to one, halving the i18n payload on the critical path (the server already serves these JSONs brotli/gzip-compressed via the global CompressionLayer, so the wire cost was already small — this removes the extra round-trip + parse from first paint). Validated: new unit test (initI18n resolves while the en fetch is still pending, en is kicked off in the background, and a key missing from the active locale falls back once en lands) → 47 frontend tests green; npm run check; prod build. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6 --- frontend/src/lib/i18n/i18n.test.ts | 52 +++++++++++++++++++++++++-- frontend/src/lib/i18n/index.svelte.ts | 6 +++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/i18n/i18n.test.ts b/frontend/src/lib/i18n/i18n.test.ts index 4bc2951b..5447fe14 100644 --- a/frontend/src/lib/i18n/i18n.test.ts +++ b/frontend/src/lib/i18n/i18n.test.ts @@ -1,5 +1,12 @@ -import { describe, expect, it } from 'vitest'; -import { getNestedValue, interpolate, resolveBrowserLocale } from './index.svelte'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { + getNestedValue, + interpolate, + resolveBrowserLocale, + initI18n, + t, + i18n +} from './index.svelte'; describe('resolveBrowserLocale', () => { it('matches an exact full tag', () => { @@ -69,3 +76,44 @@ describe('interpolate', () => { expect(interpolate('{{count}} items', { count: 5 })).toBe('5 items'); }); }); + +describe('initI18n — lazy English fallback', () => { + let resolveEn: () => void; + + beforeEach(() => { + localStorage.setItem('oxicloud-locale', 'es'); + resolveEn = () => {}; + globalThis.fetch = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/es.json')) { + return Promise.resolve(new Response(JSON.stringify({ greeting: 'Hola' }), { status: 200 })); + } + if (url.includes('/en.json')) { + // Deferred: only resolves when the test flips it, proving init didn't wait. + return new Promise((res) => { + resolveEn = () => + res(new Response(JSON.stringify({ only_en: 'English only' }), { status: 200 })); + }); + } + return Promise.resolve(new Response('{}', { status: 404 })); + }) as unknown as typeof fetch; + }); + + it('is ready after only the active locale and warms en in the background', async () => { + // Resolves even though the en fetch is still pending — it isn't awaited. + await initI18n(); + expect(i18n.loaded).toBe(true); + expect(i18n.locale).toBe('es'); + expect(t('greeting')).toBe('Hola'); + + const urls = vi.mocked(globalThis.fetch).mock.calls.map((c) => String(c[0])); + expect(urls.some((u) => u.includes('/es.json'))).toBe(true); + expect(urls.some((u) => u.includes('/en.json'))).toBe(true); // en was kicked off + + // A key missing from es is unresolved until en arrives, then falls back. + expect(t('only_en')).toBe('only_en'); + resolveEn(); + await new Promise((r) => setTimeout(r, 0)); + expect(t('only_en')).toBe('English only'); + }); +}); diff --git a/frontend/src/lib/i18n/index.svelte.ts b/frontend/src/lib/i18n/index.svelte.ts index a15b8a50..9a80169a 100644 --- a/frontend/src/lib/i18n/index.svelte.ts +++ b/frontend/src/lib/i18n/index.svelte.ts @@ -207,9 +207,13 @@ export async function initI18n(): Promise { store.locale = saved; } await loadDict(store.locale); - if (store.locale !== 'en') await loadDict('en'); applyHtmlLang(store.locale); store.loaded = true; + // Warm the English fallback in the background. `t()` only consults it for + // keys the active (complete) locale is missing — and most call sites already + // pass an inline English fallback — so it must not block first paint. When it + // arrives, `dicts.en` is reactive, so any key that fell through re-renders. + if (store.locale !== 'en') void loadDict('en'); } export async function setLocale(locale: Locale): Promise {