Files
cloudflare__vinext/tests/pages-i18n-prod.test.ts
T
Steve Faulkner 0c1193fd76 fix: include i18n context in Pages ISR cache keys (#1698)
Pages Router ISR cache entries were keyed only by pathname. With i18n
domain routing, the same pathname can render different content depending
on the matched domain and default locale. A cache entry created for one
domain could be reused for another domain.

Add an i18n cache variant to the Pages ISR key when i18n is configured:
- domain:<domain> for matched domain-locales
- locale:<locale> for locale-prefix/default-locale routing

Adds a Pages i18n production fixture page with getStaticProps +
revalidate and verifies example.fr and example.com maintain separate
ISR entries for the same pathname.
2026-05-30 15:33:17 -05:00

241 lines
7.9 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
import { build } from "vite-plus";
import path from "node:path";
import fsp from "node:fs/promises";
import http from "node:http";
import vinext from "../packages/vinext/src/index.js";
import {
PAGES_I18N_DOMAINS_BASEPATH_FIXTURE_DIR,
PAGES_I18N_DOMAINS_FIXTURE_DIR,
createIsolatedFixture,
requestNodeServerWithHost,
} from "./helpers.js";
async function startProdFixture(
fixtureDir: string,
prefix: string,
): Promise<{
port: number;
server: http.Server;
tmpDir: string;
}> {
const tmpDir = await createIsolatedFixture(fixtureDir, prefix);
const outDir = path.join(tmpDir, "dist");
// Pages Router only — no RSC pipeline, so separate build() calls work.
// For App Router, use createBuilder().buildApp() instead.
await build({
root: tmpDir,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(outDir, "server"),
ssr: "virtual:vinext-server-entry",
rollupOptions: { output: { entryFileNames: "entry.js" } },
},
});
await build({
root: tmpDir,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(outDir, "client"),
manifest: true,
ssrManifest: true,
rollupOptions: { input: "virtual:vinext-client-entry" },
},
});
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const { server } = await startProdServer({
port: 0,
host: "127.0.0.1",
outDir,
});
const addr = server.address();
if (!addr || typeof addr === "string") {
throw new Error(`Failed to start production server for fixture ${fixtureDir}`);
}
return { port: addr.port, server, tmpDir };
}
describe("Pages i18n domain routing (production)", () => {
let tmpDir: string;
let prodServer: http.Server;
let prodPort: number;
beforeAll(async () => {
({
server: prodServer,
tmpDir,
port: prodPort,
} = await startProdFixture(PAGES_I18N_DOMAINS_FIXTURE_DIR, "vinext-pages-i18n-prod-"));
}, 30000);
afterAll(async () => {
if (prodServer) {
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
}
if (tmpDir) {
await fsp.rm(tmpDir, { recursive: true, force: true });
}
}, 15000);
it("redirects the root path to the preferred locale domain", async () => {
const res = await requestNodeServerWithHost(prodPort, "/", "example.com", {
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
});
expect(res.status).toBe(307);
expect(res.headers.location).toBe("http://example.fr/");
});
it("uses Accept-Language rather than NEXT_LOCALE to pick the preferred domain", async () => {
const res = await requestNodeServerWithHost(prodPort, "/", "example.com", {
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
Cookie: "NEXT_LOCALE=en",
});
expect(res.status).toBe(307);
expect(res.headers.location).toBe("http://example.fr/");
});
it("preserves the search string on root locale redirects", async () => {
const res = await requestNodeServerWithHost(
prodPort,
"/?utm=campaign&next=%2Fcheckout",
"example.com",
{
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
},
);
expect(res.status).toBe(307);
expect(res.headers.location).toBe("http://example.fr/?utm=campaign&next=%2Fcheckout");
});
it("does not redirect unprefixed non-root paths for locale detection", async () => {
const res = await requestNodeServerWithHost(prodPort, "/about", "example.com", {
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
});
expect(res.status).toBe(200);
expect(res.headers.location).toBeUndefined();
});
it("renders locale-switcher links with the target locale domain during SSR", async () => {
const res = await requestNodeServerWithHost(prodPort, "/about", "example.com");
expect(res.status).toBe(200);
expect(res.body).toContain('href="http://example.fr/about" id="switch-locale"');
});
it("uses the matched domain default locale for request context", async () => {
const res = await requestNodeServerWithHost(prodPort, "/about", "example.fr");
expect(res.status).toBe(200);
expect(res.body).toContain('<p id="locale">fr</p>');
expect(res.body).toContain('<p id="defaultLocale">fr</p>');
expect(res.body).toContain('href="/about" id="switch-locale"');
expect(res.body).toContain('"defaultLocale":"fr"');
expect(res.body).toContain(
'"domainLocales":[{"domain":"example.com","defaultLocale":"en"},{"domain":"example.fr","defaultLocale":"fr","http":true}]',
);
});
it("keys Pages ISR entries by i18n domain context", async () => {
const fr = await requestNodeServerWithHost(prodPort, "/isr-about", "example.fr");
expect(fr.status).toBe(200);
expect(fr.headers["x-vinext-cache"]).toBe("MISS");
expect(fr.body).toContain('<p id="locale">fr</p>');
expect(fr.body).toContain('<p id="defaultLocale">fr</p>');
const en = await requestNodeServerWithHost(prodPort, "/isr-about", "example.com");
expect(en.status).toBe(200);
expect(en.headers["x-vinext-cache"]).toBe("MISS");
expect(en.body).toContain('<p id="locale">en</p>');
expect(en.body).toContain('<p id="defaultLocale">en</p>');
expect(en.body).not.toContain('<p id="locale">fr</p>');
const enHit = await requestNodeServerWithHost(prodPort, "/isr-about", "example.com");
expect(enHit.status).toBe(200);
expect(enHit.headers["x-vinext-cache"]).toBe("HIT");
expect(enHit.body).toContain('<p id="locale">en</p>');
});
// Issue #1336 item 3: locale prefix must be stripped before API route matching.
//
// Ported from Next.js: test/e2e/middleware-redirects/test/index.test.ts
// (the "should redirect to api route with locale" case, which exercises
// /fr/api/ok hitting pages/api/ok.js)
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-redirects/test/index.test.ts
it("matches /api/ok without a locale prefix", async () => {
const res = await requestNodeServerWithHost(prodPort, "/api/ok", "example.com");
expect(res.status).toBe(200);
expect(res.body).toBe("ok");
});
it("matches /fr/api/ok by stripping the locale prefix (issue #1336)", async () => {
const res = await requestNodeServerWithHost(prodPort, "/fr/api/ok", "example.com");
expect(res.status).toBe(200);
expect(res.body).toBe("ok");
});
it("preserves query parameters when stripping the locale prefix from an API path", async () => {
const res = await requestNodeServerWithHost(
prodPort,
"/fr/api/ok?foo=bar&baz=qux",
"example.com",
);
expect(res.status).toBe(200);
expect(res.body).toBe("ok");
});
});
describe("Pages i18n domain routing with basePath (production)", () => {
let tmpDir: string;
let prodServer: http.Server;
let prodPort: number;
beforeAll(async () => {
({
server: prodServer,
tmpDir,
port: prodPort,
} = await startProdFixture(
PAGES_I18N_DOMAINS_BASEPATH_FIXTURE_DIR,
"vinext-pages-i18n-basepath-prod-",
));
}, 30000);
afterAll(async () => {
if (prodServer) {
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
}
if (tmpDir) {
await fsp.rm(tmpDir, { recursive: true, force: true });
}
}, 15000);
it("preserves basePath and trailingSlash in root locale redirects", async () => {
const res = await requestNodeServerWithHost(prodPort, "/app/?utm=campaign", "example.com", {
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
});
expect(res.status).toBe(307);
expect(res.headers.location).toBe("http://example.fr/app/?utm=campaign");
});
it("renders locale-switcher links with basePath on cross-domain hrefs", async () => {
const res = await requestNodeServerWithHost(prodPort, "/app/about/", "example.com");
expect(res.status).toBe(200);
expect(res.body).toContain('href="http://example.fr/app/about" id="switch-locale"');
});
});