feat(dashboard): add i18n runtime with per-locale code-splitting (U3)

Initialize the browser i18next instance with lazy per-locale catalog loading,
localStorage->navigator->htmlTag detection, script-aware zh fallback, and the
shared @fusion/i18n config. Catalogs are synced from @fusion/i18n into a
gitignored app/locales/ (predev/prebuild) and imported app-relative so Vite
emits one chunk per locale/namespace (verified: 15 chunks, none inlined into
the main bundle). First paint is gated on i18nReady to avoid raw-key flashes;
<I18nextProvider> wraps the App provider stack; vendor-i18n manualChunk added.

A build-assertion script (verify:locale-chunks) guards the KTD3a splitting
invariant. Fallback/namespace behavior is covered by @fusion/i18n config tests;
the live instance is verified via the build assertion rather than a unit test
(the dynamic catalog backend is impractical to exercise in vitest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-03 08:16:08 -07:00
parent 9072d71306
commit cd71b92930
7 changed files with 180 additions and 21 deletions

View File

@@ -49,6 +49,8 @@ import { useProjects } from "./hooks/useProjects";
import { useAgents } from "./hooks/useAgents";
import { useNodes } from "./hooks/useNodes";
import { useCurrentProject } from "./hooks/useCurrentProject";
import { I18nextProvider } from "react-i18next";
import i18n from "./i18n";
import { ToastProvider, useToast } from "./hooks/useToast";
import { ConfirmDialogProvider } from "./hooks/useConfirm";
import { useTheme } from "./hooks/useTheme";
@@ -2043,16 +2045,18 @@ function AppInner() {
export function App() {
return (
<ToastProvider>
<ShellHostProvider>
<ShellProvider>
<NodeProvider>
<ConfirmDialogProvider>
<AppInner />
</ConfirmDialogProvider>
</NodeProvider>
</ShellProvider>
</ShellHostProvider>
</ToastProvider>
<I18nextProvider i18n={i18n}>
<ToastProvider>
<ShellHostProvider>
<ShellProvider>
<NodeProvider>
<ConfirmDialogProvider>
<AppInner />
</ConfirmDialogProvider>
</NodeProvider>
</ShellProvider>
</ShellHostProvider>
</ToastProvider>
</I18nextProvider>
);
}

View File

@@ -0,0 +1,55 @@
import { baseInitOptions, DASHBOARD_NAMESPACES, DEFAULT_NAMESPACE } from "@fusion/i18n/config";
import i18next from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next";
/**
* Browser i18next instance for the dashboard.
*
* Catalogs are loaded lazily per locale: only the active locale's namespaces
* are fetched on first paint; switching language fetches the new locale's
* chunk on demand. The dynamic import is **app-relative** over the generated
* `app/locales/` tree (synced from @fusion/i18n by scripts/sync-locales.mjs)
* so Vite statically analyses it and emits one chunk per locale/namespace.
*/
/** localStorage key for the persisted language. Uses the neighbor-consistent
* `kb-dashboard-*` prefix (see useTheme.ts) — not changed to `fn-` here; that
* belongs to the brand-rename track. */
export const LANGUAGE_STORAGE_KEY = "kb-dashboard-language";
i18next
.use(LanguageDetector)
.use(
resourcesToBackend(
(language: string, namespace: string) =>
import(`../locales/${language}/${namespace}.json`),
),
)
.use(initReactI18next);
export const i18nReady = i18next.init({
...baseInitOptions(),
ns: [...DASHBOARD_NAMESPACES],
defaultNS: DEFAULT_NAMESPACE,
detection: {
order: ["localStorage", "navigator", "htmlTag"],
lookupLocalStorage: LANGUAGE_STORAGE_KEY,
caches: ["localStorage"],
},
react: {
// First paint is gated on `i18nReady` in main.tsx, so Suspense is not
// needed to avoid raw-key flashes and would otherwise require a boundary
// around every translated subtree.
useSuspense: false,
},
});
i18next.on("languageChanged", (language) => {
if (typeof document !== "undefined") {
document.documentElement.lang = language;
}
});
export default i18next;

View File

@@ -8,6 +8,7 @@ import { installVersionCheck } from "./versionCheck";
import { installSwUpdate } from "./swUpdate";
import { bootstrapShellHostContext } from "./shell-host";
import { registerBundledPluginViews } from "./plugins/registerBundledPluginViews";
import { i18nReady } from "./i18n";
import "./styles.css";
// Install the bearer-token fetch wrapper before React mounts so every API
@@ -19,14 +20,20 @@ installVersionCheck();
bootstrapShellHostContext();
registerBundledPluginViews();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RootErrorBoundary>
<DesktopLaunchGate>
<App />
</DesktopLaunchGate>
</RootErrorBoundary>
</StrictMode>,
);
// Gate first paint on the active locale's catalogs so the UI never flashes raw
// translation keys. The catalog is a small local chunk, so this is a brief
// wait; `.finally` ensures we still render if i18n init fails (strings then
// fall back to keys/en rather than blocking the app).
void i18nReady.finally(() => {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RootErrorBoundary>
<DesktopLaunchGate>
<App />
</DesktopLaunchGate>
</RootErrorBoundary>
</StrictMode>,
);
installSwUpdate();
installSwUpdate();
});

