web: adopt Cache Components, Partial Prefetching and ensureStatic
web / ci (push) Successful in 2m2s

This commit is contained in:
ponas committed 2026-10-13 23:59:14 +02:00
1 parent 3a0c2fd82b
commit db286434ba
70 files changed
+1285 -316

No files matched your search

+21 -3
View File
@@ -10,6 +10,7 @@ import type {
AllAssociationsQuery,
AllGenericSlugsQuery,
AllVenueSlugsQuery,
LatestPageSlugQuery,
AssociationBySlugQuery,
AssociationFragment,
AssociationIndexFragment,
@@ -53,6 +54,8 @@ import type {
export type MockState = {
newsHeadlineVersion: "v1" | "v2";
// how long the CMS takes to answer newsBySlug, to hold a page render open
newsDelayMs?: number;
};
export const SLUGS = {
@@ -60,6 +63,8 @@ export const SLUGS = {
association: "testforening",
venue: "storsalen",
news: "test-artikkel",
// exists in the CMS but is not prerendered: rendered on first request
newsOnDemand: "eldre-artikkel",
event: "testkonsert",
} as const;
@@ -98,7 +103,7 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
__typename: "EventCategory",
name: "Konsert",
slug: "konsert",
pig: "PIG_PINK",
pig: "music",
showInFilters: true,
} satisfies EventCategoryFragment;
@@ -158,7 +163,8 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
seoTitle: "Testkonsert",
searchDescription: null,
lead: "Bli med på testkonsert.",
pig: "PIG_PINK",
// picks a pig from the event's categories, which must be prerenderable
pig: "automatic",
facebookUrl: null,
ticketUrl: null,
free: true,
@@ -344,6 +350,15 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
({
pages: [{ id: "30", slug: SLUGS.venue }],
}) satisfies AllVenueSlugsQuery,
latestPageSlug: (vars) =>
({
pages:
vars.contentType === "news.NewsPage"
? [{ id: "20", slug: SLUGS.news }]
: vars.contentType === "events.EventPage"
? [{ id: "10", slug: SLUGS.event }]
: [],
}) satisfies LatestPageSlugQuery,
// --- build time: static pages ---
home: (_vars, state) =>
@@ -412,7 +427,10 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
// --- runtime ---
newsBySlug: (vars, state) =>
({
news: vars.slug === SLUGS.news ? mask(newsPage(state)) : null,
news:
vars.slug === SLUGS.news || vars.slug === SLUGS.newsOnDemand
? mask(newsPage(state))
: null,
}) satisfies NewsBySlugQuery,
eventBySlug: (vars) =>
({
+2
View File
@@ -79,6 +79,8 @@ export default async function setup(project: TestProject) {
);
});
mock.markBuildDone();
if (mock.graphqlRequests === 0) {
throw new Error(
"next build made no GraphQL requests to the mock CMS — is WAGTAIL_BASE_URL being overridden?"
+37 -1
View File
@@ -19,6 +19,12 @@ export type MockCms = {
url: string;
state: MockState;
graphqlRequests: number;
// GraphQL requests per operation name, served at /__mock/requests so tests
// can tell which queries a re-render actually sent
requestsByOperation: Record<string, number>;
// call once `next build` is done; /__mock/requests then also reports the
// requests made since
markBuildDone(): void;
close(): Promise<void>;
};
@@ -34,11 +40,16 @@ function readBody(req: http.IncomingMessage): Promise<string> {
export async function startMockCms(): Promise<MockCms> {
const state: MockState = { newsHeadlineVersion: "v1" };
let fixtures: Fixtures | null = null; // built once we know our own URL
let requestsAtBuild: Record<string, number> = {};
const mock: MockCms = {
url: "",
state,
graphqlRequests: 0,
requestsByOperation: {},
markBuildDone: () => {
requestsAtBuild = { ...mock.requestsByOperation };
},
close: () =>
new Promise((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve()))
@@ -56,6 +67,20 @@ export async function startMockCms(): Promise<MockCms> {
return;
}
if (req.method === "GET" && req.url === "/__mock/requests") {
const sinceBuild = Object.fromEntries(
Object.entries(mock.requestsByOperation).map(([name, count]) => [
name,
count - (requestsAtBuild[name] ?? 0),
])
);
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({ total: mock.requestsByOperation, sinceBuild })
);
return;
}
if (req.method === "POST" && req.url === "/__mock/state") {
Object.assign(state, JSON.parse(await readBody(req)));
res.writeHead(200, { "content-type": "application/json" });
@@ -84,6 +109,10 @@ export async function startMockCms(): Promise<MockCms> {
const name =
body.operationName ??
/(?:query|mutation)\s+(\w+)/.exec(body.query ?? "")?.[1];
if (name) {
mock.requestsByOperation[name] =
(mock.requestsByOperation[name] ?? 0) + 1;
}
const resolve = name ? fixtures?.[name] : undefined;
res.writeHead(200, { "content-type": "application/json" });
if (!resolve) {
@@ -96,7 +125,14 @@ export async function startMockCms(): Promise<MockCms> {
);
return;
}
res.end(JSON.stringify({ data: resolve(body.variables ?? {}, state) }));
// decide the answer first: a slow CMS returns what it had when asked
const answer = JSON.stringify({
data: resolve(body.variables ?? {}, state),
});
if (name === "newsBySlug" && state.newsDelayMs) {
await new Promise((r) => setTimeout(r, state.newsDelayMs));
}
res.end(answer);
return;
}
+142 -10
View File
@@ -1,12 +1,12 @@
// HTTP smoke tests against the built standalone app (see global-setup.ts).
// These cover the surfaces most likely to break on a Next.js upgrade:
// prerendered and request-rendered routes, 404s, the events API, the image
// optimizer, the fetch-cache + tag revalidation round trip, and draft-mode
// optimizer, the cache + tag revalidation round trip, and draft-mode
// preview.
import { beforeAll, describe, expect, inject, test } from "vitest";
import { afterAll, beforeAll, describe, expect, inject, test } from "vitest";
import { SLUGS } from "./fixtures";
import { type MockState, SLUGS } from "./fixtures";
const app = (path: string) => `${inject("appUrl")}${path}`;
const mock = (path: string) => `${inject("mockUrl")}${path}`;
@@ -16,14 +16,46 @@ async function getPage(path: string): Promise<{ status: number; html: string }>
return { status: res.status, html: await res.text() };
}
async function setNewsVersion(version: "v1" | "v2"): Promise<void> {
async function setMockState(state: Partial<MockState>): Promise<void> {
const res = await fetch(mock("/__mock/state"), {
method: "POST",
body: JSON.stringify({ newsHeadlineVersion: version }),
body: JSON.stringify(state),
});
expect(res.status).toBe(200);
}
const setNewsVersion = (version: "v1" | "v2") =>
setMockState({ newsHeadlineVersion: version });
type CmsRequests = Record<string, number>;
// GraphQL requests the mock CMS has received, per operation name
async function cmsRequests(): Promise<{
total: CmsRequests;
sinceBuild: CmsRequests;
}> {
const res = await fetch(mock("/__mock/requests"));
return res.json() as Promise<{ total: CmsRequests; sinceBuild: CmsRequests }>;
}
// Nothing expires by time: the server purges its own cache when it starts
// and at Oslo midnight (instrumentation.ts).
// Must stay the first test: any later purge would also make this pass
describe("startup purge", () => {
test("pages from the build are rendered again", async () => {
// the purge lands just after the server starts listening
const deadline = Date.now() + 10_000;
let sinceBuild: CmsRequests = {};
while (Date.now() < deadline) {
await getPage("/");
({ sinceBuild } = await cmsRequests());
if ((sinceBuild.home ?? 0) > 0) break;
await new Promise((r) => setTimeout(r, 250));
}
expect(sinceBuild.home ?? 0).toBeGreaterThan(0);
});
});
describe("pages", () => {
test.each([
["/", "Testkonsert"],
@@ -32,7 +64,7 @@ describe("pages", () => {
[`/${SLUGS.genericUrl}`, "Praktisk info"],
[`/foreninger/${SLUGS.association}`, "Testforeningen"],
[`/lokaler/${SLUGS.venue}`, "Storsalen"],
// not prerendered (empty generateStaticParams) — rendered on first request
// only the latest page of each is prerendered; these are the latest
[`/aktuelt/${SLUGS.news}`, "Testartikkel"],
[`/arrangementer/${SLUGS.event}`, "Testkonsert"],
])("%s renders and contains %s", async (path, needle) => {
@@ -51,6 +83,23 @@ describe("pages", () => {
);
});
describe("event list", () => {
// The list is rendered per request inside the prerendered page, so that
// the URL's filters are applied on the server (no layout shift)
const activeButton = (html: string) =>
/data-active="true"[^>]*><span>Vis (liste|kalender)<\/span>/.exec(html)?.[1];
test("is server-rendered in list mode by default", async () => {
const { html } = await getPage("/arrangementer");
expect(activeButton(html)).toBe("liste");
});
test("is server-rendered with the mode from the URL", async () => {
const { html } = await getPage("/arrangementer?mode=calendar");
expect(activeButton(html)).toBe("kalender");
});
});
describe("events api", () => {
test("rejects a missing view parameter", async () => {
const res = await fetch(app("/api/v1/events"));
@@ -84,15 +133,18 @@ describe("image optimizer", () => {
});
});
describe("fetch cache and tag revalidation", () => {
const path = `/aktuelt/${SLUGS.news}`;
// Runs for a page prerendered at build time and for one rendered on its
// first request, which is how most news and event pages are served
describe.each([
["prerendered", `/aktuelt/${SLUGS.news}`],
["rendered on demand", `/aktuelt/${SLUGS.newsOnDemand}`],
])("cache and tag revalidation (%s page)", (_kind, path) => {
beforeAll(async () => {
await setNewsVersion("v1");
});
test("serves cached content until the webhook fires, then updates", async () => {
// prime the cache (the route is request-rendered, then cached)
// prerendered at build time or rendered now; either way the CMS has v1
const first = await getPage(path);
expect(first.status).toBe(200);
expect(first.html).toContain("Testartikkel (v1)");
@@ -136,6 +188,86 @@ describe("fetch cache and tag revalidation", () => {
});
});
// Next stamps a cached page when its render finishes, so a page that was
// being rendered while a purge came in looks newer than the purge. The
// server sends itself a follow-up purge a few seconds after each webhook
describe("purge during a render", () => {
const path = `/aktuelt/${SLUGS.newsOnDemand}`;
const webhook = () =>
fetch(app("/api/revalidate"), {
method: "POST",
headers: { "x-revalidate-secret": "smoke-secret" },
});
afterAll(async () => {
await setMockState({ newsDelayMs: 0 });
});
test("two edits in quick succession end up showing the second", async () => {
// first edit: published and purged, and the CMS is slow to answer
await setMockState({ newsHeadlineVersion: "v1", newsDelayMs: 1_500 });
expect((await webhook()).status).toBe(200);
const visitor = getPage(path);
await new Promise((r) => setTimeout(r, 300));
// second edit lands while the visitor's page is still rendering
await setMockState({ newsHeadlineVersion: "v2", newsDelayMs: 0 });
expect((await webhook()).status).toBe(200);
// that visitor gets what the CMS had when it was asked
expect((await visitor).html).toContain("Testartikkel (v1)");
// everyone after must get the second edit once the follow-up has run
const deadline = Date.now() + 12_000;
let html = "";
while (Date.now() < deadline) {
({ html } = await getPage(path));
if (html.includes("Testartikkel (v2)")) break;
await new Promise((r) => setTimeout(r, 500));
}
expect(html).toContain("Testartikkel (v2)");
}, 30_000);
});
describe("midnight purge", () => {
async function cacheStatus(path: string): Promise<string | null> {
const res = await fetch(app(path));
await res.text();
return res.headers.get("x-nextjs-cache");
}
test("the midnight purge re-renders pages but only refetches date-dependent data", async () => {
const purgeToday = () =>
fetch(app("/api/revalidate?tag=today"), {
method: "POST",
headers: { "x-revalidate-secret": "smoke-secret" },
});
// settle: everything rendered and cached by this server process
expect((await purgeToday()).status).toBe(200);
await getPage("/");
await getPage("/kontakt");
expect(await cacheStatus("/")).toBe("HIT");
expect(await cacheStatus("/kontakt")).toBe("HIT");
const before = (await cmsRequests()).total;
expect((await purgeToday()).status).toBe(200);
// every page shows today's date somewhere, so every page renders again
expect(await cacheStatus("/")).not.toBe("HIT");
expect(await cacheStatus("/kontakt")).not.toBe("HIT");
expect(await cacheStatus("/")).toBe("HIT");
expect(await cacheStatus("/kontakt")).toBe("HIT");
const after = (await cmsRequests()).total;
// the home page asks the CMS for future events again...
expect(after.home).toBe(before.home + 1);
// ...while content that only changes on edit comes from the cache
expect(after.contacts).toBe(before.contacts);
expect(after.openingHoursSets).toBe(before.openingHoursSets);
});
});
describe("preview", () => {
test("draft mode round trip renders the preview page", async () => {
const enable = await fetch(