- >
+
);
}
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;
}