web: adopt Cache Components, Partial Prefetching and ensureStatic
web / ci (push) Successful in 2m2s
web / ci (push) Successful in 2m2s
This commit is contained in:
70 files changed
+1285
-316
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { graphql } from "@/gql";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import { Metadata, ResolvingMetadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
@@ -8,6 +8,13 @@ import {
|
||||
} from "@/components/general/GenericPageView";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
// Navigations wait for the page instead of showing a loading shell first:
|
||||
// the response has to be able to turn into a real 404 for an unknown path
|
||||
export const instant = false;
|
||||
|
||||
function getWagtailUrlPath(url: string[]): string {
|
||||
// for the page /foo/bar we need to look for `/home/foo/bar/`
|
||||
return `/home/${url.join("/")}/`;
|
||||
@@ -22,10 +29,7 @@ export async function generateStaticParams() {
|
||||
}
|
||||
}
|
||||
`);
|
||||
const { data, error } = await getClient().query(allGenericSlugsQuery, {});
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
const data = await cmsQuery(allGenericSlugsQuery, {});
|
||||
if (!data?.pages) {
|
||||
throw new Error("Failed to generate static params for generic subpages");
|
||||
}
|
||||
|
||||
@@ -5,10 +5,17 @@ import {
|
||||
loadNewsPageProps,
|
||||
} from "@/components/news/NewsPageView";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
import { latestSlugParams } from "@/lib/staticParams";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
// Navigations wait for the page instead of showing a loading shell first:
|
||||
// the response has to be able to turn into a real 404 for an unknown path
|
||||
export const instant = false;
|
||||
|
||||
export async function generateStaticParams() {
|
||||
// Prerender nothing at build time; render and cache on first request
|
||||
return [];
|
||||
return latestSlugParams("news.NewsPage");
|
||||
}
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Metadata, ResolvingMetadata } from "next";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import {
|
||||
NewsIndexView,
|
||||
loadNewsIndexProps,
|
||||
@@ -8,12 +8,14 @@ import { NewsIndexFragment } from "@/gql/graphql";
|
||||
import { newsIndexMetadataQuery } from "@/lib/news";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
export async function generateMetadata(
|
||||
_: unknown,
|
||||
parent: ResolvingMetadata
|
||||
): Promise<Metadata | null> {
|
||||
const { data, error } = await getClient().query(newsIndexMetadataQuery, {});
|
||||
if (error) throw new Error(error.message);
|
||||
const data = await cmsQuery(newsIndexMetadataQuery, {});
|
||||
if (!data?.index) return null;
|
||||
return getSeoMetadata(data.index as NewsIndexFragment, parent);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next/cache", () => ({ revalidateTag: vi.fn() }));
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { CMS_CACHE_TAG } from "@/lib/revalidation";
|
||||
import { setInternalRevalidateSecret } from "@/lib/internalRevalidate";
|
||||
import { CMS_CACHE_TAG, TODAY_CACHE_TAG } from "@/lib/revalidation";
|
||||
import { POST } from "./route.ts";
|
||||
|
||||
const request = (headers: Record<string, string> = {}) =>
|
||||
new Request("http://localhost/api/revalidate", {
|
||||
const request = (headers: Record<string, string> = {}, query = "") =>
|
||||
new NextRequest(`http://localhost/api/revalidate${query}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
}) as unknown as NextRequest;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
setInternalRevalidateSecret("");
|
||||
});
|
||||
|
||||
describe("POST /api/revalidate", () => {
|
||||
@@ -47,4 +49,119 @@ describe("POST /api/revalidate", () => {
|
||||
expect(await res.json()).toEqual({ revalidated: true, tag: CMS_CACHE_TAG });
|
||||
expect(revalidateTag).toHaveBeenCalledWith(CMS_CACHE_TAG, { expire: 0 });
|
||||
});
|
||||
|
||||
it("purges only the today tag when asked to", async () => {
|
||||
vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", "hemmelig");
|
||||
|
||||
const res = await POST(
|
||||
request({ "x-revalidate-secret": "hemmelig" }, "?tag=today")
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(revalidateTag).toHaveBeenCalledTimes(1);
|
||||
expect(revalidateTag).toHaveBeenCalledWith(TODAY_CACHE_TAG, { expire: 0 });
|
||||
});
|
||||
|
||||
it("rejects tags it doesn't know", async () => {
|
||||
vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", "hemmelig");
|
||||
|
||||
const res = await POST(
|
||||
request({ "x-revalidate-secret": "hemmelig" }, "?tag=noe-annet")
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(revalidateTag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("follow-up purge", () => {
|
||||
const fetchMock = vi.fn();
|
||||
const followUpCalls = () => fetchMock.mock.calls.map(([url]) => String(url));
|
||||
|
||||
function setUp() {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", "hemmelig");
|
||||
vi.stubEnv("PORT", "4321");
|
||||
vi.stubEnv("HOSTNAME", "0.0.0.0");
|
||||
setInternalRevalidateSecret("intern");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it("purges again a few seconds after a webhook, as the server itself", async () => {
|
||||
setUp();
|
||||
|
||||
await POST(request({ "x-revalidate-secret": "hemmelig" }));
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_500);
|
||||
expect(followUpCalls()).toEqual([
|
||||
"http://127.0.0.1:4321/api/revalidate?tag=cms",
|
||||
]);
|
||||
expect(fetchMock.mock.calls[0][1].headers["x-revalidate-secret"]).toBe(
|
||||
"intern"
|
||||
);
|
||||
});
|
||||
|
||||
it("sends one follow-up after a burst of webhooks", async () => {
|
||||
setUp();
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await POST(request({ "x-revalidate-secret": "hemmelig" }));
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
expect(followUpCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("is not scheduled by the server's own purges", async () => {
|
||||
setUp();
|
||||
|
||||
await POST(request({ "x-revalidate-secret": "intern" }, "?tag=today"));
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is skipped where the server can't call itself", async () => {
|
||||
setUp();
|
||||
setInternalRevalidateSecret("");
|
||||
|
||||
await POST(request({ "x-revalidate-secret": "hemmelig" }));
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the server's own purges", () => {
|
||||
it("are accepted without a configured webhook secret", async () => {
|
||||
vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", undefined);
|
||||
setInternalRevalidateSecret("intern");
|
||||
|
||||
const res = await POST(
|
||||
request({ "x-revalidate-secret": "intern" }, "?tag=today")
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(revalidateTag).toHaveBeenCalledWith(TODAY_CACHE_TAG, { expire: 0 });
|
||||
});
|
||||
|
||||
it("don't open the endpoint to an empty secret", async () => {
|
||||
vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", "hemmelig");
|
||||
|
||||
expect((await POST(request())).status).toBe(401);
|
||||
expect((await POST(request({ "x-revalidate-secret": "" }))).status).toBe(
|
||||
401
|
||||
);
|
||||
expect(revalidateTag).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,11 @@ import { timingSafeEqual } from "node:crypto";
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { CMS_CACHE_TAG } from "@/lib/revalidation";
|
||||
import {
|
||||
internalRevalidateSecret,
|
||||
purgeOwnCache,
|
||||
} from "@/lib/internalRevalidate";
|
||||
import { CACHE_TAGS, CMS_CACHE_TAG, type CacheTag } from "@/lib/revalidation";
|
||||
|
||||
function secretMatches(provided: string, expected: string): boolean {
|
||||
const a = Buffer.from(provided);
|
||||
@@ -10,19 +14,55 @@ function secretMatches(provided: string, expected: string): boolean {
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
const FOLLOW_UP_DELAY_MS = 5_000;
|
||||
const followUps = new Map<CacheTag, ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Next stamps a cached page when its render finishes. A page that was being
|
||||
// rendered while a purge came in (two saves in quick succession) therefore
|
||||
// looks newer than the purge and would keep the older content. A second
|
||||
// purge shortly after the last one in a burst catches those pages
|
||||
function scheduleFollowUpPurge(tag: CacheTag) {
|
||||
clearTimeout(followUps.get(tag));
|
||||
const timer = setTimeout(async () => {
|
||||
followUps.delete(tag);
|
||||
const error = await purgeOwnCache(tag);
|
||||
if (error) console.error(`Follow-up purge of "${tag}" failed: ${error}`);
|
||||
}, FOLLOW_UP_DELAY_MS);
|
||||
timer.unref();
|
||||
followUps.set(tag, timer);
|
||||
}
|
||||
|
||||
// POST /api/revalidate purges the cms tag (the CMS webhook);
|
||||
// POST /api/revalidate?tag=today purges only what depends on the day
|
||||
export async function POST(req: NextRequest) {
|
||||
const secret = process.env.REVALIDATE_WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
return NextResponse.json(
|
||||
{ error: "revalidation is not configured" },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
const provided = req.headers.get("x-revalidate-secret") ?? "";
|
||||
if (!secretMatches(provided, secret)) {
|
||||
return NextResponse.json({ error: "invalid secret" }, { status: 401 });
|
||||
// the server's own startup and midnight purges (instrumentation.ts)
|
||||
const internal = internalRevalidateSecret();
|
||||
const isInternal = internal !== undefined && secretMatches(provided, internal);
|
||||
|
||||
if (!isInternal) {
|
||||
const secret = process.env.REVALIDATE_WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
return NextResponse.json(
|
||||
{ error: "revalidation is not configured" },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
if (!secretMatches(provided, secret)) {
|
||||
return NextResponse.json({ error: "invalid secret" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const tag = req.nextUrl.searchParams.get("tag") ?? CMS_CACHE_TAG;
|
||||
if (!CACHE_TAGS.includes(tag as CacheTag)) {
|
||||
return NextResponse.json({ error: "unknown tag" }, { status: 400 });
|
||||
}
|
||||
// expire: 0 purges immediately; the next request renders fresh
|
||||
revalidateTag(CMS_CACHE_TAG, { expire: 0 });
|
||||
return NextResponse.json({ revalidated: true, tag: CMS_CACHE_TAG });
|
||||
revalidateTag(tag, { expire: 0 });
|
||||
// not after the server's own purges, which would loop; and only where the
|
||||
// server can call itself (not in dev)
|
||||
if (!isInternal && internal !== undefined) {
|
||||
scheduleFollowUpPurge(tag as CacheTag);
|
||||
}
|
||||
return NextResponse.json({ revalidated: true, tag });
|
||||
}
|
||||
@@ -2,9 +2,19 @@ import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const queryMock = vi.fn();
|
||||
// mirrors cmsQueryForToday: returns the data, throws on a GraphQL error
|
||||
vi.mock("@/app/client", () => ({
|
||||
getClient: () => ({ query: queryMock }),
|
||||
cmsQueryForToday: async (...args: unknown[]) => {
|
||||
const { data, error } = await queryMock(...args);
|
||||
if (error) throw new Error(error.message);
|
||||
return data;
|
||||
},
|
||||
}));
|
||||
// the real one is a server-only cached function; read the (fake) clock instead
|
||||
vi.mock("@/lib/today", async () => {
|
||||
const { osloDay, startOfOsloDay } = await import("@/lib/date");
|
||||
return { getToday: async () => startOfOsloDay(osloDay()) };
|
||||
});
|
||||
|
||||
import { GET } from "./route.ts";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
|
||||
import { cmsQueryForToday } from "@/app/client";
|
||||
import { getToday } from "@/lib/today";
|
||||
import {
|
||||
eventsOverviewQuery,
|
||||
getSingularEvents,
|
||||
@@ -46,14 +46,7 @@ export async function GET(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await getClient().query(
|
||||
eventsOverviewQuery,
|
||||
{},
|
||||
cachedUntilOsloMidnight()
|
||||
);
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
const data = await cmsQueryForToday(eventsOverviewQuery, {});
|
||||
if (
|
||||
!data?.index ||
|
||||
!data?.events?.futureEvents ||
|
||||
@@ -64,6 +57,7 @@ export async function GET(req: NextRequest) {
|
||||
throw new Error("Failed to fetch events");
|
||||
}
|
||||
|
||||
const today = await getToday();
|
||||
const futureEvents = (data?.events?.futureEvents ?? []) as EventOverviewItemFragment[];
|
||||
const eventCategories = (data?.eventCategories ?? []) as EventCategory[];
|
||||
const eventOrganizers = (data?.eventOrganizers ?? []) as EventOrganizer[];
|
||||
@@ -71,7 +65,7 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const keepKeys = ["id", "slug", "title", "subtitle", "occurrence"];
|
||||
const compactEvents = futureEvents.map((event) => {
|
||||
const futureOccurrences = getFutureOccurrences(event);
|
||||
const futureOccurrences = getFutureOccurrences(event, today);
|
||||
const nextOccurrence = futureOccurrences.length
|
||||
? futureOccurrences[0]
|
||||
: null;
|
||||
|
||||
@@ -5,10 +5,17 @@ import {
|
||||
loadEventPageProps,
|
||||
} from "@/components/events/EventPageView";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
import { latestSlugParams } from "@/lib/staticParams";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
// Navigations wait for the page instead of showing a loading shell first:
|
||||
// the response has to be able to turn into a real 404 for an unknown path
|
||||
export const instant = false;
|
||||
|
||||
export async function generateStaticParams() {
|
||||
// Prerender nothing at build time; render and cache on first request
|
||||
return [];
|
||||
return latestSlugParams("events.EventPage");
|
||||
}
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Metadata, ResolvingMetadata } from "next";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import {
|
||||
EventIndexView,
|
||||
loadEventIndexProps,
|
||||
@@ -8,22 +8,20 @@ import { EventIndexFragment } from "@/gql/graphql";
|
||||
import { eventIndexMetadataQuery } from "@/lib/event";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
|
||||
// The event list renders per request (it depends on the URL's filters), so
|
||||
// only the shell around it has to stay prerendered
|
||||
export const ensureStatic = "shell";
|
||||
|
||||
export async function generateMetadata(
|
||||
_: unknown,
|
||||
parent: ResolvingMetadata
|
||||
): Promise<Metadata | null> {
|
||||
const { data, error } = await getClient().query(eventIndexMetadataQuery, {});
|
||||
if (error) throw new Error(error.message);
|
||||
const data = await cmsQuery(eventIndexMetadataQuery, {});
|
||||
if (!data?.index) return null;
|
||||
return getSeoMetadata(data.index as EventIndexFragment, parent);
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
await searchParams;
|
||||
export default async function Page() {
|
||||
const props = await loadEventIndexProps();
|
||||
return <EventIndexView {...props} />;
|
||||
}
|
||||
+52
-5
@@ -1,9 +1,16 @@
|
||||
import "server-only";
|
||||
|
||||
import { cacheExchange, createClient, fetchExchange } from "@urql/core";
|
||||
import {
|
||||
type AnyVariables,
|
||||
type TypedDocumentNode,
|
||||
cacheExchange,
|
||||
createClient,
|
||||
fetchExchange,
|
||||
stringifyDocument,
|
||||
} from "@urql/core";
|
||||
import { registerUrql } from "@urql/next/rsc";
|
||||
|
||||
import { CMS_CACHE_TAG } from "@/lib/revalidation";
|
||||
import { cmsCache, cmsCacheForToday } from "@/lib/cmsCache";
|
||||
|
||||
const wagtailBaseUrl = process.env.WAGTAIL_BASE_URL;
|
||||
if (!wagtailBaseUrl) {
|
||||
@@ -15,10 +22,50 @@ const makeClient = () => {
|
||||
return createClient({
|
||||
url: graphqlEndpoint,
|
||||
exchanges: [cacheExchange, fetchExchange],
|
||||
// Cache all queries in the Data Cache until revalidateTag(CMS_CACHE_TAG);
|
||||
// override per operation with the contexts in @/lib/revalidation
|
||||
fetchOptions: { cache: "force-cache", next: { tags: [CMS_CACHE_TAG] } },
|
||||
});
|
||||
};
|
||||
|
||||
// Uncached. Only for per-user (search) and token-scoped (preview) queries,
|
||||
// together with the `uncached` context; everything else goes through cmsQuery
|
||||
export const { getClient } = registerUrql(makeClient);
|
||||
|
||||
// Throws on GraphQL and network errors, which keeps them out of the cache
|
||||
async function runQuery(query: string, variables: AnyVariables) {
|
||||
const { data, error } = await getClient().query(query, variables);
|
||||
if (error) throw new Error(error.message);
|
||||
return data;
|
||||
}
|
||||
|
||||
// The cached functions take the query as text: their arguments are the cache
|
||||
// key, and urql adds bookkeeping properties to document objects it has seen
|
||||
async function cachedQuery(query: string, variables: AnyVariables) {
|
||||
"use cache";
|
||||
cmsCache();
|
||||
return runQuery(query, variables);
|
||||
}
|
||||
|
||||
async function cachedQueryForToday(query: string, variables: AnyVariables) {
|
||||
"use cache";
|
||||
cmsCacheForToday();
|
||||
return runQuery(query, variables);
|
||||
}
|
||||
|
||||
// Every cacheable CMS read goes through one of the two functions below, so
|
||||
// all of them carry the tag the CMS webhook purges
|
||||
|
||||
// Cached until the content is edited in the CMS
|
||||
export function cmsQuery<Data, Variables extends AnyVariables>(
|
||||
document: TypedDocumentNode<Data, Variables>,
|
||||
variables: Variables
|
||||
): Promise<Data | undefined> {
|
||||
return cachedQuery(stringifyDocument(document), variables);
|
||||
}
|
||||
|
||||
// For queries where the CMS decides by today's date what to return
|
||||
// (futureEvents): also purged at Oslo midnight
|
||||
export function cmsQueryForToday<Data, Variables extends AnyVariables>(
|
||||
document: TypedDocumentNode<Data, Variables>,
|
||||
variables: Variables
|
||||
): Promise<Data | undefined> {
|
||||
return cachedQueryForToday(stringifyDocument(document), variables);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Metadata, ResolvingMetadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import {
|
||||
AssociationPageView,
|
||||
loadAssociationPageProps,
|
||||
@@ -8,6 +8,13 @@ import {
|
||||
import { graphql } from "@/gql";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
// Navigations wait for the page instead of showing a loading shell first:
|
||||
// the response has to be able to turn into a real 404 for an unknown path
|
||||
export const instant = false;
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
|
||||
export async function generateMetadata(
|
||||
@@ -29,11 +36,7 @@ export async function generateStaticParams() {
|
||||
}
|
||||
}
|
||||
`);
|
||||
const { data, error } = await getClient().query(allAssociationSlugsQuery, {});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
const data = await cmsQuery(allAssociationSlugsQuery, {});
|
||||
if (!data?.pages) {
|
||||
throw new Error(
|
||||
"Failed to generate static params for subpages of /foreninger"
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
} from "@/components/associations/AssociationIndexView";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
export async function generateMetadata(
|
||||
_: unknown,
|
||||
parent: ResolvingMetadata
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
} from "@/components/contact/ContactIndexView";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
export async function generateMetadata(
|
||||
_: unknown,
|
||||
parent: ResolvingMetadata
|
||||
|
||||
+10
-5
@@ -1,5 +1,7 @@
|
||||
import "@/css/main.scss";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { SeasonalHeader } from "@/components/layout/SeasonalHeader";
|
||||
import { TodayProvider } from "@/components/general/Today";
|
||||
import { getOsloDay } from "@/lib/today";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { Metadata } from "next";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
@@ -26,11 +28,12 @@ export const metadata: Metadata = {
|
||||
...baseUrlMetadata,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const day = await getOsloDay();
|
||||
return (
|
||||
<html lang="no">
|
||||
<head>
|
||||
@@ -61,9 +64,11 @@ export default function RootLayout({
|
||||
</head>
|
||||
<body>
|
||||
<NuqsAdapter>
|
||||
<Header />
|
||||
{children}
|
||||
<Footer />
|
||||
<TodayProvider day={day}>
|
||||
<SeasonalHeader />
|
||||
{children}
|
||||
<Footer />
|
||||
</TodayProvider>
|
||||
</NuqsAdapter>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Metadata, ResolvingMetadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getClient } from "@/app/client";
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import {
|
||||
VenuePageView,
|
||||
loadVenuePageProps,
|
||||
@@ -8,6 +8,13 @@ import {
|
||||
import { graphql } from "@/gql";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
// Navigations wait for the page instead of showing a loading shell first:
|
||||
// the response has to be able to turn into a real 404 for an unknown path
|
||||
export const instant = false;
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
|
||||
const EXCLUDED_SLUGS = ["hele-huset"];
|
||||
@@ -31,11 +38,7 @@ export async function generateStaticParams() {
|
||||
}
|
||||
}
|
||||
`);
|
||||
const { data, error } = await getClient().query(allVenueSlugsQuery, {});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
const data = await cmsQuery(allVenueSlugsQuery, {});
|
||||
if (!data?.pages) {
|
||||
throw new Error(
|
||||
"Failed to generate static params for subpages of /lokaler"
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
} from "@/components/venues/VenueIndexView";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
export async function generateMetadata(
|
||||
_: unknown,
|
||||
parent: ResolvingMetadata
|
||||
|
||||
@@ -3,6 +3,9 @@ import {
|
||||
loadHomePageProps,
|
||||
} from "@/components/home/HomePageView";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
export default async function Home() {
|
||||
const props = await loadHomePageProps();
|
||||
return <HomePageView {...props} />;
|
||||
|
||||
@@ -74,8 +74,10 @@ import {
|
||||
VenueRentalIndexFragment,
|
||||
} from "@/gql/graphql";
|
||||
import { cookies } from "next/headers";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
// Rendered per request from the editor's preview cookie; there is nothing
|
||||
// to prerender, so let the route block instead of requiring a loading shell
|
||||
export const instant = false;
|
||||
|
||||
const previewPageQuery = graphql(`
|
||||
query previewPage($token: String!) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connection } from "next/server";
|
||||
import { getClient } from "@/app/client";
|
||||
import { uncached } from "@/lib/revalidation";
|
||||
import {
|
||||
@@ -9,6 +10,10 @@ import { graphql } from "@/gql";
|
||||
|
||||
// TODO: seo metadata?
|
||||
|
||||
// Results depend on the query in the URL, so the route renders per request
|
||||
// and is allowed to block
|
||||
export const instant = false;
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -17,6 +22,8 @@ export default async function Page({
|
||||
}>;
|
||||
}) {
|
||||
const { q: query } = (await searchParams) ?? {};
|
||||
// search always runs at request time
|
||||
await connection();
|
||||
let results: SearchResult[] = [];
|
||||
let totalCount = 0;
|
||||
const RESULT_LIMIT = 500;
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
} from "@/components/sponsor/SponsorsPageView";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
export async function generateMetadata(
|
||||
_: unknown,
|
||||
parent: ResolvingMetadata
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
} from "@/components/studio/StudioPageView";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
export async function generateMetadata(
|
||||
_: unknown,
|
||||
parent: ResolvingMetadata
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
} from "@/components/venues/VenueRentalIndexView";
|
||||
import { getSeoMetadata } from "@/lib/seo";
|
||||
|
||||
// Fail the build if this route stops being fully prerendered
|
||||
export const ensureStatic = "navigation";
|
||||
|
||||
export async function generateMetadata(
|
||||
_: unknown,
|
||||
parent: ResolvingMetadata
|
||||
|
||||
Reference in new issue
Block a user