perf(i18n): load the English fallback lazily, off the startup critical path

`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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
This commit is contained in:
Claude
2026-06-19 16:27:58 +00:00
parent afbc0ba515
commit a8709b447a
2 changed files with 55 additions and 3 deletions
+50 -2
View File
@@ -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<Response>((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');
});
});
+5 -1
View File
@@ -207,9 +207,13 @@ export async function initI18n(): Promise<void> {
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<boolean> {