From bb3799f705d3c6b46725726e17b370b69717a573 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 18 Oct 2026 14:50:26 +0200 Subject: [PATCH] perf(i18n): stop serializing the full catalogue on public pages The root layout fed the entire ~254 KB message catalogue to NextIntlClientProvider, which serializes it into the RSC payload of every page. A public page only reads ~59 KB of it; the ~195 KB difference is staff tool copy that only /admin, /admin-next and /mod ever render. The root provider now installs the reduced catalogue and each staff route group re-installs the full one, since a nested NextIntlClientProvider replaces the parent message set rather than merging into it. pages.admin.actions stays public because the shared useServerAction hook runs on public pages too. Measured: 253 KB -> 59 KB of messages per public page (raw JSON). Guard tests cover all three halves: the root keeps using the reduced catalogue, every staff layout installs its own full provider, and no public tree reads a staff-only namespace. --- src/app/admin-next/layout.tsx | 12 +++- src/app/admin/layout.tsx | 12 +++- src/app/layout.tsx | 7 ++- src/app/mod/layout.tsx | 12 +++- .../foundation/preview-route-contract.test.ts | 4 ++ src/lib/i18n-client-messages.test.ts | 63 ++++++++++++++++++- src/lib/i18n-client-messages.ts | 44 ++++++++++--- 7 files changed, 136 insertions(+), 18 deletions(-) diff --git a/src/app/admin-next/layout.tsx b/src/app/admin-next/layout.tsx index e01f9681..7613410b 100644 --- a/src/app/admin-next/layout.tsx +++ b/src/app/admin-next/layout.tsx @@ -1,4 +1,6 @@ import { notFound } from "next/navigation"; +import { NextIntlClientProvider } from "next-intl"; +import { getLocale, getMessages } from "next-intl/server"; import type { ReactNode } from "react"; import { env } from "@/env"; import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate"; @@ -27,5 +29,13 @@ export default async function AdminNextLayout({ notFound(); } - return children; + // Staff tree: re-install the full catalogue the root layout scopes down to + // the public subset (nested providers replace the parent set). + const [locale, messages] = await Promise.all([getLocale(), getMessages()]); + + return ( + + {children} + + ); } diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 6c101c82..9b1b8984 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -1,7 +1,8 @@ import { eq } from "drizzle-orm"; import { LogOut } from "lucide-react"; import { redirect } from "next/navigation"; -import { getTranslations } from "next-intl/server"; +import { NextIntlClientProvider } from "next-intl"; +import { getLocale, getMessages, getTranslations } from "next-intl/server"; import type { ReactNode } from "react"; import { AdminHubChrome } from "@/components/admin/admin-hub-chrome"; import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper"; @@ -40,8 +41,13 @@ export default async function AdminLayout({ if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired"); } + // The root layout ships the reduced catalogue so public pages do not carry + // the ~195 KB of staff-tool strings. Nested providers replace the parent + // set rather than merging, so staff route groups re-install the full one. + const [locale, messages] = await Promise.all([getLocale(), getMessages()]); + return ( - <> + {csrfToken ? : null} }>
- + ); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 576571b3..0fc0b936 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,6 +13,7 @@ import { ThemeVars } from "@/components/theme-vars"; import { enforceSiteAccess } from "@/lib/access-guard"; import { getDefaultFaviconDataUri } from "@/lib/default-favicon"; import { resolveHotelName } from "@/lib/hotel-name"; +import { publicClientMessages } from "@/lib/i18n-client-messages"; import { siteSettings } from "@/lib/services/site-settings"; import "./globals.css"; @@ -68,7 +69,11 @@ export default async function RootLayout({ }) { await enforceSiteAccess(); const locale = await getLocale(); - const messages = await getMessages(); + // Only the namespaces non-staff routes can reach: the full catalogue is + // ~254 KB and would otherwise be serialised into every public page's RSC + // payload. `/admin`, `/admin-next` and `/mod` install a nested provider + // seeded with the full set (see i18n-client-messages). + const messages = publicClientMessages(await getMessages()); const defaultDark = await siteSettings.getBool("default_dark", false); const nitroUrl = await siteSettings.get("nitro_client_url", ""); const nonce = (await headers()).get("x-nonce") ?? undefined; diff --git a/src/app/mod/layout.tsx b/src/app/mod/layout.tsx index 3694be4d..0fcbdec2 100644 --- a/src/app/mod/layout.tsx +++ b/src/app/mod/layout.tsx @@ -10,7 +10,8 @@ import { Users, } from "lucide-react"; import { redirect } from "next/navigation"; -import { getTranslations } from "next-intl/server"; +import { NextIntlClientProvider } from "next-intl"; +import { getLocale, getMessages, getTranslations } from "next-intl/server"; import type { ReactNode } from "react"; import { LanguageSwitcher } from "@/components/language-switcher"; import Link from "@/components/link"; @@ -81,8 +82,13 @@ export default async function ModLayout({ children }: { children: ReactNode }) { { href: "/mod/team", label: t("nav.team"), icon: Users, show: showTeam }, ].filter((item) => item.show); + // The root layout ships the reduced catalogue so public pages do not carry + // the ~195 KB of staff-tool strings. Nested providers replace the parent + // set rather than merging, so staff route groups re-install the full one. + const [locale, messages] = await Promise.all([getLocale(), getMessages()]); + return ( - <> + {csrfToken ? : null}
{children}
- +
); } diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts index 007d0afd..07bcb534 100644 --- a/src/features/housekeeping/foundation/preview-route-contract.test.ts +++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts @@ -41,6 +41,8 @@ const routeMocks = vi.hoisted(() => { } return translate; }), + getLocale: vi.fn(async () => "en"), + getMessages: vi.fn(async () => ({})), notFound: vi.fn((): never => { throw new Error("NEXT_NOT_FOUND"); }), @@ -58,6 +60,8 @@ vi.mock("next/navigation", () => ({ })); vi.mock("next-intl/server", () => ({ getTranslations: routeMocks.getTranslations, + getLocale: routeMocks.getLocale, + getMessages: routeMocks.getMessages, })); vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({ getHousekeepingCapabilityContext: routeMocks.getHousekeepingCapabilityContext, diff --git a/src/lib/i18n-client-messages.test.ts b/src/lib/i18n-client-messages.test.ts index 54bbc9d6..947237f1 100644 --- a/src/lib/i18n-client-messages.test.ts +++ b/src/lib/i18n-client-messages.test.ts @@ -85,7 +85,12 @@ describe("publicClientMessages", () => { if (key === "admin") continue; expect(result[key]).toBeDefined(); } - expect(result.pages.admin).toBeUndefined(); + // `pages.admin` survives only in the shape the shared server-action hook + // needs: the generic `actions` copy, nothing staff-specific. + expect(Object.keys(result.pages.admin)).toEqual(["actions"]); + expect(result.pages.admin.actions.saved).toBe(en.pages.admin.actions.saved); + expect(result.pages.admin.nav).toBeUndefined(); + expect(result.pages.admin.catalog).toBeUndefined(); }); it("actually shrinks the catalogue", async () => { @@ -111,3 +116,59 @@ describe("publicClientMessages", () => { expect(en.pages.admin).toBeDefined(); }); }); + +/** + * Scoping only works if the reduced catalogue goes to the client *and* every + * staff route group re-installs the full one. These two halves drift silently: + * narrowing the root without adding a staff provider renders raw keys in the + * admin panel, and the reverse just costs bytes. Both are asserted here. + */ +describe("client catalogue scoping is wired up", () => { + const STAFF_LAYOUTS = [ + "src/app/admin/layout.tsx", + "src/app/admin-next/layout.tsx", + "src/app/mod/layout.tsx", + ]; + + it("the root layout feeds the reduced catalogue to its provider", () => { + const src = readFileSync(join(process.cwd(), "src/app/layout.tsx"), "utf8"); + expect(src).toContain("publicClientMessages(await getMessages())"); + }); + + for (const layout of STAFF_LAYOUTS) { + it(`${layout} re-installs the full catalogue`, () => { + const src = readFileSync(join(process.cwd(), layout), "utf8"); + expect(src).toContain("NextIntlClientProvider"); + expect(src).toContain("getMessages()"); + }); + } +}); + +/** + * A client component that reads `admin.*` / `pages.admin.*` outside a staff + * route group would fall back to raw keys once the root catalogue is scoped, + * because the provider it inherits no longer carries them. Staff trees are the + * ones that install the full catalogue; the allowlist holds shared code that + * deliberately runs on public pages too. + */ +describe("staff namespace consumers sit in a staff tree", () => { + const STAFF_TREE = + /^src[\\/](app[\\/](admin|admin-next|mod)[\\/]|components[\\/]admin[\\/]|features[\\/](catalog|operations|history|housekeeping)[\\/])/; + const ALLOWLIST = new Set(["src/hooks/use-server-action.ts"]); + const CLIENT_STAFF_NS = /useTranslations\((["`])((admin)|(pages\.admin))/; + + it("there is source to scan", () => { + expect(sourceFiles(join(process.cwd(), "src")).length).toBeGreaterThan(500); + }); + + it("no stray consumer of a staff namespace", () => { + const offenders: string[] = []; + for (const file of sourceFiles(join(process.cwd(), "src"))) { + const rel = file.slice(process.cwd().length + 1).replaceAll("\\", "/"); + if (rel.includes(".test.")) continue; + if (STAFF_TREE.test(rel) || ALLOWLIST.has(rel)) continue; + if (CLIENT_STAFF_NS.test(readFileSync(file, "utf8"))) offenders.push(rel); + } + expect(offenders).toEqual([]); + }); +}); diff --git a/src/lib/i18n-client-messages.ts b/src/lib/i18n-client-messages.ts index 673f7886..83a57814 100644 --- a/src/lib/i18n-client-messages.ts +++ b/src/lib/i18n-client-messages.ts @@ -3,22 +3,35 @@ import type { AbstractIntlMessages } from "next-intl"; /** * Client-side message scoping. * - * `getMessages()` hands back the whole catalogue, and the root layout feeds all - * of it to `NextIntlClientProvider`, which serialises every message into the RSC - * payload of every page. The catalogue is ~235 KB of which `pages.admin` and - * `admin` together are ~177 KB — and no public page ever reads either. + * `getMessages()` hands back the whole catalogue, and the root layout feeds it + * to `NextIntlClientProvider`, which serialises every message into the RSC + * payload of every page. The catalogue is ~254 KB of which `pages.admin` and + * `admin` together are ~195 KB — and no public page ever reads either. * * Nested `NextIntlClientProvider`s REPLACE the parent's messages rather than * merging them (see use-intl's IntlProvider: `messages ?? parent.messages`), so - * a segment cannot ask for "just my part". Instead the public layout installs a - * provider seeded with the public subset, which shrinks the payload for every - * public page while leaving /admin, /mod, /client and /admin-next on the full set. + * a segment cannot ask for "just my part". The root layout therefore installs + * the reduced catalogue, and the staff route groups (`/admin`, `/admin-next`, + * `/mod`) each install a nested provider seeded with the full one, which keeps + * staff tools complete while public pages ship ~59 KB instead of ~254 KB. */ /** Top-level namespaces plus `pages.*` sub-namespaces reserved for staff tools. */ const ADMIN_ONLY_NAMESPACES = new Set(["admin"]); const ADMIN_ONLY_PAGE_NAMESPACES = new Set(["admin"]); +/** + * `pages.admin` sub-namespaces that are NOT staff-only because a shared + * client component reads them on public pages too. + * + * `useServerAction` (src/hooks/use-server-action.ts) resolves its default + * success/error toast copy from `pages.admin.actions` and is used by + * `/events/[slugOrId]` and `/polls/[id]`, so that one sub-namespace has to + * survive the filter — the strings in it are generic ("Saved successfully."), + * not staff-tool copy. + */ +const PUBLIC_PAGE_NAMESPACES = new Set(["actions"]); + /** * Drop the staff-tool namespaces from a full catalogue. * @@ -43,8 +56,21 @@ export function publicClientMessages( for (const [pageKey, pageValue] of Object.entries( value as AbstractIntlMessages, )) { - if (ADMIN_ONLY_PAGE_NAMESPACES.has(pageKey)) continue; - pages[pageKey] = pageValue; + if (!ADMIN_ONLY_PAGE_NAMESPACES.has(pageKey)) { + pages[pageKey] = pageValue; + continue; + } + // Staff-only page namespace: keep only the sub-namespaces that a + // shared client component reads on public pages as well. + const shared: AbstractIntlMessages = {}; + for (const [sharedKey, sharedValue] of Object.entries( + pageValue as AbstractIntlMessages, + )) { + if (PUBLIC_PAGE_NAMESPACES.has(sharedKey)) { + shared[sharedKey] = sharedValue; + } + } + if (Object.keys(shared).length > 0) pages[pageKey] = shared; } result.pages = pages; }