diff --git a/.agents/rules/frontend/shared.md b/.agents/rules/frontend/shared.md
index 96b4d0dc1a..135c0d7a1c 100644
--- a/.agents/rules/frontend/shared.md
+++ b/.agents/rules/frontend/shared.md
@@ -42,7 +42,8 @@ mutation.mutate({ text, clientId: crypto.randomUUID() });
## Routing (`routes.tsx`, `App.tsx`)
-- `createBrowserRouter`, flat config. Pages are **named exports** loaded via a `lazyNamed(importer, name)` helper (adapts named → `React.lazy`'s default contract). No default exports.
+- `createBrowserRouter`, flat config. Pages are **named exports** loaded via a `lazyNamed(importer, name, namespaces)` helper (adapts named → `React.lazy`'s default contract, and loads the route's lazy translation namespaces alongside the chunk). No default exports.
+- **Initial bundle**: `vite.config.ts` groups everything the entry statically imports (Rolldown's `$initial` tag) into one `vendor` and one `app` chunk; route pages stay lazy. Keep heavy, post-login-only deps (SignalR, zod forms, cmdk) out of the static graph of `main.tsx` with a dynamic `import()`.
- Nesting: public auth routes → `` → `` → page children. `errorElement: `.
- Provider tree: `ThemeProvider > QueryClientProvider > AuthProvider > … > RouterProvider` + `sonner` ``.
@@ -76,7 +77,7 @@ So "neutrals must be chroma 0" is a **dashboard** rule. Admin neutrals are inten
Every string a user reads goes through `t()`; English and Portuguese ship today. The backend negotiates its own messages from the `Accept-Language` that `apiFetch` sends, so ProblemDetails text is shown as-is (`.agents/rules/localization.md`).
-- **One namespace per area**: `src/locales/en-US/{ns}.json`, registered with one line in the catalog map in `src/i18n.ts` (`CATALOGS` in admin, `catalogs` in dashboard). `src/locales/pt-BR/{ns}.json` is optional: `src/locales/pt-BR.ts` picks every file up into one lazy chunk, and a missing key renders in English.
+- **One namespace per area**: `src/locales/en-US/{ns}.json`, registered with one line in `src/i18n.ts`. A namespace the app shell renders with (layout, sign-in, banners, notification bell; plus admin's landing dashboard) goes in the eager map (`EAGER_CATALOGS` in admin, `eagerCatalogs` in dashboard) and ships in the main bundle. Every other namespace goes in the lazy map (`LAZY_CATALOGS` / `lazyCatalogs`) as `() => import(...)`, and **every route whose page or components use it lists it** in `lazyNamed(importer, name, ["ns", …])`, which loads it in parallel with the page chunk. A route that forgets a namespace renders guessed labels and logs `[i18n] missing key` in dev. `src/locales/pt-BR/{ns}.json` is optional: `src/locales/pt-BR.ts` picks every file up into one lazy chunk, and a missing key renders in English.
- **Keys are flat and dotted** inside the file (`"header.title"`, `"create.validation.url"`). Interpolate with `{{name}}`; counts use `_one` / `_other` plus `{{count, number}}` and are called with `{ count }`.
- **In a component**: `const { t } = useTranslation("ns")`. Another namespace through the list form, `useTranslation(["activity", "audits"])` with `t("audits:…")`, or through a second translator when a helper requires one bound to it (`const { t: tCommon } = useTranslation("common")` for `describeUploadError`). Never prefix a key with the namespace the translator is already bound to.
- **Keys are typed.** `src/i18next.d.ts` types every key against the en-US catalogs, so add the key to the JSON first: a key the namespace lacks fails `tsc -b`. A helper that receives the translator takes `TFunction<"ns">`; a key stored in data (nav items, tabs, enum labels) is `ParseKeys<"ns">`, and an enum label goes through a map typed against the enum (`satisfies Record>`) rather than a template key, so a new member without a label stops compiling. Billing and impersonation statuses have their maps in `src/lib/status-keys.ts`, read through `statusKey()`, which returns `undefined` for a status added after the build so the caller can show the raw value; `tests/i18n/status-keys.spec.ts` checks every member of the backend enum against those maps and both catalogs. A template key still compiles when the call passes `defaultValue`, but its key is not checked; a few remain.
@@ -95,7 +96,7 @@ Every string a user reads goes through `t()`; English and Portuguese ship today.
1. API: extend `src/api/{feature}.ts` — hand-written types + `apiFetch` calls.
2. Page: `src/pages/{area}/{name}.tsx`, **named** export. `useQuery` with hierarchical key; `useMutation` invalidating in `onSuccess`, passing per-call data via `mutate(arg)`. Text through `t()` from the area's namespace, with the keys added to `src/locales/en-US/{ns}.json` (see Translations).
-3. Route: add `const X = lazyNamed(() => import("@/pages/area/name"), "XPage")` and a child route under `AppShell`.
+3. Route: add `const X = lazyNamed(() => import("@/pages/area/name"), "XPage", ["ns"])` (its lazy translation namespaces) and a child route under `AppShell`.
4. Test: `tests/{area}/{name}.spec.ts` with seed + shell mocks + page mocks.
Then apply the app-specific steps in `admin.md` / `dashboard.md` (forms, permission gating, suspense, etc.).
diff --git a/.agents/skills/add-react-page/SKILL.md b/.agents/skills/add-react-page/SKILL.md
index b25f2411da..4c17ecd1ab 100644
--- a/.agents/skills/add-react-page/SKILL.md
+++ b/.agents/skills/add-react-page/SKILL.md
@@ -51,7 +51,8 @@ export async function create{Resource}(input: Create{Resource}Input) {
Every string the page shows is a key in its area's namespace. Add the English entries **before** using
them: keys are typed against the en-US catalogs, so `t("{resources}.title")` fails `tsc -b` until the
-key exists. A new area gets a new `{ns}.json` plus one line in the catalog map in `src/i18n.ts`.
+key exists. A new area gets a new `{ns}.json` plus one line in the lazy catalog map in `src/i18n.ts`
+(`LAZY_CATALOGS` in admin, `lazyCatalogs` in dashboard), and its routes list `{ns}` (Step 5).
`src/locales/pt-BR/{ns}.json` is optional; a missing key renders in English.
```json
@@ -102,7 +103,9 @@ If you need to track the in-flight item (e.g. a per-row busy state), use `onMuta
## Step 5 — Register the route (`routes.tsx`)
```tsx
-const {Resource}ListPage = lazyNamed(() => import("@/pages/{area}/list"), "{Resource}ListPage");
+// The last argument lists the lazy translation namespaces the page and its components use;
+// they load alongside the page chunk. Shell namespaces (common, auth, …) are always loaded.
+const {Resource}ListPage = lazyNamed(() => import("@/pages/{area}/list"), "{Resource}ListPage", ["{ns}"]);
// admin — under AppShell.children, gated:
{ path: "{resources}", element: <{Resource}ListPage /> },
// dashboard — under AppShell.children, suspense only:
diff --git a/clients/admin/src/i18n.ts b/clients/admin/src/i18n.ts
index 369a94eacb..c18bc63432 100644
--- a/clients/admin/src/i18n.ts
+++ b/clients/admin/src/i18n.ts
@@ -5,17 +5,7 @@ import { fallbackChain, missingKeyFallback } from "@/lib/i18n-fallback";
import enCommon from "@/locales/en-US/common.json";
import enNav from "@/locales/en-US/nav.json";
import enAuth from "@/locales/en-US/auth.json";
-import enSettings from "@/locales/en-US/settings.json";
-import enSessions from "@/locales/en-US/sessions.json";
-import enUsers from "@/locales/en-US/users.json";
-import enRoles from "@/locales/en-US/roles.json";
-import enImpersonation from "@/locales/en-US/impersonation.json";
-import enBilling from "@/locales/en-US/billing.json";
-import enTenants from "@/locales/en-US/tenants.json";
-import enWebhooks from "@/locales/en-US/webhooks.json";
-import enAudits from "@/locales/en-US/audits.json";
import enNotifications from "@/locales/en-US/notifications.json";
-import enHealth from "@/locales/en-US/health.json";
import enDashboard from "@/locales/en-US/dashboard.json";
// Canonical tags: specific (what the switcher offers, User.Locale persists, the claim carries).
@@ -23,33 +13,75 @@ export const SUPPORTED = ["en-US", "pt-BR"] as const;
type Catalog = Record;
-// Translation namespaces keyed by name → en-US catalog. This map is the single source
-// string-migration waves extend: to add a namespace, import its en-US catalog and add one entry
-// here — `resources` and `ns` below derive from it, so no other wiring changes. Its pt-BR file
-// (optional) is picked up by src/locales/pt-BR.ts; a namespace or key pt-BR lacks renders in
-// English through fallbackChain. Its type is also what src/i18next.d.ts checks every t() key against.
-const CATALOGS = {
+// en-US catalogs the app shell renders with (layout, sign-in, notification bell, the landing
+// dashboard). They ship in the main bundle so the first paint never waits on a catalog.
+const EAGER_CATALOGS = {
common: enCommon,
nav: enNav,
auth: enAuth,
- settings: enSettings,
- sessions: enSessions,
- users: enUsers,
- roles: enRoles,
- impersonation: enImpersonation,
- billing: enBilling,
- tenants: enTenants,
- webhooks: enWebhooks,
- audits: enAudits,
notifications: enNotifications,
- health: enHealth,
dashboard: enDashboard,
} satisfies Record;
-export type Catalogs = typeof CATALOGS;
+// en-US catalogs only route pages use. Each is its own small chunk, loaded by loadNamespaces()
+// in parallel with the page's chunk (see `lazyNamed` in src/routes.tsx), so the page never
+// renders before its text is there.
+//
+// To add a namespace: add its en-US JSON here (or to EAGER_CATALOGS when the shell uses it) and
+// list it on the routes that use it. `resources`, `ns` and the t() key types all derive from
+// these two maps. Its pt-BR file (optional) is picked up by src/locales/pt-BR.ts; a namespace or
+// key pt-BR lacks renders in English through fallbackChain.
+const LAZY_CATALOGS = {
+ settings: () => import("@/locales/en-US/settings.json"),
+ sessions: () => import("@/locales/en-US/sessions.json"),
+ users: () => import("@/locales/en-US/users.json"),
+ roles: () => import("@/locales/en-US/roles.json"),
+ impersonation: () => import("@/locales/en-US/impersonation.json"),
+ billing: () => import("@/locales/en-US/billing.json"),
+ tenants: () => import("@/locales/en-US/tenants.json"),
+ webhooks: () => import("@/locales/en-US/webhooks.json"),
+ audits: () => import("@/locales/en-US/audits.json"),
+ health: () => import("@/locales/en-US/health.json"),
+} satisfies Record Promise<{ default: Catalog }>>;
-// en-US ships in the main bundle: it is the terminal fallback and must resolve synchronously.
-// Every other language is one lazy chunk, fetched before the UI switches to it.
+export type LazyNamespace = keyof typeof LAZY_CATALOGS;
+
+// What src/i18next.d.ts checks every t() key against: the eager and lazy catalogs together.
+export type Catalogs = typeof EAGER_CATALOGS & {
+ [K in LazyNamespace]: Awaited>["default"];
+};
+
+const NAMESPACES = [...Object.keys(EAGER_CATALOGS), ...Object.keys(LAZY_CATALOGS)];
+
+const pendingNamespaces = new Map>();
+
+// Loads route namespaces into en-US, the terminal fallback. Other languages need no work here:
+// loadLanguage adds every namespace of a language in one go. A failed load is forgotten so the
+// next navigation retries it.
+export function loadNamespaces(namespaces: readonly LazyNamespace[]): Promise {
+ return Promise.all(
+ namespaces.map((ns) => {
+ let pending = pendingNamespaces.get(ns);
+ if (!pending) {
+ pending = LAZY_CATALOGS[ns]().then(
+ ({ default: catalog }) => {
+ i18n.addResourceBundle("en-US", ns, catalog, true, true);
+ },
+ (error: unknown) => {
+ pendingNamespaces.delete(ns);
+ throw error;
+ },
+ );
+ pendingNamespaces.set(ns, pending);
+ }
+ return pending;
+ }),
+ ).then(() => undefined);
+}
+
+// en-US is the terminal fallback: its shell catalogs ship in the main bundle and its route
+// catalogs load with their routes. Every other language is one lazy chunk, fetched before the UI
+// switches to it.
const LAZY_LANGUAGES: Partial<
Record<(typeof SUPPORTED)[number], () => Promise<{ default: Record }>>
> = {
@@ -61,7 +93,7 @@ async function loadLanguage(lng: string) {
const load = LAZY_LANGUAGES[lng as (typeof SUPPORTED)[number]];
if (!load || loadedLanguages.has(lng)) return;
const { default: catalogs } = await load();
- for (const ns of Object.keys(CATALOGS)) {
+ for (const ns of NAMESPACES) {
const catalog = catalogs[ns];
if (catalog) i18n.addResourceBundle(lng, ns, catalog, true, true);
}
@@ -113,8 +145,9 @@ export async function initI18n(deploymentDefault: string) {
.use(LanguageDetector)
.use(initReactI18next)
.init({
- resources: { "en-US": CATALOGS },
- ns: Object.keys(CATALOGS),
+ // Route catalogs are added later through loadNamespaces().
+ resources: { "en-US": EAGER_CATALOGS },
+ ns: NAMESPACES,
fallbackLng: fallbackChain(deploymentDefault, SUPPORTED),
supportedLngs: [...SUPPORTED],
defaultNS: "common",
diff --git a/clients/admin/src/realtime/realtime-context.tsx b/clients/admin/src/realtime/realtime-context.tsx
index 9d45b0de73..a18ba4e0d7 100644
--- a/clients/admin/src/realtime/realtime-context.tsx
+++ b/clients/admin/src/realtime/realtime-context.tsx
@@ -8,16 +8,19 @@ import {
useState,
type ReactNode,
} from "react";
-import {
- HubConnection,
- HubConnectionBuilder,
- HubConnectionState,
- HttpTransportType,
- LogLevel,
-} from "@microsoft/signalr";
+import type { HubConnection } from "@microsoft/signalr";
import { env } from "@/env";
import { tokenStore } from "@/auth/token-store";
+// SignalR is about 25 KB gzip. It's imported on first connect, so the sign-in page and the
+// initial bundle don't carry it. Same approach as the dashboard's provider.
+type SignalRModule = typeof import("@microsoft/signalr");
+let signalRPromise: Promise | null = null;
+function loadSignalR(): Promise {
+ if (!signalRPromise) signalRPromise = import("@microsoft/signalr");
+ return signalRPromise;
+}
+
export type RealtimeStatus = "idle" | "connecting" | "connected" | "reconnecting" | "error";
type Listener = (payload: unknown) => void;
@@ -50,15 +53,16 @@ export function RealtimeProvider({ children }: { children: ReactNode }) {
stoppedRef.current = false;
const backoffs = [0, 2_000, 5_000, 10_000, 30_000];
- const buildConnection = () => {
+ const buildConnection = async () => {
+ const sr = await loadSignalR();
const url = `${env.apiBase || ""}/api/v1/realtime/hub`;
- return new HubConnectionBuilder()
+ return new sr.HubConnectionBuilder()
.withUrl(url, {
accessTokenFactory: () => tokenStore.getAccessToken() ?? "",
transport:
- HttpTransportType.WebSockets |
- HttpTransportType.ServerSentEvents |
- HttpTransportType.LongPolling,
+ sr.HttpTransportType.WebSockets |
+ sr.HttpTransportType.ServerSentEvents |
+ sr.HttpTransportType.LongPolling,
})
// SignalR's automatic reconnect re-negotiates internally, bypassing connect()'s
// token guard. When the user is signed out (token cleared/expired) every reconnect
@@ -70,7 +74,7 @@ export function RealtimeProvider({ children }: { children: ReactNode }) {
? (backoffs.slice(1)[ctx.previousRetryCount] ?? null)
: null,
})
- .configureLogging(LogLevel.Warning)
+ .configureLogging(sr.LogLevel.Warning)
.build();
};
@@ -99,7 +103,9 @@ export function RealtimeProvider({ children }: { children: ReactNode }) {
return;
}
setStatus("connecting");
- const conn = buildConnection();
+ const conn = await buildConnection();
+ // Torn down while the SignalR chunk was loading: don't open a socket we'd have to stop.
+ if (stoppedRef.current) return;
connectionRef.current = conn;
wireListeners(conn);
@@ -128,7 +134,9 @@ export function RealtimeProvider({ children }: { children: ReactNode }) {
stoppedRef.current = true;
const conn = connectionRef.current;
connectionRef.current = null;
- if (conn && conn.state !== HubConnectionState.Disconnected) {
+ // HubConnectionState is a string enum; comparing the literal avoids importing the
+ // lazy-loaded module here.
+ if (conn && (conn.state as string) !== "Disconnected") {
void conn.stop();
}
setStatus("idle");
diff --git a/clients/admin/src/routes.tsx b/clients/admin/src/routes.tsx
index 164059f5a2..dd9ea9b47f 100644
--- a/clients/admin/src/routes.tsx
+++ b/clients/admin/src/routes.tsx
@@ -7,6 +7,7 @@ import { RouteError } from "@/components/route-error";
import { LoginPage } from "@/pages/login";
import { DashboardPage } from "@/pages/dashboard";
import { NotFoundPage } from "@/pages/not-found";
+import { loadNamespaces, type LazyNamespace } from "@/i18n";
import {
AuditingPermissions,
BillingPermissions,
@@ -18,37 +19,42 @@ import {
// Lazy-loaded pages — each `import()` becomes its own bundle chunk so the
// initial paint only ships the shell + dashboard. We re-export the named
// page component as the chunk's `default` via the wrapper-import trick.
+// `namespaces` lists the route-only translation catalogs the page (and the
+// components it imports) use. They load in parallel with the page chunk, so
+// the page never renders before its text is there. The shell's namespaces
+// (common, nav, auth, notifications, dashboard) are always loaded.
const lazyNamed = (
loader: () => Promise>,
named: T,
+ namespaces: readonly LazyNamespace[] = [],
) =>
lazy(async () => {
- const mod = await loader();
+ const [mod] = await Promise.all([loader(), loadNamespaces(namespaces)]);
return { default: mod[named] as React.ComponentType };
});
-const TenantsListPage = lazyNamed(() => import("@/pages/tenants/list"), "TenantsListPage");
-const TenantDetailPage = lazyNamed(() => import("@/pages/tenants/detail"), "TenantDetailPage");
-const UsersListPage = lazyNamed(() => import("@/pages/users/list"), "UsersListPage");
-const UserDetailPage = lazyNamed(() => import("@/pages/users/detail"), "UserDetailPage");
-const RolesListPage = lazyNamed(() => import("@/pages/roles/list"), "RolesListPage");
-const RoleDetailPage = lazyNamed(() => import("@/pages/roles/detail"), "RoleDetailPage");
-const BillingLayout = lazyNamed(() => import("@/pages/billing/layout"), "BillingLayout");
-const PlansListPage = lazyNamed(() => import("@/pages/billing/plans-list"), "PlansListPage");
-const InvoicesListPage = lazyNamed(() => import("@/pages/billing/invoices-list"), "InvoicesListPage");
-const InvoiceDetailPage = lazyNamed(() => import("@/pages/billing/invoice-detail"), "InvoiceDetailPage");
-const TopupsListPage = lazyNamed(() => import("@/pages/billing/topups-list"), "TopupsListPage");
-const AuditsListPage = lazyNamed(() => import("@/pages/audits/list"), "AuditsListPage");
-const HealthPage = lazyNamed(() => import("@/pages/health/page"), "HealthPage");
-const ImpersonationListPage = lazyNamed(() => import("@/pages/impersonation/list"), "ImpersonationListPage");
-const WebhooksListPage = lazyNamed(() => import("@/pages/webhooks/list"), "WebhooksListPage");
-const WebhookDetailPage = lazyNamed(() => import("@/pages/webhooks/detail"), "WebhookDetailPage");
+const TenantsListPage = lazyNamed(() => import("@/pages/tenants/list"), "TenantsListPage", ["tenants"]);
+const TenantDetailPage = lazyNamed(() => import("@/pages/tenants/detail"), "TenantDetailPage", ["tenants", "impersonation"]);
+const UsersListPage = lazyNamed(() => import("@/pages/users/list"), "UsersListPage", ["users"]);
+const UserDetailPage = lazyNamed(() => import("@/pages/users/detail"), "UserDetailPage", ["users", "sessions"]);
+const RolesListPage = lazyNamed(() => import("@/pages/roles/list"), "RolesListPage", ["roles"]);
+const RoleDetailPage = lazyNamed(() => import("@/pages/roles/detail"), "RoleDetailPage", ["roles"]);
+const BillingLayout = lazyNamed(() => import("@/pages/billing/layout"), "BillingLayout", ["billing"]);
+const PlansListPage = lazyNamed(() => import("@/pages/billing/plans-list"), "PlansListPage", ["billing"]);
+const InvoicesListPage = lazyNamed(() => import("@/pages/billing/invoices-list"), "InvoicesListPage", ["billing"]);
+const InvoiceDetailPage = lazyNamed(() => import("@/pages/billing/invoice-detail"), "InvoiceDetailPage", ["billing"]);
+const TopupsListPage = lazyNamed(() => import("@/pages/billing/topups-list"), "TopupsListPage", ["billing"]);
+const AuditsListPage = lazyNamed(() => import("@/pages/audits/list"), "AuditsListPage", ["audits"]);
+const HealthPage = lazyNamed(() => import("@/pages/health/page"), "HealthPage", ["health"]);
+const ImpersonationListPage = lazyNamed(() => import("@/pages/impersonation/list"), "ImpersonationListPage", ["impersonation"]);
+const WebhooksListPage = lazyNamed(() => import("@/pages/webhooks/list"), "WebhooksListPage", ["webhooks"]);
+const WebhookDetailPage = lazyNamed(() => import("@/pages/webhooks/detail"), "WebhookDetailPage", ["webhooks"]);
const NotificationsInboxPage = lazyNamed(() => import("@/pages/notifications/inbox"), "NotificationsInboxPage");
-const SettingsLayout = lazyNamed(() => import("@/pages/settings/layout"), "SettingsLayout");
-const ProfileSettings = lazyNamed(() => import("@/pages/settings/profile"), "ProfileSettings");
-const SecuritySettings = lazyNamed(() => import("@/pages/settings/security"), "SecuritySettings");
-const SessionsSettings = lazyNamed(() => import("@/pages/settings/sessions"), "SessionsSettings");
-const AppearanceSettings = lazyNamed(() => import("@/pages/settings/appearance"), "AppearanceSettings");
+const SettingsLayout = lazyNamed(() => import("@/pages/settings/layout"), "SettingsLayout", ["settings"]);
+const ProfileSettings = lazyNamed(() => import("@/pages/settings/profile"), "ProfileSettings", ["settings"]);
+const SecuritySettings = lazyNamed(() => import("@/pages/settings/security"), "SecuritySettings", ["settings"]);
+const SessionsSettings = lazyNamed(() => import("@/pages/settings/sessions"), "SessionsSettings", ["sessions"]);
+const AppearanceSettings = lazyNamed(() => import("@/pages/settings/appearance"), "AppearanceSettings", ["settings"]);
const ForgotPasswordPage = lazyNamed(
() => import("@/pages/auth/forgot-password"),
"ForgotPasswordPage",
diff --git a/clients/admin/tests/i18n/route-namespaces.spec.ts b/clients/admin/tests/i18n/route-namespaces.spec.ts
new file mode 100644
index 0000000000..f552d9ac63
--- /dev/null
+++ b/clients/admin/tests/i18n/route-namespaces.spec.ts
@@ -0,0 +1,125 @@
+import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { expect, test } from "@playwright/test";
+
+// Route-only translation catalogs are not in the main bundle: src/i18n.ts loads them when a route
+// that lists them (lazyNamed's third argument in src/routes.tsx) is opened. A page, or a
+// component it imports, that uses a namespace its route doesn't list renders guessed labels.
+// This walks each lazy target's static import graph and fails on any namespace it uses that
+// isn't loaded by the time it renders.
+const src = fileURLToPath(new URL("../../src", import.meta.url));
+
+const read = (file: string) => readFileSync(file, "utf8");
+
+const files = new Map();
+(function walk(dir: string) {
+ for (const name of readdirSync(dir)) {
+ const full = path.join(dir, name);
+ if (statSync(full).isDirectory()) walk(full);
+ else if (/\.tsx?$/.test(name) && !/\.(test|spec)\./.test(name)) files.set(full, read(full));
+ }
+})(src);
+
+function resolve(spec: string, from: string): string | undefined {
+ let base: string;
+ if (spec.startsWith("@/")) base = path.join(src, spec.slice(2));
+ else if (spec.startsWith(".")) base = path.join(path.dirname(from), spec);
+ else return undefined;
+ for (const ext of [".tsx", ".ts", "/index.tsx", "/index.ts", ""]) {
+ const candidate = path.normalize(base + ext);
+ if (files.has(candidate)) return candidate;
+ }
+ return undefined;
+}
+
+const STATIC_IMPORT =
+ /^\s*(?:import|export)\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']|^\s*import\s+["']([^"']+)["']/gm;
+const NAMESPACE_USE = [
+ /useTranslation\(\s*["']([A-Za-z]+)["']/g,
+ /useTranslation\(\s*\[([^\]]*)\]/g,
+ /\bns:\s*["']([A-Za-z]+)["']/g,
+ /\bt\(\s*["'`]([A-Za-z]+):/g,
+];
+
+function staticImports(file: string): string[] {
+ const out: string[] = [];
+ for (const m of files.get(file)!.matchAll(STATIC_IMPORT)) {
+ const target = resolve(m[1] ?? m[2], file);
+ if (target) out.push(target);
+ }
+ return out;
+}
+
+function closure(start: string): Set {
+ const seen = new Set();
+ const stack = [start];
+ while (stack.length) {
+ const next = stack.pop()!;
+ if (seen.has(next)) continue;
+ seen.add(next);
+ stack.push(...staticImports(next));
+ }
+ return seen;
+}
+
+function namespacesUsed(modules: Iterable): Map {
+ const used = new Map(); // namespace -> first module using it
+ for (const file of modules) {
+ for (const rx of NAMESPACE_USE) {
+ for (const m of files.get(file)!.matchAll(rx)) {
+ for (const ns of m[1].match(/[A-Za-z]+/g) ?? []) {
+ if (!used.has(ns)) used.set(ns, path.relative(src, file));
+ }
+ }
+ }
+ }
+ return used;
+}
+
+// The eager namespaces: the keys of the eager map in src/i18n.ts.
+const i18nSource = read(path.join(src, "i18n.ts"));
+const eagerBlock = /const EAGER_CATALOGS = \{([^}]*)\}/.exec(i18nSource)?.[1] ?? "";
+const EAGER = new Set([...eagerBlock.matchAll(/^\s*([A-Za-z]+):/gm)].map((m) => m[1]));
+
+const initial = closure(path.join(src, "main.tsx"));
+
+test.describe("route translation namespaces", () => {
+ test("the eager namespace list was found", () => {
+ expect([...EAGER].sort()).toEqual(["auth", "common", "dashboard", "nav", "notifications"]);
+ });
+
+ test("the app shell only uses eager namespaces", () => {
+ const lazyInShell = [...namespacesUsed(initial)].filter(([ns]) => !EAGER.has(ns));
+ expect(lazyInShell).toEqual([]);
+ });
+
+ // `lazyNamed(() => import("@/pages/x"), "XPage", ["ns", …])`
+ const routes = [
+ ...read(path.join(src, "routes.tsx")).matchAll(
+ /lazyNamed\(\s*\(\) => import\("([^"]+)"\),\s*"[A-Za-z]+"(?:,\s*\[([^\]]*)\])?/g,
+ ),
+ ];
+
+ test("routes.tsx declares its lazy routes through lazyNamed", () => {
+ expect(routes.length).toBeGreaterThan(15);
+ });
+
+ for (const [, spec, listed] of routes) {
+ test(`${spec} lists every namespace it uses`, () => {
+ const page = resolve(spec, path.join(src, "routes.tsx"));
+ expect(page, `cannot resolve ${spec}`).toBeTruthy();
+ const declared = new Set(listed?.match(/[A-Za-z]+/g) ?? []);
+ const missing = [...namespacesUsed(closure(page!))]
+ .filter(([ns]) => !EAGER.has(ns) && !declared.has(ns))
+ .map(([ns, file]) => `${ns} (used in ${file})`);
+ expect(missing).toEqual([]);
+ });
+ }
+
+ test("every lazy catalog file exists", () => {
+ for (const m of i18nSource.matchAll(/import\("@\/locales\/en-US\/([A-Za-z]+)\.json"\)/g)) {
+ expect(existsSync(path.join(src, "locales", "en-US", `${m[1]}.json`))).toBe(true);
+ }
+ });
+});
diff --git a/clients/admin/vite.config.ts b/clients/admin/vite.config.ts
index 535a048240..6f1d021843 100644
--- a/clients/admin/vite.config.ts
+++ b/clients/admin/vite.config.ts
@@ -14,6 +14,25 @@ export default defineConfig(({ mode }) => {
"@": path.resolve(import.meta.dirname, "./src"),
},
},
+ build: {
+ // Left to its defaults, Rolldown splits the code the entry loads up front into ~45 tiny
+ // chunks. Each compresses on its own, which costs ~15 KiB gzip on first load over a few
+ // larger files. These groups put everything the entry statically imports (the `$initial`
+ // tag) into one vendor chunk and one app chunk. Route pages stay lazy: they aren't
+ // `$initial`, so automatic chunking still handles them. See #1462.
+ rolldownOptions: {
+ output: {
+ codeSplitting: {
+ groups: [
+ { name: "vendor", test: /node_modules[\\/]/, tags: ["$initial"], priority: 2 },
+ { name: "app", tags: ["$initial"], priority: 1 },
+ ],
+ },
+ },
+ },
+ // The vendor chunk is ~560 KB minified (~175 KiB gzip) on purpose, see above.
+ chunkSizeWarningLimit: 600,
+ },
server: {
port: 5173,
strictPort: true,
diff --git a/clients/dashboard/src/components/command-palette/command-palette.tsx b/clients/dashboard/src/components/command-palette/command-palette.tsx
index cf29166bd4..95785800bf 100644
--- a/clients/dashboard/src/components/command-palette/command-palette.tsx
+++ b/clients/dashboard/src/components/command-palette/command-palette.tsx
@@ -9,19 +9,20 @@ import {
useState,
type ReactNode,
} from "react";
+import { loadNamespaces } from "@/i18n";
/**
* Command palette provider — owns open state + ⌘K / Ctrl+K listener,
* deliberately tiny so the main bundle stays light. The heavy dialog
* (cmdk + the full action graph + every lucide icon) lives in a lazy
* chunk that's fetched the first time the palette opens, so cold start
- * doesn't pay for any of it.
+ * doesn't pay for any of it. Its translation catalog loads alongside it.
*/
const LazyCommandPaletteDialog = lazy(() =>
- import("./command-palette-dialog").then((mod) => ({
- default: mod.CommandPaletteDialog,
- })),
+ Promise.all([import("./command-palette-dialog"), loadNamespaces(["commandPalette"])]).then(
+ ([mod]) => ({ default: mod.CommandPaletteDialog }),
+ ),
);
type CommandPaletteContextValue = {
diff --git a/clients/dashboard/src/i18n.ts b/clients/dashboard/src/i18n.ts
index e63715e008..581a32b0b6 100644
--- a/clients/dashboard/src/i18n.ts
+++ b/clients/dashboard/src/i18n.ts
@@ -4,56 +4,83 @@ import { initReactI18next } from "react-i18next";
import { fallbackChain, missingKeyFallback } from "@/lib/i18n-fallback";
import enCommon from "@/locales/en-US/common.json";
import enAuth from "@/locales/en-US/auth.json";
-import enSettings from "@/locales/en-US/settings.json";
-import enIdentity from "@/locales/en-US/identity.json";
-import enOverview from "@/locales/en-US/overview.json";
-import enSubscription from "@/locales/en-US/subscription.json";
-import enActivity from "@/locales/en-US/activity.json";
-import enCatalog from "@/locales/en-US/catalog.json";
-import enTickets from "@/locales/en-US/tickets.json";
-import enFiles from "@/locales/en-US/files.json";
-import enAudits from "@/locales/en-US/audits.json";
-import enCommandPalette from "@/locales/en-US/commandPalette.json";
import enNotifications from "@/locales/en-US/notifications.json";
-import enSystem from "@/locales/en-US/system.json";
-import enChat from "@/locales/en-US/chat.json";
-import enHealth from "@/locales/en-US/health.json";
// Canonical tags: specific (what the switcher offers, User.Locale persists, the claim carries).
export const SUPPORTED = ["en-US", "pt-BR"] as const;
type Catalog = Record;
-// en-US translation catalogs keyed by namespace. To add a namespace in a later wave: import its
-// en-US JSON file and add one row here — `resources` and the namespace list derive from this
-// single map, so no other wiring changes. Its pt-BR file (optional) is picked up by
-// src/locales/pt-BR.ts; a namespace or key pt-BR lacks renders in English through fallbackChain.
-// Its type is also what src/i18next.d.ts checks every t() key against.
-const catalogs = {
+// en-US catalogs the app shell renders with (layout, sign-in, banners, notification bell). They
+// ship in the main bundle so the first paint never waits on a catalog.
+const eagerCatalogs = {
common: enCommon,
auth: enAuth,
- settings: enSettings,
- identity: enIdentity,
- overview: enOverview,
- subscription: enSubscription,
- activity: enActivity,
- catalog: enCatalog,
- tickets: enTickets,
- files: enFiles,
- audits: enAudits,
- commandPalette: enCommandPalette,
notifications: enNotifications,
- system: enSystem,
- chat: enChat,
- health: enHealth,
} satisfies Record;
-export type Catalogs = typeof catalogs;
+// en-US catalogs only route pages use. Each is its own small chunk, loaded by loadNamespaces()
+// in parallel with the page's chunk (see `lazyNamed` in src/routes.tsx), so the page never
+// renders before its text is there.
+//
+// To add a namespace: add its en-US JSON here (or to eagerCatalogs when the shell uses it) and
+// list it on the routes that use it. `resources`, the namespace list and the t() key types all
+// derive from these two maps. Its pt-BR file (optional) is picked up by src/locales/pt-BR.ts; a
+// namespace or key pt-BR lacks renders in English through fallbackChain.
+const lazyCatalogs = {
+ settings: () => import("@/locales/en-US/settings.json"),
+ identity: () => import("@/locales/en-US/identity.json"),
+ overview: () => import("@/locales/en-US/overview.json"),
+ subscription: () => import("@/locales/en-US/subscription.json"),
+ activity: () => import("@/locales/en-US/activity.json"),
+ catalog: () => import("@/locales/en-US/catalog.json"),
+ tickets: () => import("@/locales/en-US/tickets.json"),
+ files: () => import("@/locales/en-US/files.json"),
+ audits: () => import("@/locales/en-US/audits.json"),
+ commandPalette: () => import("@/locales/en-US/commandPalette.json"),
+ system: () => import("@/locales/en-US/system.json"),
+ chat: () => import("@/locales/en-US/chat.json"),
+ health: () => import("@/locales/en-US/health.json"),
+} satisfies Record Promise<{ default: Catalog }>>;
-export const NAMESPACES = Object.keys(catalogs);
+export type LazyNamespace = keyof typeof lazyCatalogs;
-// en-US ships in the main bundle: it is the terminal fallback and must resolve synchronously.
-// Every other language is one lazy chunk, fetched before the UI switches to it.
+// What src/i18next.d.ts checks every t() key against: the eager and lazy catalogs together.
+export type Catalogs = typeof eagerCatalogs & {
+ [K in LazyNamespace]: Awaited>["default"];
+};
+
+export const NAMESPACES = [...Object.keys(eagerCatalogs), ...Object.keys(lazyCatalogs)];
+
+const pendingNamespaces = new Map>();
+
+// Loads route namespaces into en-US, the terminal fallback. Other languages need no work here:
+// loadLanguage adds every namespace of a language in one go. A failed load is forgotten so the
+// next navigation retries it.
+export function loadNamespaces(namespaces: readonly LazyNamespace[]): Promise {
+ return Promise.all(
+ namespaces.map((ns) => {
+ let pending = pendingNamespaces.get(ns);
+ if (!pending) {
+ pending = lazyCatalogs[ns]().then(
+ ({ default: catalog }) => {
+ i18n.addResourceBundle("en-US", ns, catalog, true, true);
+ },
+ (error: unknown) => {
+ pendingNamespaces.delete(ns);
+ throw error;
+ },
+ );
+ pendingNamespaces.set(ns, pending);
+ }
+ return pending;
+ }),
+ ).then(() => undefined);
+}
+
+// en-US is the terminal fallback: its shell catalogs ship in the main bundle and its route
+// catalogs load with their routes. Every other language is one lazy chunk, fetched before the UI
+// switches to it.
const LAZY_LANGUAGES: Partial<
Record<(typeof SUPPORTED)[number], () => Promise<{ default: Record }>>
> = {
@@ -117,7 +144,8 @@ export async function initI18n(deploymentDefault: string) {
.use(LanguageDetector)
.use(initReactI18next)
.init({
- resources: { "en-US": catalogs },
+ // Route catalogs are added later through loadNamespaces().
+ resources: { "en-US": eagerCatalogs },
fallbackLng: fallbackChain(deploymentDefault, SUPPORTED),
supportedLngs: [...SUPPORTED],
ns: NAMESPACES,
diff --git a/clients/dashboard/src/routes.tsx b/clients/dashboard/src/routes.tsx
index 1adcb99c56..1d4253d6b0 100644
--- a/clients/dashboard/src/routes.tsx
+++ b/clients/dashboard/src/routes.tsx
@@ -6,6 +6,7 @@ import { ProtectedRoute } from "@/auth/protected-route";
import { RouteError } from "@/components/route-error";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/cn";
+import { loadNamespaces, type LazyNamespace } from "@/i18n";
// ─────────────────────────────────────────────────────────────────────────
// Lazy route boundaries
@@ -17,14 +18,20 @@ import { cn } from "@/lib/cn";
// `lazyNamed` wraps a dynamic import that exports a *named* component
// (we don't use default exports anywhere in this codebase). Returns a
// shape compatible with React.lazy.
+//
+// `namespaces` lists the route-only translation catalogs the page (and the
+// components it imports) use. They load in parallel with the page chunk, so
+// the page never renders before its text is there. The shell's namespaces
+// (common, auth, notifications) are always loaded and are not listed.
// ─────────────────────────────────────────────────────────────────────────
function lazyNamed, K extends keyof T>(
importer: () => Promise,
name: K,
+ namespaces: readonly LazyNamespace[] = [],
) {
return lazy(async () => {
- const mod = await importer();
+ const [mod] = await Promise.all([importer(), loadNamespaces(namespaces)]);
return { default: mod[name] as ComponentType };
});
}
@@ -42,25 +49,16 @@ const ConfirmEmailPage = lazyNamed(
() => import("@/pages/auth/confirm-email"),
"ConfirmEmailPage",
);
-const OverviewPage = lazyNamed(() => import("@/pages/overview"), "OverviewPage");
-const ActivityPage = lazyNamed(() => import("@/pages/activity"), "ActivityPage");
-const InvoicesPage = lazyNamed(() => import("@/pages/invoices"), "InvoicesPage");
-const InvoiceDetailPage = lazyNamed(
- () => import("@/pages/invoice-detail"),
- "InvoiceDetailPage",
-);
-const SubscriptionPage = lazyNamed(
- () => import("@/pages/subscription"),
- "SubscriptionPage",
-);
-const WalletPage = lazyNamed(() => import("@/pages/wallet"), "WalletPage");
-const BrandsPage = lazyNamed(() => import("@/pages/catalog/brands"), "BrandsPage");
-const CategoriesPage = lazyNamed(() => import("@/pages/catalog/categories"), "CategoriesPage");
-const ProductsPage = lazyNamed(() => import("@/pages/catalog/products"), "ProductsPage");
-const ProductDetailPage = lazyNamed(
- () => import("@/pages/catalog/product-detail"),
- "ProductDetailPage",
-);
+const OverviewPage = lazyNamed(() => import("@/pages/overview"), "OverviewPage", ["overview"]);
+const ActivityPage = lazyNamed(() => import("@/pages/activity"), "ActivityPage", ["activity", "audits"]);
+const InvoicesPage = lazyNamed(() => import("@/pages/invoices"), "InvoicesPage", ["subscription"]);
+const InvoiceDetailPage = lazyNamed(() => import("@/pages/invoice-detail"), "InvoiceDetailPage", ["subscription"]);
+const SubscriptionPage = lazyNamed(() => import("@/pages/subscription"), "SubscriptionPage", ["subscription"]);
+const WalletPage = lazyNamed(() => import("@/pages/wallet"), "WalletPage", ["subscription"]);
+const BrandsPage = lazyNamed(() => import("@/pages/catalog/brands"), "BrandsPage", ["catalog"]);
+const CategoriesPage = lazyNamed(() => import("@/pages/catalog/categories"), "CategoriesPage", ["catalog"]);
+const ProductsPage = lazyNamed(() => import("@/pages/catalog/products"), "ProductsPage", ["catalog"]);
+const ProductDetailPage = lazyNamed(() => import("@/pages/catalog/product-detail"), "ProductDetailPage", ["catalog", "files"]);
const NotFoundPage = lazyNamed(() => import("@/pages/not-found"), "NotFoundPage");
const TenantDeactivatedPage = lazyNamed(
() => import("@/pages/tenant-deactivated"),
@@ -70,51 +68,27 @@ const ImpersonationEndedPage = lazyNamed(
() => import("@/pages/impersonation-ended"),
"ImpersonationEndedPage",
);
-const SettingsLayout = lazyNamed(
- () => import("@/pages/settings/settings-layout"),
- "SettingsLayout",
-);
-const ProfileSettings = lazyNamed(() => import("@/pages/settings/profile"), "ProfileSettings");
-const SecuritySettings = lazyNamed(() => import("@/pages/settings/security"), "SecuritySettings");
-const BrandingSettings = lazyNamed(
- () => import("@/pages/settings/branding"),
- "BrandingSettings",
-);
-const AppearanceSettings = lazyNamed(
- () => import("@/pages/settings/appearance"),
- "AppearanceSettings",
-);
-const NotificationsSettings = lazyNamed(
- () => import("@/pages/settings/notifications"),
- "NotificationsSettings",
-);
-const ApiKeysSettings = lazyNamed(() => import("@/pages/settings/api-keys"), "ApiKeysSettings");
-const HealthPage = lazyNamed(() => import("@/pages/health"), "HealthPage");
-const AuditsPage = lazyNamed(() => import("@/pages/audits"), "AuditsPage");
-const TicketsPage = lazyNamed(() => import("@/pages/tickets/tickets"), "TicketsPage");
-const TicketDetailPage = lazyNamed(
- () => import("@/pages/tickets/ticket-detail"),
- "TicketDetailPage",
-);
-const TrashPage = lazyNamed(() => import("@/pages/system/trash"), "TrashPage");
-const SessionsPage = lazyNamed(() => import("@/pages/system/sessions"), "SessionsPage");
-const UsersPage = lazyNamed(() => import("@/pages/identity/users"), "UsersPage");
-const UserDetailPage = lazyNamed(
- () => import("@/pages/identity/user-detail"),
- "UserDetailPage",
-);
-const RolesPage = lazyNamed(() => import("@/pages/identity/roles"), "RolesPage");
-const RoleDetailPage = lazyNamed(
- () => import("@/pages/identity/role-detail"),
- "RoleDetailPage",
-);
-const GroupsPage = lazyNamed(() => import("@/pages/identity/groups"), "GroupsPage");
-const GroupDetailPage = lazyNamed(
- () => import("@/pages/identity/group-detail"),
- "GroupDetailPage",
-);
-const MyFilesPage = lazyNamed(() => import("@/pages/files/my-files"), "MyFilesPage");
-const ChatPage = lazyNamed(() => import("@/pages/chat/chat-page"), "ChatPage");
+const SettingsLayout = lazyNamed(() => import("@/pages/settings/settings-layout"), "SettingsLayout", ["settings"]);
+const ProfileSettings = lazyNamed(() => import("@/pages/settings/profile"), "ProfileSettings", ["settings", "files"]);
+const SecuritySettings = lazyNamed(() => import("@/pages/settings/security"), "SecuritySettings", ["settings"]);
+const BrandingSettings = lazyNamed(() => import("@/pages/settings/branding"), "BrandingSettings", ["settings"]);
+const AppearanceSettings = lazyNamed(() => import("@/pages/settings/appearance"), "AppearanceSettings", ["settings"]);
+const NotificationsSettings = lazyNamed(() => import("@/pages/settings/notifications"), "NotificationsSettings", ["settings"]);
+const ApiKeysSettings = lazyNamed(() => import("@/pages/settings/api-keys"), "ApiKeysSettings", ["settings"]);
+const HealthPage = lazyNamed(() => import("@/pages/health"), "HealthPage", ["health"]);
+const AuditsPage = lazyNamed(() => import("@/pages/audits"), "AuditsPage", ["audits"]);
+const TicketsPage = lazyNamed(() => import("@/pages/tickets/tickets"), "TicketsPage", ["tickets"]);
+const TicketDetailPage = lazyNamed(() => import("@/pages/tickets/ticket-detail"), "TicketDetailPage", ["tickets"]);
+const TrashPage = lazyNamed(() => import("@/pages/system/trash"), "TrashPage", ["system"]);
+const SessionsPage = lazyNamed(() => import("@/pages/system/sessions"), "SessionsPage", ["system"]);
+const UsersPage = lazyNamed(() => import("@/pages/identity/users"), "UsersPage", ["identity"]);
+const UserDetailPage = lazyNamed(() => import("@/pages/identity/user-detail"), "UserDetailPage", ["identity"]);
+const RolesPage = lazyNamed(() => import("@/pages/identity/roles"), "RolesPage", ["identity"]);
+const RoleDetailPage = lazyNamed(() => import("@/pages/identity/role-detail"), "RoleDetailPage", ["identity"]);
+const GroupsPage = lazyNamed(() => import("@/pages/identity/groups"), "GroupsPage", ["identity"]);
+const GroupDetailPage = lazyNamed(() => import("@/pages/identity/group-detail"), "GroupDetailPage", ["identity"]);
+const MyFilesPage = lazyNamed(() => import("@/pages/files/my-files"), "MyFilesPage", ["files"]);
+const ChatPage = lazyNamed(() => import("@/pages/chat/chat-page"), "ChatPage", ["chat"]);
/**
* RouteFallback — what shows while a lazy chunk is downloading. Mirrors
diff --git a/clients/dashboard/tests/i18n/route-namespaces.spec.ts b/clients/dashboard/tests/i18n/route-namespaces.spec.ts
new file mode 100644
index 0000000000..5c21c01261
--- /dev/null
+++ b/clients/dashboard/tests/i18n/route-namespaces.spec.ts
@@ -0,0 +1,134 @@
+import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { expect, test } from "@playwright/test";
+
+// Route-only translation catalogs are not in the main bundle: src/i18n.ts loads them when a route
+// that lists them (lazyNamed's third argument in src/routes.tsx) is opened. A page, or a
+// component it imports, that uses a namespace its route doesn't list renders guessed labels.
+// This walks each lazy target's static import graph and fails on any namespace it uses that
+// isn't loaded by the time it renders.
+const src = fileURLToPath(new URL("../../src", import.meta.url));
+
+const read = (file: string) => readFileSync(file, "utf8");
+
+const files = new Map();
+(function walk(dir: string) {
+ for (const name of readdirSync(dir)) {
+ const full = path.join(dir, name);
+ if (statSync(full).isDirectory()) walk(full);
+ else if (/\.tsx?$/.test(name) && !/\.(test|spec)\./.test(name)) files.set(full, read(full));
+ }
+})(src);
+
+function resolve(spec: string, from: string): string | undefined {
+ let base: string;
+ if (spec.startsWith("@/")) base = path.join(src, spec.slice(2));
+ else if (spec.startsWith(".")) base = path.join(path.dirname(from), spec);
+ else return undefined;
+ for (const ext of [".tsx", ".ts", "/index.tsx", "/index.ts", ""]) {
+ const candidate = path.normalize(base + ext);
+ if (files.has(candidate)) return candidate;
+ }
+ return undefined;
+}
+
+const STATIC_IMPORT =
+ /^\s*(?:import|export)\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']|^\s*import\s+["']([^"']+)["']/gm;
+const NAMESPACE_USE = [
+ /useTranslation\(\s*["']([A-Za-z]+)["']/g,
+ /useTranslation\(\s*\[([^\]]*)\]/g,
+ /\bns:\s*["']([A-Za-z]+)["']/g,
+ /\bt\(\s*["'`]([A-Za-z]+):/g,
+];
+
+function staticImports(file: string): string[] {
+ const out: string[] = [];
+ for (const m of files.get(file)!.matchAll(STATIC_IMPORT)) {
+ const target = resolve(m[1] ?? m[2], file);
+ if (target) out.push(target);
+ }
+ return out;
+}
+
+function closure(start: string): Set {
+ const seen = new Set();
+ const stack = [start];
+ while (stack.length) {
+ const next = stack.pop()!;
+ if (seen.has(next)) continue;
+ seen.add(next);
+ stack.push(...staticImports(next));
+ }
+ return seen;
+}
+
+function namespacesUsed(modules: Iterable): Map {
+ const used = new Map(); // namespace -> first module using it
+ for (const file of modules) {
+ for (const rx of NAMESPACE_USE) {
+ for (const m of files.get(file)!.matchAll(rx)) {
+ for (const ns of m[1].match(/[A-Za-z]+/g) ?? []) {
+ if (!used.has(ns)) used.set(ns, path.relative(src, file));
+ }
+ }
+ }
+ }
+ return used;
+}
+
+// The eager namespaces: the keys of the eager map in src/i18n.ts.
+const i18nSource = read(path.join(src, "i18n.ts"));
+const eagerBlock = /const eagerCatalogs = \{([^}]*)\}/.exec(i18nSource)?.[1] ?? "";
+const EAGER = new Set([...eagerBlock.matchAll(/^\s*([A-Za-z]+):/gm)].map((m) => m[1]));
+
+const initial = closure(path.join(src, "main.tsx"));
+
+test.describe("route translation namespaces", () => {
+ test("the eager namespace list was found", () => {
+ expect([...EAGER].sort()).toEqual(["auth", "common", "notifications"]);
+ });
+
+ test("the app shell only uses eager namespaces", () => {
+ const lazyInShell = [...namespacesUsed(initial)].filter(([ns]) => !EAGER.has(ns));
+ expect(lazyInShell).toEqual([]);
+ });
+
+ // `lazyNamed(() => import("@/pages/x"), "XPage", ["ns", …])`
+ const routes = [
+ ...read(path.join(src, "routes.tsx")).matchAll(
+ /lazyNamed\(\s*\(\) => import\("([^"]+)"\),\s*"[A-Za-z]+"(?:,\s*\[([^\]]*)\])?/g,
+ ),
+ ];
+
+ test("routes.tsx declares its lazy routes through lazyNamed", () => {
+ expect(routes.length).toBeGreaterThan(20);
+ });
+
+ for (const [, spec, listed] of routes) {
+ test(`${spec} lists every namespace it uses`, () => {
+ const page = resolve(spec, path.join(src, "routes.tsx"));
+ expect(page, `cannot resolve ${spec}`).toBeTruthy();
+ const declared = new Set(listed?.match(/[A-Za-z]+/g) ?? []);
+ const missing = [...namespacesUsed(closure(page!))]
+ .filter(([ns]) => !EAGER.has(ns) && !declared.has(ns))
+ .map(([ns, file]) => `${ns} (used in ${file})`);
+ expect(missing).toEqual([]);
+ });
+ }
+
+ // Not a route: command-palette.tsx loads the dialog and the commandPalette catalog together.
+ test("the command palette dialog only uses commandPalette and eager namespaces", () => {
+ const dialog = path.join(src, "components", "command-palette", "command-palette-dialog.tsx");
+ const missing = [...namespacesUsed(closure(dialog))]
+ .filter(([ns]) => !EAGER.has(ns) && ns !== "commandPalette")
+ .map(([ns, file]) => `${ns} (used in ${file})`);
+ expect(missing).toEqual([]);
+ });
+
+ test("every lazy catalog file exists", () => {
+ for (const m of i18nSource.matchAll(/import\("@\/locales\/en-US\/([A-Za-z]+)\.json"\)/g)) {
+ expect(existsSync(path.join(src, "locales", "en-US", `${m[1]}.json`))).toBe(true);
+ }
+ });
+});
diff --git a/clients/dashboard/vite.config.ts b/clients/dashboard/vite.config.ts
index 0974b31c55..0d3e006d2d 100644
--- a/clients/dashboard/vite.config.ts
+++ b/clients/dashboard/vite.config.ts
@@ -45,6 +45,25 @@ export default defineConfig(({ mode }) => {
"@": path.resolve(import.meta.dirname, "./src"),
},
},
+ build: {
+ // Left to its defaults, Rolldown splits the code the entry loads up front into ~70 tiny
+ // chunks. Each compresses on its own, which costs ~20 KiB gzip on first load over a few
+ // larger files. These groups put everything the entry statically imports (the `$initial`
+ // tag) into one vendor chunk and one app chunk. Route pages stay lazy: they aren't
+ // `$initial`, so automatic chunking still handles them. See #1462.
+ rolldownOptions: {
+ output: {
+ codeSplitting: {
+ groups: [
+ { name: "vendor", test: /node_modules[\\/]/, tags: ["$initial"], priority: 2 },
+ { name: "app", tags: ["$initial"], priority: 1 },
+ ],
+ },
+ },
+ },
+ // The vendor chunk is ~560 KB minified (~175 KiB gzip) on purpose, see above.
+ chunkSizeWarningLimit: 600,
+ },
server: {
port: 5174,
strictPort: true,