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,