Files
EpicNext-Cms/src/lib/i18n-client-messages.test.ts
T
openhands bb3799f705 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.
2026-10-18 14:50:26 +02:00

175 lines
6.0 KiB
TypeScript

// @ts-nocheck
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
/**
* The public route group installs a reduced catalogue (see
* `publicClientMessages`). Anything a public page asks for must therefore live
* outside the staff-only namespaces — a reference to one of them would silently
* fall back to the raw key, so this is enforced here rather than discovered in
* production.
*/
const SITE_DIR = join(process.cwd(), "src/app/(site)");
const STAFF_NAMESPACES = ['useTranslations("admin', 'getTranslations("admin'];
function sourceFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
out.push(...sourceFiles(full));
} else if (/\.(ts|tsx)$/.test(entry)) {
out.push(full);
}
}
return out;
}
describe("public client message scope", () => {
it("the site route group has pages to check", () => {
expect(sourceFiles(SITE_DIR).length).toBeGreaterThan(10);
});
it("no public page reads a staff-only namespace", () => {
const offenders: string[] = [];
for (const file of sourceFiles(SITE_DIR)) {
const src = readFileSync(file, "utf8");
for (const ns of STAFF_NAMESPACES) {
// `pages.admin.*` is the second form; `admin.*` the first.
for (const needle of [
`${ns})`,
`${ns}.`,
ns.replace("(admin", "(pages.admin"),
]) {
if (src.includes(needle)) {
offenders.push(`${file.replace(process.cwd(), "")}: ${needle}`);
}
}
}
}
expect(offenders).toEqual([]);
});
});
describe("publicClientMessages", () => {
async function load() {
const mod = await import("@/lib/i18n-client-messages");
return mod.publicClientMessages;
}
it("strips the staff-only namespaces", async () => {
const publicClientMessages = await load();
const result = publicClientMessages({
admin: { title: "x" },
nav: { home: "Home" },
pages: {
admin: { dashboard: "y" },
home: { title: "z" },
},
});
expect(result.admin).toBeUndefined();
expect(result.nav).toEqual({ home: "Home" });
expect(result.pages.admin).toBeUndefined();
expect(result.pages.home).toEqual({ title: "z" });
});
it("keeps every non-admin top-level namespace", async () => {
const publicClientMessages = await load();
const en = JSON.parse(
readFileSync(join(process.cwd(), "src/messages/en.json"), "utf8"),
);
const result = publicClientMessages(en);
for (const key of Object.keys(en)) {
if (key === "admin") continue;
expect(result[key]).toBeDefined();
}
// `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 () => {
const publicClientMessages = await load();
const en = JSON.parse(
readFileSync(join(process.cwd(), "src/messages/en.json"), "utf8"),
);
const full = JSON.stringify(en).length;
const scoped = JSON.stringify(publicClientMessages(en)).length;
// The staff-tool namespaces dominate the file; without them the payload
// should drop by well over half.
expect(scoped).toBeLessThan(full * 0.5);
});
it("does not mutate the input", async () => {
const publicClientMessages = await load();
const en = JSON.parse(
readFileSync(join(process.cwd(), "src/messages/en.json"), "utf8"),
);
const before = JSON.stringify(en).length;
publicClientMessages(en);
expect(JSON.stringify(en).length).toBe(before);
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([]);
});
});