perf(fonts): lazy-load CJK fonts per locale (~16MB font payload off the boot path)

Kanit (th), Noto Sans JP (ja) and Noto Sans KR (ko) were imported statically in main.tsx, so every visitor downloaded ~16MB of CJK font files. They're now loaded on demand once per locale (lib/cjkFonts.ts, wired in LanguageContext), so non-CJK users never fetch them and CJK users get their font after the first paint.
This commit is contained in:
SkateSpots 2026-10-12 19:52:54 +07:00
parent c95dd54a7a
commit cc250b4d0b
4 changed files with 53 additions and 16 deletions

View file

@ -2368,7 +2368,7 @@ similar are internal endpoints/hook names, not credits — leave them.)
is now **multi-way** — en/es stay key-for-key identical (unchanged), `th` (and is now **multi-way** — en/es stay key-for-key identical (unchanged), `th` (and
`ja`) may be partial and are reported as **coverage %**; a `th`/`ja` key absent `ja`) may be partial and are reported as **coverage %**; a `th`/`ja` key absent
from `en` fails. from `en` fails.
- **Font:** `@fontsource/kanit` (Kanit) imported in `main.tsx` and added to the - **Font:** `@fontsource/kanit` (Kanit), **lazy-loaded per locale** (`lib/cjkFonts.ts`) and added to the
CSS + Tailwind font stacks — ⚠️ Outfit/Inter/Bebas/Barlow have **no Thai CSS + Tailwind font stacks — ⚠️ Outfit/Inter/Bebas/Barlow have **no Thai
glyphs**, so without it Thai silently falls back to a system font. glyphs**, so without it Thai silently falls back to a system font.
- **Detection:** a Thai-device browser (`th*`) starts in `th` (`detectLanguage`). - **Detection:** a Thai-device browser (`th*`) starts in `th` (`detectLanguage`).
@ -2388,7 +2388,9 @@ similar are internal endpoints/hook names, not credits — leave them.)
bloats the app. Each language is its own on-demand chunk. bloats the app. Each language is its own on-demand chunk.
- **Fonts:** added **Noto Sans KR** (Korean) and **Inter** (Cyrillic/Russian) to - **Fonts:** added **Noto Sans KR** (Korean) and **Inter** (Cyrillic/Russian) to
the CSS + Tailwind stacks; JP/KR/TH each need their own font (Outfit/Inter the CSS + Tailwind stacks; JP/KR/TH each need their own font (Outfit/Inter
lack CJK). lack CJK). **(2026-10-12) CJK fonts are lazy-loaded per locale** (`lib/cjkFonts.ts`,
wired in `contexts/LanguageContext.tsx`) — they're ~16 MB of font payload that
non-CJK users no longer download at boot.
- `check-i18n.mjs` now reports **all 12**; new locales are *partial* (coverage %) - `check-i18n.mjs` now reports **all 12**; new locales are *partial* (coverage %)
until complete. Adding a locale = register in `language.ts` (`Language` union + until complete. Adding a locale = register in `language.ts` (`Language` union +
`LANGUAGE_LOADERS`), `LanguageContext.detectLanguage`, `LanguageToggle`, `LANGUAGE_LOADERS`), `LanguageContext.detectLanguage`, `LanguageToggle`,
@ -2408,7 +2410,7 @@ similar are internal endpoints/hook names, not credits — leave them.)
Both partial-locale rules below still hold for any *future* keys. Both partial-locale rules below still hold for any *future* keys.
- **Partial by design** (same as `th`): a missing `ja` key falls back to English. - **Partial by design** (same as `th`): a missing `ja` key falls back to English.
`check-i18n.mjs` reports `ja` coverage and fails if a `ja` key is absent from `en`. `check-i18n.mjs` reports `ja` coverage and fails if a `ja` key is absent from `en`.
- **Font:** `@fontsource/noto-sans-jp` (Noto Sans JP) imported in `main.tsx` and - **Font:** `@fontsource/noto-sans-jp` (Noto Sans JP), **lazy-loaded per locale** (`lib/cjkFonts.ts`) and
added to the CSS + Tailwind font stacks — ⚠️ Outfit/Inter/Bebas/Barlow/Kanit have added to the CSS + Tailwind font stacks — ⚠️ Outfit/Inter/Bebas/Barlow/Kanit have
**no Japanese glyphs**, so without it Japanese falls back to a system font. **no Japanese glyphs**, so without it Japanese falls back to a system font.
- **Detection:** a Japanese-device browser (`ja*`) starts in `ja` (`detectLanguage`). - **Detection:** a Japanese-device browser (`ja*`) starts in `ja` (`detectLanguage`).

