diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/(content)/[pagePath]/loading.tsx b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/(content)/[pagePath]/loading.tsx new file mode 100644 index 000000000..8892cd8bc --- /dev/null +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/(content)/[pagePath]/loading.tsx @@ -0,0 +1,5 @@ +import { SitePageSkeleton } from '@/components/SitePage'; + +export default function Loading() { + return ; +} diff --git a/packages/gitbook/src/app/utils.ts b/packages/gitbook/src/app/utils.ts index ca0c944fc..5a627444c 100644 --- a/packages/gitbook/src/app/utils.ts +++ b/packages/gitbook/src/app/utils.ts @@ -1,4 +1,5 @@ import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive'; +import { cache } from '@/lib/cache'; import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@/lib/context'; import { getDynamicCustomizationSettings } from '@/lib/customization'; import type { SiteAPIToken } from '@gitbook/api'; @@ -26,56 +27,81 @@ export type RouteParams = RouteLayoutParams & { * Get the static context when rendering statically a site. */ export async function getStaticSiteContext(params: RouteLayoutParams) { - const siteURL = getSiteURLFromParams(params); - const siteURLData = getSiteURLDataFromParams(params); - - // For static routes, we check the expiration of the JWT token - // as the route might be revalidated after expiration - const decoded = jwtDecode(siteURLData.apiToken); - if (decoded.exp && decoded.exp < Date.now() / 1000 + 120) { - forbidden(); - } - - const context = await fetchSiteContextByURLLookup( - getBaseContext({ - siteURL, - siteURLData, - urlMode: getModeFromParams(params.mode), - }), - siteURLData - ); - - return { - context, - visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded), - }; + return fetchStaticSiteContext(params.mode, params.siteURL, params.siteData); } +// Deduped per request so the layout, page and their metadata/viewport generators don't each +// re-parse the site structure. Keyed on the primitive route params — the pagePath differs between +// layout and page, so it is intentionally excluded to keep a single cache entry per request. +const fetchStaticSiteContext = cache( + async (mode: string, encodedSiteURL: string, encodedSiteData: string) => { + const params: RouteLayoutParams = { + mode, + siteURL: encodedSiteURL, + siteData: encodedSiteData, + }; + const siteURL = getSiteURLFromParams(params); + const siteURLData = getSiteURLDataFromParams(params); + + // For static routes, we check the expiration of the JWT token + // as the route might be revalidated after expiration + const decoded = jwtDecode(siteURLData.apiToken); + if (decoded.exp && decoded.exp < Date.now() / 1000 + 120) { + forbidden(); + } + + const context = await fetchSiteContextByURLLookup( + getBaseContext({ + siteURL, + siteURLData, + urlMode: getModeFromParams(params.mode), + }), + siteURLData + ); + + return { + context, + visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded), + }; + } +); + /** * Get the site context when rendering dynamically. * The context will depend on the request. */ export async function getDynamicSiteContext(params: RouteLayoutParams) { - const siteURL = getSiteURLFromParams(params); - const siteURLData = getSiteURLDataFromParams(params); - - const context = await fetchSiteContextByURLLookup( - getBaseContext({ - siteURL, - siteURLData, - urlMode: getModeFromParams(params.mode), - }), - siteURLData - ); - - context.customization = await getDynamicCustomizationSettings(context.customization); - - return { - context, - visitorAuthClaims: getVisitorAuthClaims(siteURLData), - }; + return fetchDynamicSiteContext(params.mode, params.siteURL, params.siteData); } +const fetchDynamicSiteContext = cache( + async (mode: string, encodedSiteURL: string, encodedSiteData: string) => { + const params: RouteLayoutParams = { + mode, + siteURL: encodedSiteURL, + siteData: encodedSiteData, + }; + const siteURL = getSiteURLFromParams(params); + const siteURLData = getSiteURLDataFromParams(params); + + const context = await fetchSiteContextByURLLookup( + getBaseContext({ + siteURL, + siteURLData, + urlMode: getModeFromParams(params.mode), + }), + siteURLData + ); + + context.customization = await getDynamicCustomizationSettings(context.customization); + + return { + context, + visitorAuthClaims: getVisitorAuthClaims(siteURLData), + }; + } +); + /** * Get the decoded page path from the params. */ diff --git a/packages/gitbook/src/components/PageBody/PageCoverImage.tsx b/packages/gitbook/src/components/PageBody/PageCoverImage.tsx index ae2d18c4b..bfa70e8e0 100644 --- a/packages/gitbook/src/components/PageBody/PageCoverImage.tsx +++ b/packages/gitbook/src/components/PageBody/PageCoverImage.tsx @@ -29,15 +29,10 @@ interface PageCoverImageProps { export function PageCoverImage(props: PageCoverImageProps) { const { imgs, y, height, mask } = props; - const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y); - - if (isLoading) { - return ( -
-
-
- ); - } + // The image is always rendered server-side (reserving space via aspect-ratio) so it stays + // discoverable by the preload scanner as the LCP element; the client probe only refines + // `objectPositionY` once real dimensions are known. + const { containerRef, objectPositionY } = useCoverPosition(imgs, y); return (
diff --git a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx index 26983d017..ec3edcc64 100644 --- a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx +++ b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx @@ -1,6 +1,7 @@ import type { GitBookSiteContext } from '@/lib/context'; import { CustomizationDefaultThemeMode } from '@gitbook/api'; import type { Metadata, Viewport } from 'next'; +import Script from 'next/script'; import React from 'react'; import * as ReactDOM from 'react-dom'; @@ -39,12 +40,6 @@ export async function SiteLayout(props: { ReactDOM.preconnect(GITBOOK_ASSETS_URL); } - scripts.forEach(({ script }) => { - ReactDOM.preload(script, { - as: 'script', - }); - }); - return ( {scripts.length > 0 - ? scripts.map(({ script }) =>