diff --git a/.changeset/ppr-cache-tag-prefix.md b/.changeset/ppr-cache-tag-prefix.md
new file mode 100644
index 000000000..5a1f4f76f
--- /dev/null
+++ b/.changeset/ppr-cache-tag-prefix.md
@@ -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.
diff --git a/packages/gitbook/src/app/utils.ts b/packages/gitbook/src/app/utils.ts
index 7d82dc0ba..c11613faa 100644
--- a/packages/gitbook/src/app/utils.ts
+++ b/packages/gitbook/src/app/utils.ts
@@ -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 {
diff --git a/packages/gitbook/src/components/SitePage/PPRSitePage.tsx b/packages/gitbook/src/components/SitePage/PPRSitePage.tsx
index 6c2bdf70a..88931279e 100644
--- a/packages/gitbook/src/components/SitePage/PPRSitePage.tsx
+++ b/packages/gitbook/src/components/SitePage/PPRSitePage.tsx
@@ -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 ;
@@ -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);
}
diff --git a/packages/gitbook/src/lib/cache-tags.ts b/packages/gitbook/src/lib/cache-tags.ts
new file mode 100644
index 000000000..f8eaa1ecd
--- /dev/null
+++ b/packages/gitbook/src/lib/cache-tags.ts
@@ -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;
+}
diff --git a/packages/gitbook/src/lib/context.ts b/packages/gitbook/src/lib/context.ts
index 85a2debfc..37bd01a15 100644
--- a/packages/gitbook/src/lib/context.ts
+++ b/packages/gitbook/src/lib/context.ts
@@ -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;
diff --git a/packages/gitbook/src/lib/data/api.ts b/packages/gitbook/src/lib/data/api.ts
index 29a77d0f4..11dd296c4 100644
--- a/packages/gitbook/src/lib/data/api.ts
+++ b/packages/gitbook/src/lib/data/api.ts
@@ -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 = {
* 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[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) {
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));
+}
diff --git a/packages/gitbook/src/lib/data/types.ts b/packages/gitbook/src/lib/data/types.ts
index 4dcaf76c0..ddbba3e26 100644
--- a/packages/gitbook/src/lib/data/types.ts
+++ b/packages/gitbook/src/lib/data/types.ts
@@ -24,6 +24,12 @@ export type DataFetcherResponse =
* 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.
*/
diff --git a/packages/gitbook/src/lib/openapi/fetch.ts b/packages/gitbook/src/lib/openapi/fetch.ts
index 7ec7dc53c..273ddab63 100644
--- a/packages/gitbook/src/lib/openapi/fetch.ts
+++ b/packages/gitbook/src/lib/openapi/fetch.ts
@@ -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.