View file

@ -10,6 +10,7 @@ import {
type Translations, type Translations,
} from './language'; } from './language';
import { setDateLocale } from '@/lib/dateFnsI18n'; import { setDateLocale } from '@/lib/dateFnsI18n';
import { loadCjkFont } from '@/lib/cjkFonts';
interface LanguageProviderProps { interface LanguageProviderProps {
children: ReactNode; children: ReactNode;
@ -76,6 +77,12 @@ export function LanguageProvider({ children }: LanguageProviderProps) {
return () => { active = false; }; return () => { active = false; };
}, [language]); }, [language]);
// Lazy-load the CJK display font for the active locale (ja/ko/th) — large font
// files that non-CJK users no longer have to download at boot.
useEffect(() => {
loadCjkFont(language);
}, [language]);
const setLanguage = useCallback((lang: Language) => { const setLanguage = useCallback((lang: Language) => {
setLanguageState(lang); setLanguageState(lang);
setStoredLanguage(lang); setStoredLanguage(lang);

38
src/lib/cjkFonts.ts Normal file
View file

@ -0,0 +1,38 @@
import type { Language } from '@/contexts/language';
const loaded = new Set<Language>();
/**
* Lazy-load the CJK display font for the active locale.
*
* The Thai (Kanit), Japanese (Noto Sans JP) and Korean (Noto Sans KR) font
* families are large and only needed for users on those locales — but they were
* imported statically in main.tsx, so every visitor downloaded them. They are
* now loaded on demand, once per locale, and non-CJK users never fetch them.
* Text briefly falls back to a system font until the stylesheet resolves.
*/
export function loadCjkFont(language: Language): void {
if (loaded.has(language)) return;
loaded.add(language);
if (language === 'ja') {
void Promise.all([
import('@fontsource/noto-sans-jp/400.css'),
import('@fontsource/noto-sans-jp/500.css'),
import('@fontsource/noto-sans-jp/700.css'),
]).catch(() => {});
} else if (language === 'ko') {
void Promise.all([
import('@fontsource/noto-sans-kr/400.css'),
import('@fontsource/noto-sans-kr/500.css'),
import('@fontsource/noto-sans-kr/700.css'),
]).catch(() => {});
} else if (language === 'th') {
void Promise.all([
import('@fontsource/kanit/400.css'),
import('@fontsource/kanit/500.css'),
import('@fontsource/kanit/600.css'),
import('@fontsource/kanit/700.css'),
]).catch(() => {});
}
}

View file

@ -34,19 +34,9 @@ import '@fontsource/bebas-neue';
import '@fontsource/barlow-condensed/400.css'; import '@fontsource/barlow-condensed/400.css';
import '@fontsource/barlow-condensed/600.css'; import '@fontsource/barlow-condensed/600.css';
import '@fontsource/barlow-condensed/700.css'; import '@fontsource/barlow-condensed/700.css';
// Kanit — Thai glyphs (Latin/Thai stack fallback; the other fonts have no Thai) // CJK fonts (Thai Kanit, Noto Sans JP, Noto Sans KR) are NOT imported here —
import '@fontsource/kanit/400.css'; // they're large and only needed for ja/ko/th users, so they're lazy-loaded per
import '@fontsource/kanit/500.css'; // locale (lib/cjkFonts.ts, wired in contexts/LanguageContext.tsx).
import '@fontsource/kanit/600.css';
import '@fontsource/kanit/700.css';
// Japanese glyphs — Noto Sans JP (Outfit/Inter/Bebas/Barlow/Kanit have none)
import '@fontsource/noto-sans-jp/400.css';
import '@fontsource/noto-sans-jp/500.css';
import '@fontsource/noto-sans-jp/700.css';
// Korean glyphs — Noto Sans KR
import '@fontsource/noto-sans-kr/400.css';
import '@fontsource/noto-sans-kr/500.css';
import '@fontsource/noto-sans-kr/700.css';
// Cyrillic (Russian) — Inter has Cyrillic; Outfit does not // Cyrillic (Russian) — Inter has Cyrillic; Outfit does not
import '@fontsource-variable/inter'; import '@fontsource-variable/inter';