From d894b51e363cd8dc647e2ccfa1c37d98deba64a1 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:06:07 +0300 Subject: [PATCH] Inline the initial i18n namespaces into the SSR HTML --- app/modules/i18n/InitialI18nStore.tsx | 100 ++++++++++++++++++++++++++ app/modules/i18n/loader.ts | 5 ++ app/root.tsx | 2 + 3 files changed, 107 insertions(+) create mode 100644 app/modules/i18n/InitialI18nStore.tsx diff --git a/app/modules/i18n/InitialI18nStore.tsx b/app/modules/i18n/InitialI18nStore.tsx new file mode 100644 index 000000000..d62bf2ec1 --- /dev/null +++ b/app/modules/i18n/InitialI18nStore.tsx @@ -0,0 +1,100 @@ +import type { i18n } from "i18next"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useMatches } from "react-router"; +import { DEFAULT_LANGUAGE } from "./config"; + +declare global { + interface Window { + __initialI18nStore?: Record>; + } +} + +/** + * Embeds the translation bundles the server rendered with (the page language + * plus the English fallback) into the HTML so client-side i18next can + * initialize from them instead of fetching every initial namespace over HTTP + * before hydration can start. + */ +export function InitialI18nStore({ locale }: { locale: string }) { + const { i18n } = useTranslation(); + const matches = useMatches(); + + // computed once so client-side navigations don't re-serialize the store; + // on hydration the embedded store round-trips to the exact server markup + const [scriptHtml] = React.useState(() => { + const store = initialStore(i18n, locale, namespacesFromMatches(matches)); + + return `window.__initialI18nStore=${JSON.stringify(store).replace( + / + ); +} + +function namespacesFromMatches(matches: Array<{ handle: unknown }>): string[] { + const namespaces = matches.flatMap((match) => { + const handle = match.handle; + if (typeof handle !== "object" || handle === null) return []; + if (!("i18n" in handle)) return []; + const value = (handle as { i18n: unknown }).i18n; + if (typeof value === "string") return [value]; + if ( + Array.isArray(value) && + value.every((item) => typeof item === "string") + ) { + return value as string[]; + } + return []; + }); + + return [...new Set(namespaces)]; +} + +function initialStore( + i18nInstance: i18n, + locale: string, + namespaces: string[], +): Record> { + if (typeof window !== "undefined" && window.__initialI18nStore) { + return window.__initialI18nStore; + } + + const languages = + locale === DEFAULT_LANGUAGE ? [locale] : [locale, DEFAULT_LANGUAGE]; + + const store: Record> = {}; + for (const language of languages) { + const bundles: Record = {}; + for (const namespace of namespaces) { + const bundle = i18nInstance.getResourceBundle(language, namespace); + if (bundle) { + bundles[namespace] = withoutUntranslated(bundle); + } + } + store[language] = bundles; + } + + return store; +} + +function withoutUntranslated(value: object): object { + const result: Record = {}; + for (const [key, child] of Object.entries(value)) { + if (child === "") continue; + if (typeof child === "object" && child !== null && !Array.isArray(child)) { + result[key] = withoutUntranslated(child); + } else { + result[key] = child; + } + } + + return result; +} diff --git a/app/modules/i18n/loader.ts b/app/modules/i18n/loader.ts index 3646caa0c..a794d4d86 100644 --- a/app/modules/i18n/loader.ts +++ b/app/modules/i18n/loader.ts @@ -12,6 +12,11 @@ export function i18nLoader() { .init({ ...config, ns: getInitialNamespaces(), + // the SSR HTML embeds the initial namespaces (see InitialI18nStore) so + // init needs no HTTP round-trips before hydration; anything not embedded + // still loads via the backend thanks to partialBundledLanguages + resources: window.__initialI18nStore, + partialBundledLanguages: true, backend: { loadPath: (lng: any, ns: any) => { // use vite static asset fingerprinting diff --git a/app/root.tsx b/app/root.tsx index b875f5c62..6aa4e6be1 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -59,6 +59,7 @@ import { UnsavedChangesGuard } from "./form/UnsavedChangesGuard"; import { useUserIntlPreference } from "./hooks/intl/useUserIntlPreference"; import { useHydrated } from "./hooks/useHydrated"; import { DEFAULT_LANGUAGE } from "./modules/i18n/config"; +import { InitialI18nStore } from "./modules/i18n/InitialI18nStore"; import { getLocale, i18nCookie, @@ -253,6 +254,7 @@ function Document({ +