Inline the initial i18n namespaces into the SSR HTML

This commit is contained in:
Kalle
2026-08-15 16:06:07 +03:00
parent 37930ee938
commit d894b51e36
3 changed files with 107 additions and 0 deletions

View File

@@ -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<string, Record<string, object>>;
}
}
/**
* 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(
/</g,
"\\u003c",
)}`;
});
return (
<script
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: scriptHtml }}
/>
);
}
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<string, Record<string, object>> {
if (typeof window !== "undefined" && window.__initialI18nStore) {
return window.__initialI18nStore;
}
const languages =
locale === DEFAULT_LANGUAGE ? [locale] : [locale, DEFAULT_LANGUAGE];
const store: Record<string, Record<string, object>> = {};
for (const language of languages) {
const bundles: Record<string, object> = {};
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<string, unknown> = {};
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;
}

View File

@@ -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

View File

@@ -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({
</SearchParamsProvider>
</React.StrictMode>
<ScrollRestoration />
<InitialI18nStore locale={locale} />
<Scripts />
</body>
</html>