From c4937362a20570cddd65fdb3acb8a6a05c396b9a Mon Sep 17 00:00:00 2001 From: Matt <57228426+xtantaudio@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:37:39 -0400 Subject: [PATCH] fix: i18n requests locale files that don't exist on disk The browser's reported locale (e.g. en-US) was interpolated directly into the translation file request path, but the shipped locale files only exist under the base language code (en), not the full regional variant. Every page load produced 11 404s for missing translation JSON files. Normalized the requested locale to match what's actually shipped before the i18next backend fetches it. Verified: page load now produces zero 404 requests for locale files. --- apps/web/src/core/hooks/useLang.test.ts | 44 ++++++ apps/web/src/core/hooks/useLang.ts | 11 +- apps/web/src/i18n-config.test.ts | 197 ++++++++++++++++++++++++ apps/web/src/i18n-config.ts | 168 +++++++++++++++----- 4 files changed, 383 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/core/hooks/useLang.test.ts create mode 100644 apps/web/src/i18n-config.test.ts diff --git a/apps/web/src/core/hooks/useLang.test.ts b/apps/web/src/core/hooks/useLang.test.ts new file mode 100644 index 000000000..f3f714ea8 --- /dev/null +++ b/apps/web/src/core/hooks/useLang.test.ts @@ -0,0 +1,44 @@ +import useLang from "@core/hooks/useLang.ts"; +import { act, renderHook } from "@testing-library/react"; +import i18n from "i18next"; +import { afterEach, describe, expect, it } from "vitest"; + +afterEach(async () => { + await act(async () => { + await i18n.changeLanguage("en"); + }); +}); + +describe("useLang().current", () => { + it.each([ + ["en", "en"], + // A US/GB browser resolves to the shipped `en` locale folder, but i18next + // may still report the regional code — the picker must not fall over. + ["en-US", "en"], + ["en-GB", "en"], + // Bare picker codes resolve to region qualified folders (`fi` -> `fi-FI`). + ["fi-FI", "fi"], + ["de-DE", "de"], + ["sv-SE", "sv"], + ])("reports %s as the %s picker entry", async (active, expected) => { + // Mirror production: the locale folder for this language really is loaded. + i18n.addResourceBundle(active, "common", { button: { cancel: "x" } }); + await act(async () => { + await i18n.changeLanguage(active); + }); + + const { result } = renderHook(() => useLang()); + + expect(result.current.current?.code).toBe(expected); + }); + + it("falls back to English for a language we do not ship", async () => { + await act(async () => { + await i18n.changeLanguage("th-TH"); + }); + + const { result } = renderHook(() => useLang()); + + expect(result.current.current?.code).toBe("en"); + }); +}); diff --git a/apps/web/src/core/hooks/useLang.ts b/apps/web/src/core/hooks/useLang.ts index be244f7f4..0060541b6 100644 --- a/apps/web/src/core/hooks/useLang.ts +++ b/apps/web/src/core/hooks/useLang.ts @@ -1,5 +1,6 @@ import { FALLBACK_LANGUAGE_CODE, + getLanguagePart, type Lang, type LangCode, supportedLanguages, @@ -22,12 +23,18 @@ function useLang() { ); const currentLanguage = useMemo((): Lang | undefined => { - const lang = supportedLanguages.find((l) => l.code === i18n.language); + // `i18n.language` can be a regional code that has no picker entry of its + // own (e.g. a `fi` browser resolves to the shipped `fi-FI` folder, and + // `en-US` resolves to `en`), so match on the language part as well. + const active = i18n.resolvedLanguage ?? i18n.language ?? ""; + const lang = + supportedLanguages.find((l) => l.code === active) ?? + supportedLanguages.find((l) => l.code === getLanguagePart(active)); if (lang) { return lang; } return supportedLanguages.find((l) => l.code === FALLBACK_LANGUAGE_CODE); - }, [i18n.language]); + }, [i18n.language, i18n.resolvedLanguage]); const collator = useMemo(() => { return new Intl.Collator(i18n.language, { sensitivity: "base" }); diff --git a/apps/web/src/i18n-config.test.ts b/apps/web/src/i18n-config.test.ts new file mode 100644 index 000000000..fbe647642 --- /dev/null +++ b/apps/web/src/i18n-config.test.ts @@ -0,0 +1,197 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + availableLocales, + buildLocaleFallbacks, + FALLBACK_LANGUAGE_CODE, + i18nOptions, + LOAD_PATH, + namespaces, + supportedLanguages, +} from "@app/i18n-config.ts"; +import { createInstance, type BackendModule } from "i18next"; +import { describe, expect, it } from "vitest"; + +const localesDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../public/i18n/locales", +); + +const localeFoldersOnDisk = () => + fs + .readdirSync(localesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + +const buildUrl = (lng: string, ns: string) => + LOAD_PATH.replace("{{lng}}", lng).replace("{{ns}}", ns); + +const fileForRequest = (lng: string, ns: string) => + path.join(localesDir, lng, `${ns}.json`); + +type Request = { url: string; lng: string; ns: string; status: 200 | 404 }; + +/** + * Stands in for i18next-http-backend: resolves the very same `loadPath` the + * browser would request, but against the files that are actually shipped in + * `public/i18n/locales` (which is what ends up in `dist/i18n/locales`). + */ +const recordingBackend = (requests: Request[]): BackendModule => ({ + type: "backend", + init: () => {}, + read: (lng, ns, callback) => { + const file = fileForRequest(lng, ns); + const exists = fs.existsSync(file); + requests.push({ + url: buildUrl(lng, ns), + lng, + ns, + status: exists ? 200 : 404, + }); + if (exists) { + callback(null, JSON.parse(fs.readFileSync(file, "utf8"))); + return; + } + callback(new Error("404"), false); + }, +}); + +async function loadWithDetectedLanguage(detected: string) { + const requests: Request[] = []; + const instance = createInstance(); + await instance.use(recordingBackend(requests)).init({ + ...i18nOptions, + lng: detected, + react: { useSuspense: false }, + }); + return { instance, requests }; +} + +describe("shipped locale folders", () => { + it("availableLocales matches what is on disk", () => { + expect([...availableLocales].sort()).toEqual(localeFoldersOnDisk()); + }); + + it("ships the English source locale as a plain `en` folder (crowdin source)", () => { + expect(availableLocales).toContain(FALLBACK_LANGUAGE_CODE); + expect(availableLocales).not.toContain("en-US"); + for (const ns of namespaces) { + expect(fs.existsSync(fileForRequest("en", ns))).toBe(true); + } + }); + + it("only ever interpolates locales that exist on disk", () => { + const configured = i18nOptions.supportedLngs as string[]; + expect([...configured].sort()).toEqual(localeFoldersOnDisk()); + }); + + it("does not treat regional variants of a shipped language as supported", () => { + // `nonExplicitSupportedLngs: true` is what made i18next request the + // non-existent /i18n/locales/en-US/*.json paths. + expect(i18nOptions.nonExplicitSupportedLngs).toBe(false); + }); +}); + +describe("buildLocaleFallbacks", () => { + it("maps bare language codes onto the regional folder that ships", () => { + const fallbacks = buildLocaleFallbacks(); + expect(fallbacks.default).toEqual([FALLBACK_LANGUAGE_CODE]); + expect(fallbacks.fi).toEqual(["fi-FI", "en"]); + expect(fallbacks.de).toEqual(["de-DE", "en"]); + expect(fallbacks.fr).toEqual(["fr-FR", "en"]); + expect(fallbacks.ru).toEqual(["ru-RU", "en"]); + expect(fallbacks.sv).toEqual(["sv-SE", "en"]); + expect(fallbacks.cs).toEqual(["cs-CZ", "en"]); + }); + + it("never maps a language onto a locale that is not shipped", () => { + for (const chain of Object.values(buildLocaleFallbacks())) { + for (const locale of chain) { + expect(availableLocales as readonly string[]).toContain(locale); + } + } + }); + + it("leaves `en` and ambiguous multi-variant languages unmapped", () => { + const fallbacks = buildLocaleFallbacks(); + expect(fallbacks.en).toBeUndefined(); + expect(fallbacks.pt).toBeUndefined(); + expect(fallbacks.zh).toBeUndefined(); + }); + + it("every language offered in the picker resolves to a shipped folder", () => { + const fallbacks = buildLocaleFallbacks(); + for (const { code } of supportedLanguages) { + const resolvesTo = (availableLocales as readonly string[]).includes(code) + ? code + : fallbacks[code]?.[0]; + expect( + resolvesTo, + `no shipped locale for picker entry "${code}"`, + ).toBeDefined(); + expect(fs.existsSync(path.join(localesDir, resolvesTo as string))).toBe( + true, + ); + } + }); +}); + +describe("locale request resolution (regression: en-US 404s)", () => { + it("an en-US browser loads every namespace from the `en` folder, with no 404s", async () => { + const { instance, requests } = await loadWithDetectedLanguage("en-US"); + + expect(requests.filter((r) => r.status === 404)).toEqual([]); + expect(requests.map((r) => r.url)).not.toContain( + "/i18n/locales/en-US/common.json", + ); + + // Every namespace from the live bug report must now come from `en`. + for (const ns of namespaces) { + expect(requests).toContainEqual( + expect.objectContaining({ + url: `/i18n/locales/en/${ns}.json`, + status: 200, + }), + ); + expect(instance.hasResourceBundle("en", ns)).toBe(true); + } + + expect(instance.resolvedLanguage).toBe("en"); + expect(instance.t("button.cancel", { ns: "common" })).toBe("Cancel"); + }); + + it.each([ + ["en-US", "en"], + ["en-GB", "en"], + ["en-CA", "en"], + ["en", "en"], + ["fi", "fi-FI"], + ["fi-FI", "fi-FI"], + ["de", "de-DE"], + ["de-AT", "de-DE"], + ["cs-CZ", "cs-CZ"], + ["pt", "pt-BR"], + ["pt-PT", "pt-PT"], + ["zh", "zh-CN"], + ["zh-TW", "zh-TW"], + ["th-TH", "en"], // unsupported language -> English source locale + ])( + "detected %s resolves to the shipped %s folder and never 404s", + async (detected, expectedLocale) => { + const { instance, requests } = await loadWithDetectedLanguage(detected); + + expect( + requests.filter((r) => r.status === 404).map((r) => r.url), + ).toEqual([]); + expect(instance.resolvedLanguage).toBe(expectedLocale); + for (const request of requests) { + expect(availableLocales as readonly string[]).toContain(request.lng); + } + for (const ns of namespaces) { + expect(instance.hasResourceBundle(expectedLocale, ns)).toBe(true); + } + }, + ); +}); diff --git a/apps/web/src/i18n-config.ts b/apps/web/src/i18n-config.ts index ff259bfc7..bb34650ed 100644 --- a/apps/web/src/i18n-config.ts +++ b/apps/web/src/i18n-config.ts @@ -1,4 +1,4 @@ -import i18next from "i18next"; +import i18next, { type InitOptions } from "i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import Backend from "i18next-http-backend"; import { initReactI18next } from "react-i18next"; @@ -23,44 +23,142 @@ export const supportedLanguages: Lang[] = [ export const FALLBACK_LANGUAGE_CODE: LangCode = "en"; +/** + * The locale folders that are actually shipped in `public/i18n/locales/` (and + * therefore in `dist/i18n/locales/` after a build). + * + * These are the only values that may ever be interpolated into the backend + * `loadPath`, otherwise the request 404s. + * + * Note the deliberate asymmetry: the English *source* strings live in a plain + * `en` folder because that is what `crowdin.yml` uploads + * (`/public/i18n/locales/en/*.json`), while Crowdin writes every *translation* + * back to a region qualified `%locale%` folder (`cs-CZ`, `pt-BR`, ...). So `en` + * has no region suffix while every other locale does. + * + * Keep this list in sync with the directory listing — `i18n-config.test.ts` + * fails if it drifts. + */ +export const availableLocales = [ + "be-BY", + "bg-BG", + "cs-CZ", + "de-DE", + "en", + "es-ES", + "fi-FI", + "fr-FR", + "hu-HU", + "it-IT", + "ja-JP", + "ko-KR", + "nl-NL", + "pl-PL", + "pt-BR", + "pt-PT", + "ru-RU", + "sv-SE", + "tr-TR", + "uk-UA", + "zh-CN", + "zh-TW", +] as const satisfies readonly string[]; + +export type AvailableLocale = (typeof availableLocales)[number]; + +export const getLanguagePart = (locale: string): string => + locale.split("-")[0] ?? locale; + +/** + * Builds the i18next `fallbackLng` map from the locales we actually ship. + * + * A bare language code (`de`, which is what the language picker stores, or a + * browser reporting just `cs`) has no folder of its own, so it has to fall back + * to the regional folder that does exist (`de-DE`, `cs-CZ`) and then to the + * English source locale. + * + * Language codes that already have their own folder (`en`) and languages that + * ship more than one regional variant (`pt` -> pt-BR/pt-PT, `zh` -> zh-CN/zh-TW) + * are intentionally left out: i18next's own `supportedLngs` prefix matching + * picks the first shipped variant for those, and adding a map entry would make + * every pt-PT/zh-TW user download the other dialect as an intermediate fallback. + */ +export function buildLocaleFallbacks( + locales: readonly string[] = availableLocales, + fallbackLocale: string = FALLBACK_LANGUAGE_CODE, +): Record { + const fallbacks: Record = { + default: [fallbackLocale], + }; + + const variantsPerLanguage = new Map(); + for (const locale of locales) { + const languagePart = getLanguagePart(locale); + variantsPerLanguage.set(languagePart, [ + ...(variantsPerLanguage.get(languagePart) ?? []), + locale, + ]); + } + + for (const [languagePart, variants] of variantsPerLanguage) { + const [onlyVariant] = variants; + // Ambiguous (pt-BR/pt-PT) or already a bare folder (en) — leave to i18next. + if (variants.length !== 1 || !onlyVariant || onlyVariant === languagePart) { + continue; + } + fallbacks[languagePart] = [onlyVariant, fallbackLocale]; + } + + return fallbacks; +} + +export const LOAD_PATH = "/i18n/locales/{{lng}}/{{ns}}.json"; + +export const namespaces = [ + "channels", + "connections", + "commandPalette", + "common", + "config", + "moduleConfig", + "dialog", + "messages", + "nodes", + "ui", + "map", +]; + +export const i18nOptions: InitOptions = { + backend: { + // `{{lng}}` is interpolated verbatim, so it must always be one of + // `availableLocales`. `supportedLngs` below guarantees that: an unshipped + // code such as `en-US` is dropped from the resolution hierarchy and + // narrowed to the folder that does exist (`en`) instead of being requested + // and 404ing. + loadPath: LOAD_PATH, + }, + react: { + useSuspense: true, + }, + supportedLngs: [...availableLocales], + // Must stay false: when true, i18next treats `en-US` as supported because + // `en` is, and then still requests `/i18n/locales/en-US/*.json`, which does + // not exist on disk. + nonExplicitSupportedLngs: false, + detection: { + order: ["localStorage", "navigator"], + caches: ["localStorage"], + }, + fallbackLng: buildLocaleFallbacks(), + fallbackNS: ["common", "ui", "dialog"], + ns: namespaces, +}; + i18next .use(Backend) .use(initReactI18next) .use(LanguageDetector) .init({ - backend: { - // With this setup, {{lng}} will correctly resolve to 'en-US', 'fi-FI', etc. - loadPath: "/i18n/locales/{{lng}}/{{ns}}.json", - }, - react: { - useSuspense: true, - }, - nonExplicitSupportedLngs: true, - detection: { - order: ["localStorage", "navigator"], - caches: ["localStorage"], - }, - fallbackLng: { - default: [FALLBACK_LANGUAGE_CODE], - fi: ["fi-FI", FALLBACK_LANGUAGE_CODE], - fr: ["fr-FR", FALLBACK_LANGUAGE_CODE], - ru: ["ru-RU", FALLBACK_LANGUAGE_CODE], - sv: ["sv-SE", FALLBACK_LANGUAGE_CODE], - de: ["de-DE", FALLBACK_LANGUAGE_CODE], - }, - fallbackNS: ["common", "ui", "dialog"], + ...i18nOptions, debug: import.meta.env.MODE === "development", - ns: [ - "channels", - "connections", - "commandPalette", - "common", - "config", - "moduleConfig", - "dialog", - "messages", - "nodes", - "ui", - "map", - ], });