Prefix cache tags with ppr: for PPR route rendering to ensure cache entries are partitioned from static ones

This commit is contained in:
Nicolas Dorseuil
2026-08-12 16:00:10 +02:00
parent 8d881d88f6
commit b6770d959a
8 changed files with 137 additions and 73 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Prefix all cache tags with `ppr:` when rendering under the PPR route, so PPR cache entries are partitioned from the static ones.
+4 -2
View File
@@ -43,7 +43,8 @@ export type PPRRouteParams = PPRRouteLayoutParams & {
*/
export async function getStaticSiteContext(
params: RouteLayoutParams,
getClaims = getVisitorAuthClaimsFromToken
getClaims = getVisitorAuthClaimsFromToken,
options?: { ppr?: true }
) {
const siteURL = getSiteURLFromParams(params);
const siteURLData = getSiteURLDataFromParams(params);
@@ -60,6 +61,7 @@ export async function getStaticSiteContext(
siteURL,
siteURLData,
urlMode: getModeFromParams(params.mode),
...(options?.ppr ? { ppr: options.ppr } : {}),
}),
siteURLData
);
@@ -224,7 +226,7 @@ export function getPPRTableOfContentsRouteParams(params: PPRRouteLayoutParams):
}
export async function getPPRStaticSiteContext(params: RouteLayoutParams) {
return getStaticSiteContext(params, getPPRVisitorAuthClaimsFromToken);
return getStaticSiteContext(params, getPPRVisitorAuthClaimsFromToken, { ppr: true });
}
function getPPRRouteParam(encodedParam: string, name: string): string {
@@ -3,9 +3,9 @@ import {
type RouteParams,
getPPRStaticSiteContext,
getPagePathFromParams,
getStaticSiteContext,
} from '@/app/utils';
import { SpaceHeader, SpaceTableOfContents } from '@/components/SpaceLayout';
import { prefixCacheTags } from '@/lib/cache-tags';
import { getCacheTag } from '@gitbook/cache-tags';
@@ -23,14 +23,19 @@ export async function PPRHeader(props: { params: RouteLayoutParams }) {
console.log('PPRHeader props.params', props.params);
const { context } = await getStaticSiteContext(props.params);
const { context } = await getPPRStaticSiteContext(props.params);
// We only need the site cache tag for the header, as the header is not dependent on the page or space content.
cacheTag(
getCacheTag({
tag: 'site',
site: context.site.id,
})
...prefixCacheTags(
[
getCacheTag({
tag: 'site',
site: context.site.id,
}),
],
true
)
); // Tag the cache entry for the header so it can be invalidated when the site changes
return <SpaceHeader context={context} />;
@@ -80,18 +85,22 @@ export async function cachedGenerateSitePageViewport(routeParams: RouteParams):
const { context } = await getPPRStaticSiteContext(routeParams);
cacheTag(
getCacheTag({
tag: 'site',
site: context.site.id,
})
); // Tag the cache entry for the metadata so it can be invalidated when the site changes
cacheTag(
getCacheTag({
tag: 'space',
space: context.space.id,
})
); // Tag the cache entry for the metadata so it can be invalidated when the space changes
...prefixCacheTags(
[
// Tag the cache entry for the metadata so it can be invalidated when the site changes
getCacheTag({
tag: 'site',
site: context.site.id,
}),
// ...or when the space changes
getCacheTag({
tag: 'space',
space: context.space.id,
}),
],
true
)
);
return generateSitePageViewport(context);
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Cache tags emitted while rendering the PPR route are prefixed, so that PPR cache entries
* live in their own namespace and are invalidated independently from the static ones.
*/
export const PPR_CACHE_TAG_PREFIX = 'ppr:';
/**
* Prefix cache tags when rendering under the PPR route, leave them untouched otherwise.
*/
export function prefixCacheTags(tags: string[], ppr: boolean | undefined): string[] {
return ppr ? tags.map((tag) => `${PPR_CACHE_TAG_PREFIX}${tag}`) : tags;
}
+3
View File
@@ -228,12 +228,15 @@ export function getBaseContext(input: {
siteURL: URL | string;
siteURLData: SiteURLData;
urlMode: 'url' | 'url-host';
/** Set when rendering under the PPR route, to prefix the cache tags emitted by the fetcher. */
ppr?: true;
}) {
const { urlMode, siteURLData } = input;
const siteURL = typeof input.siteURL === 'string' ? new URL(input.siteURL) : input.siteURL;
const dataFetcher = createDataFetcher({
apiToken: siteURLData.apiToken ?? null,
...(input.ppr ? { ppr: input.ppr } : {}),
});
const gitbookURL = GITBOOK_URL ? new URL(GITBOOK_URL) : undefined;
+74 -50
View File
@@ -13,6 +13,7 @@ import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-t
import { cache } from '../cache';
import { DataFetcherError, wrapDataFetcherError } from './errors';
import type { GitBookDataFetcher } from './types';
import { prefixCacheTags } from '@/lib/cache-tags';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@/lib/env';
import { trace } from '@/lib/tracing';
@@ -21,6 +22,12 @@ interface DataFetcherInput {
* API token.
*/
apiToken: string | null;
/**
* Set when rendering under the PPR route. It is part of the cache key, so PPR cache entries
* are never shared with the static ones and can carry their own `ppr:` prefixed cache tags.
*/
ppr?: true;
}
/**
@@ -37,15 +44,24 @@ export const noCacheFetchOptions: Partial<RequestInit> = {
* The data are being cached by Next.js built-in cache.
*/
export function createDataFetcher(
input: DataFetcherInput = { apiToken: null }
rawInput: DataFetcherInput = { apiToken: null }
): GitBookDataFetcher {
// The input is part of the cache key of every cached fetcher below, so we normalize its shape
// here: `ppr` is omitted entirely when unset, keeping non-PPR cache keys unchanged.
const input: DataFetcherInput = rawInput.ppr
? { apiToken: rawInput.apiToken, ppr: true }
: { apiToken: rawInput.apiToken };
return {
ppr: input.ppr,
async api() {
return apiClient(input);
},
withToken({ apiToken }) {
return createDataFetcher({
...input,
apiToken,
});
},
@@ -246,7 +262,7 @@ const getUserById = cache(async (input: DataFetcherInput, params: { userId: stri
const res = await api.users.getUserById(params.userId, {
...noCacheFetchOptions,
});
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('days');
return res.data;
});
@@ -256,12 +272,12 @@ const getUserById = cache(async (input: DataFetcherInput, params: { userId: stri
const getSpace = cache(
async (input: DataFetcherInput, params: { spaceId: string; shareKey: string | undefined }) => {
'use cache';
cacheTag(
cacheTagsFor(input, [
getCacheTag({
tag: 'space',
space: params.spaceId,
})
);
}),
]);
return wrapDataFetcherError(async () => {
return trace(`getSpace(${params.spaceId}, ${params.shareKey})`, async () => {
@@ -275,7 +291,7 @@ const getSpace = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('days');
return res.data;
});
@@ -286,13 +302,13 @@ const getSpace = cache(
const getChangeRequest = cache(
async (input: DataFetcherInput, params: { spaceId: string; changeRequestId: string }) => {
'use cache';
cacheTag(
cacheTagsFor(input, [
getCacheTag({
tag: 'change-request',
space: params.spaceId,
changeRequest: params.changeRequestId,
})
);
}),
]);
return wrapDataFetcherError(async () => {
return trace(
@@ -306,7 +322,7 @@ const getChangeRequest = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('minutes');
return res.data;
}
@@ -332,7 +348,7 @@ const getRevision = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('max');
return res.data;
});
@@ -346,13 +362,13 @@ const getChangeRequestChanges = cache(
params: { spaceId: string; changeRequestId: string; limit?: number }
) => {
'use cache: remote';
cacheTag(
cacheTagsFor(input, [
getCacheTag({
tag: 'change-request',
space: params.spaceId,
changeRequest: params.changeRequestId,
})
);
}),
]);
return wrapDataFetcherError(async () => {
return trace(
@@ -369,7 +385,7 @@ const getChangeRequestChanges = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('minutes');
return res.data;
}
@@ -401,7 +417,7 @@ const getRevisionSemanticChanges = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('max');
return res.data;
}
@@ -438,7 +454,7 @@ const getRevisionPageMarkdown = cache(
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('max');
if (!('markdown' in res.data)) {
@@ -480,7 +496,7 @@ const getRevisionPageDocument = cache(
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLifeFromResponse(res, 'max');
return res.data;
@@ -513,7 +529,7 @@ const getRevisionReusableContentDocument = cache(
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLifeFromResponse(res, 'max');
return res.data;
@@ -546,7 +562,7 @@ const getRevisionPageByPath = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('max');
return res.data;
}
@@ -569,7 +585,7 @@ const getDocument = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLifeFromResponse(res, 'max');
return res.data;
});
@@ -588,15 +604,15 @@ const getComputedDocument = cache(
}
) => {
'use cache';
cacheTag(
cacheTagsFor(input, [
...getComputedContentSourceCacheTags(
{
spaceId: params.spaceId,
organizationId: params.organizationId,
},
params.source
)
);
),
]);
return wrapDataFetcherError(async () => {
return trace(
@@ -614,7 +630,7 @@ const getComputedDocument = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLifeFromResponse(res, 'max');
return res.data;
}
@@ -627,13 +643,13 @@ const getComputedDocument = cache(
const getLatestOpenAPISpecVersionContent = cache(
async (input: DataFetcherInput, params: { organizationId: string; slug: string }) => {
'use cache';
cacheTag(
cacheTagsFor(input, [
getCacheTag({
tag: 'openapi',
organization: params.organizationId,
openAPISpec: params.slug,
})
);
}),
]);
return wrapDataFetcherError(async () => {
return trace(
@@ -647,7 +663,7 @@ const getLatestOpenAPISpecVersionContent = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('max');
return res.data;
}
@@ -663,12 +679,12 @@ const getPublishedContentSite = cache(
_apiVersion: string
) => {
'use cache';
cacheTag(
cacheTagsFor(input, [
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
}),
]);
return wrapDataFetcherError(async () => {
return trace(
@@ -685,7 +701,7 @@ const getPublishedContentSite = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('days');
return res.data;
}
@@ -705,12 +721,12 @@ const getSiteRedirectBySource = cache(
}
) => {
'use cache';
cacheTag(
cacheTagsFor(input, [
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
}),
]);
return wrapDataFetcherError(async () => {
return trace(
@@ -728,7 +744,7 @@ const getSiteRedirectBySource = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('days');
return res.data;
}
@@ -740,12 +756,12 @@ const getSiteRedirectBySource = cache(
const getEmbedByUrl = cache(
async (input: DataFetcherInput, params: { spaceId: string; url: string }) => {
'use cache';
cacheTag(
cacheTagsFor(input, [
getCacheTag({
tag: 'space',
space: params.spaceId,
})
);
}),
]);
return wrapDataFetcherError(async () => {
return trace(`getEmbedByUrl(${params.spaceId}, ${params.url})`, async () => {
@@ -759,7 +775,7 @@ const getEmbedByUrl = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('weeks');
return res.data;
});
@@ -774,12 +790,12 @@ const searchSiteContent = cache(
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
) => {
'use cache';
cacheTag(
cacheTagsFor(input, [
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
}),
]);
return wrapDataFetcherError(async () => {
return trace(
@@ -809,7 +825,7 @@ const searchSiteContent = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('hours');
return res.data.items;
}
@@ -824,12 +840,12 @@ const renderIntegrationUi = cache(
params: { integrationName: string; request: RenderIntegrationUI }
) => {
'use cache';
cacheTag(
cacheTagsFor(input, [
getCacheTag({
tag: 'integration',
integration: params.integrationName,
})
);
}),
]);
return wrapDataFetcherError(async () => {
return trace(`renderIntegrationUi(${params.integrationName})`, async () => {
@@ -841,7 +857,7 @@ const renderIntegrationUi = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('days');
return res.data;
});
@@ -871,7 +887,7 @@ const listRevisionPageMetaLinks = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('days');
return res.data;
}
@@ -903,3 +919,11 @@ function getCacheTagsFromResponse(response: HttpResponse<unknown, unknown>) {
const tags = !cacheTagHeader ? [] : cacheTagHeader.split(',');
return tags;
}
/**
* Tag the current cache entry. Always tag through this helper, never `cacheTag` directly,
* so that a PPR render never emits an un-prefixed tag.
*/
function cacheTagsFor(input: DataFetcherInput, tags: string[]) {
cacheTag(...prefixCacheTags(tags, input.ppr));
}
+6
View File
@@ -24,6 +24,12 @@ export type DataFetcherResponse<T> =
* It is used between v1 and v2.
*/
export interface GitBookDataFetcher {
/**
* Set when rendering under the PPR route: the cache entries are partitioned from the static
* ones and their cache tags are prefixed.
*/
readonly ppr?: true;
/**
* Get an API client for the current context.
*/
+6 -3
View File
@@ -17,6 +17,7 @@ import type {
ResolveOpenAPIBlockArgs,
} from './types';
import type { FetchOpenAPIFilesystemResult } from './types';
import { prefixCacheTags } from '@/lib/cache-tags';
import { DataFetcherError, noCacheFetchOptions } from '@/lib/data';
import { resolveContentRef } from '@/lib/references';
@@ -44,7 +45,7 @@ export async function fetchOpenAPIFilesystem(
return resolved.openapi.filesystem;
}
// For legacy blocks ("swagger"), we need to fetch the file system.
return fetchFilesystem(resolved.href, context.space.id);
return fetchFilesystem(resolved.href, context.space.id, context.dataFetcher.ppr);
})();
if ('error' in result) {
@@ -74,7 +75,9 @@ export async function fetchOpenAPIFilesystem(
*/
async function fetchFilesystem(
url: string,
spaceId: string
spaceId: string,
// Part of the cache key, so PPR entries are not shared with the static ones.
ppr: true | undefined
): Promise<
| Filesystem
| {
@@ -86,7 +89,7 @@ async function fetchFilesystem(
> {
'use cache';
try {
cacheTag(getCacheTag({ tag: 'space', space: spaceId }));
cacheTag(...prefixCacheTags([getCacheTag({ tag: 'space', space: spaceId })], ppr));
return await fetchFilesystemNoCache(url);
} catch (error) {
// To avoid hammering the file with requests, we cache the error for around a minute.