Skip to content

Make sure to use the correct version for adaptive content #3487

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 24, 2025
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/gitbook/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ const nextConfig = {
dynamic: 3600, // 1 hour
static: 3600, // 1 hour
},

// Since content is fully static, we don't want to fetch on hover again
optimisticClientCache: false,
},

env: {
Expand Down
7 changes: 5 additions & 2 deletions packages/gitbook/src/components/SiteLayout/ClientContexts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
import type { CustomizationThemeMode, SiteExternalLinksTarget } from '@gitbook/api';
import { ThemeProvider } from 'next-themes';
import type React from 'react';
import { RouterCacheClearer } from '../hooks/useClearRouterCache';
import { LinkSettingsContext } from '../primitives';

export function ClientContexts(props: {
nonce?: string;
forcedTheme: CustomizationThemeMode | undefined;
externalLinksTarget: SiteExternalLinksTarget;
contextId?: string;
children: React.ReactNode;
}) {
const { children, forcedTheme, externalLinksTarget } = props;
const { children, forcedTheme, externalLinksTarget, contextId } = props;

/**
* A bug in ThemeProvider is causing the nonce to be included incorrectly
Expand All @@ -24,7 +26,8 @@ export function ClientContexts(props: {

return (
<ThemeProvider nonce={nonce} attribute="class" enableSystem forcedTheme={forcedTheme}>
<LinkSettingsContext.Provider value={{ externalLinksTarget }}>
<LinkSettingsContext.Provider value={{ externalLinksTarget, contextId }}>
<RouterCacheClearer />
{children}
</LinkSettingsContext.Provider>
</ThemeProvider>
Expand Down
6 changes: 3 additions & 3 deletions packages/gitbook/src/components/SiteLayout/SiteLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@ import { AdminToolbar } from '@/components/AdminToolbar';
import { CookiesToast } from '@/components/Cookies';
import { LoadIntegrations } from '@/components/Integrations';
import { SpaceLayout } from '@/components/SpaceLayout';
import { buildVersion } from '@/lib/build';
import { isSiteIndexable } from '@/lib/seo';

import type { VisitorAuthClaims } from '@/lib/adaptive';
import { buildVersion } from '@/lib/build';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_ASSETS_URL, GITBOOK_ICONS_URL } from '@/lib/env';
import { getResizedImageURL } from '@/lib/images';
import { isSiteIndexable } from '@/lib/seo';
import { ClientContexts } from './ClientContexts';
import { RocketLoaderDetector } from './RocketLoaderDetector';

Expand Down Expand Up @@ -50,6 +49,7 @@ export async function SiteLayout(props: {
<NuqsAdapter>
<ClientContexts
nonce={nonce}
contextId={context.contextId}
forcedTheme={
forcedTheme ??
(customization.themes.toggeable ? undefined : customization.themes.default)
Expand Down
37 changes: 37 additions & 0 deletions packages/gitbook/src/components/hooks/useClearRouterCache.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use client';
import { useRouter } from 'next/navigation';
import { useContext, useEffect } from 'react';
import { LinkSettingsContext } from '../primitives';

// We cannot use a ref here because the contextId gets reset on navigation
// Probably because of this bug https://github.com/vercel/next.js/issues/67542
let previousContextId: string | undefined;

/**
* A custom hook that clears the router cache on contextId change.
* This is useful for ensuring that the router does not cache stale data for adaptive content.
*/
export function useClearRouterCache() {
const { contextId } = useContext(LinkSettingsContext);
const router = useRouter();
useEffect(() => {
if (previousContextId === undefined) {
// On the first run, we set the previousContextId to the current contextId
previousContextId = contextId;
return; // Skip the first run to avoid unnecessary reload
}
// Initially, previousContextId will be undefined, so we only clear the cache
// if contextId has changed from a defined value to a new value.
// This prevents unnecessary cache clearing on the first render.
if (contextId !== previousContextId && previousContextId !== undefined) {
previousContextId = contextId;
// Trigger a full reload to clear the in-memory cache
router.refresh(); // This will clear the cache and re-fetch the data
}
}, [contextId, router]);
}

export const RouterCacheClearer = () => {
useClearRouterCache();
return null; // This component does not render anything, it just runs the hook
};
10 changes: 9 additions & 1 deletion packages/gitbook/src/components/primitives/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export type LinkProps = Omit<BaseLinkProps, 'href'> &
*/
export const LinkSettingsContext = React.createContext<{
externalLinksTarget: SiteExternalLinksTarget;
/**
* Context ID used by adaptive content. It is used to clear the router cache.
*/
contextId?: string;
}>({
externalLinksTarget: SiteExternalLinksTarget.Self,
});
Expand Down Expand Up @@ -123,11 +127,15 @@ export const Link = React.forwardRef(function Link(
);
}

// Not sure why yet, but it seems necessary to force prefetch to true
// default behavior doesn't seem to properly use the client router cache.
const _prefetch = prefetch === null || prefetch === undefined ? true : prefetch;

return (
<NextLink
ref={ref}
href={href}
prefetch={prefetch}
prefetch={_prefetch}
className={tcls(...forwardedClassNames, className)}
{...domProps}
onClick={onClick}
Expand Down
7 changes: 7 additions & 0 deletions packages/gitbook/src/lib/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type SiteURLData = Pick<
| 'siteSection'
| 'siteBasePath'
| 'basePath'
| 'contextId'
> & {
/**
* Identifier used for image resizing.
Expand Down Expand Up @@ -123,6 +124,9 @@ export type GitBookSiteContext = GitBookSpaceContext & {

/** Scripts to load for the site. */
scripts: SiteIntegrationScript[];

/** Context ID used by adaptive content. It is used to clear the router cache */
contextId?: string;
};

/**
Expand Down Expand Up @@ -200,6 +204,7 @@ export async function fetchSiteContextByURLLookup(
shareKey: data.shareKey,
changeRequest: data.changeRequest,
revision: data.revision,
contextId: data.contextId,
});
}

Expand All @@ -217,6 +222,7 @@ export async function fetchSiteContextByIds(
shareKey: string | undefined;
changeRequest: string | undefined;
revision: string | undefined;
contextId?: string;
}
): Promise<GitBookSiteContext> {
const { dataFetcher } = baseContext;
Expand Down Expand Up @@ -314,6 +320,7 @@ export async function fetchSiteContextByIds(
structure: siteStructure,
sections,
scripts,
contextId: ids.contextId,
};
}

Expand Down
6 changes: 6 additions & 0 deletions packages/gitbook/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
shareKey: siteURLData.shareKey,
apiToken: siteURLData.apiToken,
imagesContextId: imagesContextId,
contextId: siteURLData.contextId,
};

const requestHeaders = new Headers(request.headers);
Expand Down Expand Up @@ -328,6 +329,11 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
response.headers.set('x-gitbook-route-type', routeType);
response.headers.set('x-gitbook-route-site', siteURLWithoutProtocol);

// When we use adaptive content, we want to ensure that the cache is not used at all on the client side.
if (siteURLData.contextId) {
response.headers.set('cache-control', 'public, max-age=0, must-revalidate');
}

return writeResponseCookies(response, cookies);
};

Expand Down