View File

@@ -52,8 +52,12 @@
"README.md"
],
"scripts": {
"gen:locales": "node scripts/sync-locales.mjs",
"verify:locale-chunks": "node scripts/assert-locale-chunks.mjs",
"prebuild": "node scripts/sync-locales.mjs",
"build": "vite build && tsc",
"build:client": "vite build",
"predev:serve": "node scripts/sync-locales.mjs",
"dev": "pnpm build && pnpm typecheck && pnpm dev:serve",
"dev:serve": "vite dev",
"pretest": "node ../../scripts/ensure-test-artifacts.mjs",

View File

@@ -0,0 +1,50 @@
// KTD3a regression guard: after a client build, assert that each locale's
// catalogs are emitted as their own async chunks and are NOT folded into the
// main entry chunk. If the app-relative dynamic import ever stops being
// statically analysable, Vite silently inlines every catalog into the main
// bundle with only a build warning — this check turns that into a hard failure.
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const assetsDir = join(here, "..", "dist", "client", "assets");
const namespaces = ["common", "app", "errors"];
if (!existsSync(assetsDir)) {
console.error(`assert-locale-chunks: ${assetsDir} not found — run the client build first.`);
process.exit(1);
}
const files = readdirSync(assetsDir);
const errors = [];
// One chunk per dashboard namespace per locale (5 locales) → at least 5 each.
for (const ns of namespaces) {
const matches = files.filter((f) => new RegExp(`^${ns}-[^/]+\\.js$`).test(f));
if (matches.length < 5) {
errors.push(
`expected >=5 split chunks for namespace "${ns}" (one per locale), found ${matches.length}`,
);
}
}
// The main entry chunk must not carry catalog payloads — a translated marker
// string from a non-en catalog appearing in index-*.js means splitting broke.
const indexFile = files.find((f) => /^index-[^/]+\.js$/.test(f));
if (indexFile) {
const body = readFileSync(join(assetsDir, indexFile), "utf8");
// i18next-resources-to-backend chunks are referenced by dynamic import, not
// inlined; a literal catalog object in index would show the column labels.
if (/"in-review":"In Review"/.test(body) && /"archived":"Archived"/.test(body)) {
errors.push(`catalog content found inlined in ${indexFile} — locale chunks were not split`);
}
}
if (errors.length) {
console.error("assert-locale-chunks FAILED:");
for (const e of errors) console.error(` - ${e}`);
process.exit(1);
}
console.log("assert-locale-chunks: per-locale catalog chunks emitted correctly.");

View File

@@ -0,0 +1,30 @@
// Copies the authored @fusion/i18n catalogs into the dashboard tree so Vite can
// code-split them per locale via a plainly app-relative dynamic import. The
// generated app/locales/ dir is gitignored — @fusion/i18n/locales is the
// source-of-truth. Only the dashboard namespaces are copied (the terminal-only
// `cli` namespace is skipped). Runs as a predev/prebuild step.
import { cpSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const dashboardRoot = join(here, "..");
const srcLocales = join(dashboardRoot, "..", "i18n", "locales");
const destLocales = join(dashboardRoot, "app", "locales");
// Keep in sync with DASHBOARD_NAMESPACES in @fusion/i18n config.ts.
const DASHBOARD_NAMESPACES = ["common", "app", "errors"];
const locales = readdirSync(srcLocales, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
rmSync(destLocales, { recursive: true, force: true });
for (const lng of locales) {
mkdirSync(join(destLocales, lng), { recursive: true });
for (const ns of DASHBOARD_NAMESPACES) {
cpSync(join(srcLocales, lng, `${ns}.json`), join(destLocales, lng, `${ns}.json`));
}
}
console.log(`Synced ${locales.length} locale(s) into app/locales: ${locales.join(", ")}`);

View File

@@ -168,6 +168,15 @@ export default defineConfig({
return "vendor-codemirror";
}
if (
id.includes("/node_modules/i18next/") ||
id.includes("/node_modules/react-i18next/") ||
id.includes("/node_modules/i18next-browser-languagedetector/") ||
id.includes("/node_modules/i18next-resources-to-backend/")
) {
return "vendor-i18n";
}
return undefined;
},
},