From a38b0cd5500731684b916f5e3b832471134cc4f0 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Wed, 4 Nov 2026 08:46:29 +0530 Subject: [PATCH] perf(clients): bring initial-load bundles back under the pre-Vite 8 size Rolldown split the code each app's entry loads up front into 45 (admin) and 68 (dashboard) tiny chunks, and each one compresses on its own. Initial load grew past what Rollup shipped. - vite.config.ts: group everything the entry statically imports (Rolldown's $initial tag) into one vendor and one app chunk. Route pages stay lazy. - i18n: only the shell's en-US catalogs ship in the main bundle. Route catalogs load with their route through lazyNamed(importer, name, namespaces), in parallel with the page chunk, so a page never renders before its text is there. The command palette loads its own catalog. A new tests/i18n/route-namespaces.spec.ts walks each route's import graph and fails when a route doesn't list a namespace its page uses. - admin: import SignalR on first connect, like the dashboard already does. Initial load (entry JS + CSS, gzip -9): admin 265.4 -> 225.1 KiB, dashboard 271.3 -> 229.9 KiB. Pre-upgrade was 242.9 and 245.2 KiB. Closes #1462 --- .agents/rules/frontend/shared.md | 7 +- .agents/skills/add-react-page/SKILL.md | 7 +- clients/admin/src/i18n.ts | 97 ++++++++----- .../admin/src/realtime/realtime-context.tsx | 38 +++-- clients/admin/src/routes.tsx | 50 ++++--- .../admin/tests/i18n/route-namespaces.spec.ts | 125 ++++++++++++++++ clients/admin/vite.config.ts | 19 +++ .../command-palette/command-palette.tsx | 9 +- clients/dashboard/src/i18n.ts | 102 ++++++++----- clients/dashboard/src/routes.tsx | 104 +++++--------- .../tests/i18n/route-namespaces.spec.ts | 134 ++++++++++++++++++ clients/dashboard/vite.config.ts | 19 +++ 12 files changed, 531 insertions(+), 180 deletions(-) create mode 100644 clients/admin/tests/i18n/route-namespaces.spec.ts create mode 100644 clients/dashboard/tests/i18n/route-namespaces.spec.ts 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,