diff --git a/bun.lockb b/bun.lockb index c08095915f..c51581464d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 7ec5c1f862..80e9363091 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -41,7 +41,7 @@ "katex": "^0.16.9", "mathjax": "^3.2.2", "memoizee": "^0.4.15", - "next": "14.2.15", + "next": "14.2.18", "next-themes": "^0.2.1", "nuqs": "^1.17.4", "object-hash": "^3.0.0", diff --git a/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/loading.tsx b/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/loading.tsx deleted file mode 100644 index 4b269d89e5..0000000000 --- a/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/loading.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { SkeletonHeading, SkeletonParagraph } from '@/components/primitives'; -import { tcls } from '@/lib/tailwind'; - -/** - * Placeholder when loading a page. - */ -export default function PageSkeleton() { - return ( -
-
- - -
-
- ); -} diff --git a/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/page.tsx b/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/page.tsx index 095e9841a6..b944061cb7 100644 --- a/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/page.tsx +++ b/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/page.tsx @@ -1,10 +1,12 @@ import { CustomizationHeaderPreset, CustomizationThemeMode } from '@gitbook/api'; import { Metadata, Viewport } from 'next'; +import { headers } from 'next/headers'; import { notFound, redirect } from 'next/navigation'; -import React from 'react'; +import React, { Suspense } from 'react'; import { PageAside } from '@/components/PageAside'; import { PageBody, PageCover } from '@/components/PageBody'; +import { SkeletonHeading, SkeletonParagraph } from '@/components/primitives'; import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links'; import { getPagePath, resolveFirstDocument } from '@/lib/pages'; import { ContentRefContext } from '@/lib/references'; @@ -17,15 +19,23 @@ import { PagePathParams, fetchPageData, getPathnameParam, normalizePathname } fr export const runtime = 'edge'; -/** - * Fetch and render a page. - */ -export default async function Page(props: { +type PageProps = { params: PagePathParams; searchParams: { fallback?: string }; -}) { - const { params, searchParams } = props; +}; + +export default async function Page(props: PageProps) { + // We wrap the page in Suspense to enable streaming at page level + // it's only enabled in "navigation" mode + return ( + }> + + + ); +} +async function PageContent(props: PageProps) { + const data = await getPageDataWithFallback(props, { redirectOnFallback: true }); const { content: contentPointer, contentTarget, @@ -36,26 +46,7 @@ export default async function Page(props: { pages, page, document, - } = await getPageDataWithFallback({ - pagePathParams: params, - searchParams, - redirectOnFallback: true, - }); - - const linksContext: PageHrefContext = {}; - const rawPathname = getPathnameParam(params); - if (!page) { - const pathname = normalizePathname(rawPathname); - if (pathname !== rawPathname) { - // If the pathname was not normalized, redirect to the normalized version - // before trying to resolve the page again - redirect(absoluteHref(pathname)); - } else { - notFound(); - } - } else if (getPagePath(pages, page) !== rawPathname) { - redirect(pageHref(pages, page, linksContext)); - } + } = data; const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None; const withFullPageCover = !!( @@ -78,6 +69,8 @@ export default async function Page(props: { return ( <> + {/* Title is displayed by the browser, except in navigation mode */} + {getTitle(data)} {withFullPageCover && page.cover ? ( ) : null} @@ -117,7 +110,30 @@ export default async function Page(props: { ); } -export async function generateViewport({ params }: { params: PagePathParams }): Promise { +function PageSkeleton() { + return ( +
+
+ + +
+
+ ); +} + +export async function generateViewport({ params }: PageProps): Promise { const { customization } = await fetchPageData(params); return { colorScheme: customization.themes.toggeable @@ -128,26 +144,25 @@ export async function generateViewport({ params }: { params: PagePathParams }): }; } -export async function generateMetadata({ - params, - searchParams, -}: { - params: PagePathParams; - searchParams: { fallback?: string }; -}): Promise { - const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback({ - pagePathParams: params, - searchParams, - }); +function getTitle(input: Awaited>) { + const { page, space, customization, site } = input; + return [page.title, getContentTitle(space, customization, site ?? null)] + .filter(Boolean) + .join(' | '); +} - if (!page) { - notFound(); +export async function generateMetadata(props: PageProps): Promise { + // We only generate metadata in navigation mode. Else we let the browser handle it. + if (await checkIsInAppNavigation()) { + return {}; } + const data = await getPageDataWithFallback(props, { redirectOnFallback: false }); + + const { page, pages, space, customization, site, ancestors } = data; + return { - title: [page.title, getContentTitle(space, customization, site ?? null)] - .filter(Boolean) - .join(' | '), + title: getTitle(data), description: page.description ?? '', alternates: { canonical: absoluteHref(getPagePath(pages, page), true), @@ -165,17 +180,30 @@ export async function generateMetadata({ }; } +/** + * Check if the navigation is in-app, meaning the user clicks on a link. + */ +async function checkIsInAppNavigation() { + const headerList = await headers(); + const fetchMode = headerList.get('sec-fetch-mode'); + + return fetchMode === 'cors'; +} + /** * Fetches the page data matching the requested pathname and fallback to root page when page is not found. */ -async function getPageDataWithFallback(args: { - pagePathParams: PagePathParams; - searchParams: { fallback?: string }; - redirectOnFallback?: boolean; -}) { - const { pagePathParams, searchParams, redirectOnFallback = false } = args; +async function getPageDataWithFallback( + props: PageProps, + behaviour: { + redirectOnFallback: boolean; + }, +) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + const { params, searchParams } = props; + const { redirectOnFallback } = behaviour; - const { pages, page: targetPage, ...otherPageData } = await fetchPageData(pagePathParams); + const { pages, page: targetPage, ...otherPageData } = await fetchPageData(params); let page = targetPage; const canFallback = !!searchParams.fallback; @@ -189,6 +217,21 @@ async function getPageDataWithFallback(args: { page = rootPage?.page; } + const linksContext: PageHrefContext = {}; + const rawPathname = getPathnameParam(params); + if (!page) { + const pathname = normalizePathname(rawPathname); + if (pathname !== rawPathname) { + // If the pathname was not normalized, redirect to the normalized version + // before trying to resolve the page again + redirect(absoluteHref(pathname)); + } else { + notFound(); + } + } else if (getPagePath(pages, page) !== rawPathname) { + redirect(pageHref(pages, page, linksContext)); + } + return { ...otherPageData, pages,