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.
This commit is contained in:
1 parent
d28dde459a
commit
bb3799f705
7 files changed
+135
-17
No files matched your search
@@ -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 (
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
|
||||
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
|
||||
<div
|
||||
@@ -56,7 +62,7 @@ export default async function AdminLayout({
|
||||
</div>
|
||||
</AdminMobileWrapper>
|
||||
<AdminToaster />
|
||||
</>
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
|
||||
<div
|
||||
data-admin
|
||||
@@ -140,6 +146,6 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl p-4 sm:p-6">{children}</main>
|
||||
</div>
|
||||
</>
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user