Implement fallback handling for variant space navigation

This commit is contained in:
Nicolas Dorseuil
2026-01-15 10:06:49 +01:00
parent 7eeff73080
commit 0470bdbefd
8 changed files with 143 additions and 26 deletions
@@ -17,7 +17,7 @@ interface VariantSpace {
*/
function useVariantSpaceHref(variantSpace: VariantSpace, currentSpacePath: string, active = false) {
const currentPathname = useCurrentPagePath();
const { metaLinks } = useCurrentPageMetadata();
const { metaLinks, currentPage } = useCurrentPageMetadata();
// We first check if there is an alternate link for the variant space in the current page metadata.
const pageHasAlternateForVariant = metaLinks?.alternates.find(
@@ -27,6 +27,15 @@ function useVariantSpaceHref(variantSpace: VariantSpace, currentSpacePath: strin
return pageHasAlternateForVariant.href;
}
const firstAlternate = metaLinks?.alternates[0];
const computed = firstAlternate ? {
pageID: firstAlternate.pageID,
spaceID: firstAlternate.space?.id
} : {
pageID: currentPage?.id,
spaceID: currentPage?.spaceId
};
// If there is no alternate link, we reconstruct the URL by swapping the space path.
// We need to ensure that the variant space URL is not the same as the current space path.
@@ -44,9 +53,18 @@ function useVariantSpaceHref(variantSpace: VariantSpace, currentSpacePath: strin
targetUrl.searchParams.set('fallback', 'true');
if(computed?.spaceID && computed?.pageID) {
targetUrl.searchParams.set('fallbackPageID', computed.pageID);
targetUrl.searchParams.set('fallbackSpaceID', computed.spaceID);
}
return targetUrl.toString();
}
if(computed?.spaceID && computed?.pageID) {
return `${joinPath(variantSpaceUrl, currentPathname)}?fallback=true&fallbackPageID=${computed.pageID}&fallbackSpaceID=${computed.spaceID}`;
}
// Fallback when the URL path is a relative path (in development mode)
return `${joinPath(variantSpaceUrl, currentPathname)}?fallback=true`;
}
@@ -11,8 +11,13 @@ import type { PageMetaLinks } from './SitePage';
*/
export function PageClientLayout({
pageMetaLinks,
currentPage
}: {
pageMetaLinks: PageMetaLinks | null;
currentPage: {
id: string;
spaceId: string;
} | null;
}) {
// We use this hook in the page layout to ensure the elements for the blocks
// are rendered before we scroll to a hash or to the top of the page
@@ -21,7 +26,7 @@ export function PageClientLayout({
// The page metadata such as meta links are generated on the server side,
// but need to be registered on the client side in other parts of the layout
// such as the SpaceDropdown.
useRegisterPageMetadata({ pageMetaLinks });
useRegisterPageMetadata({ pageMetaLinks, currentPage });
useStripFallbackQueryParam();
return null;
@@ -44,6 +49,8 @@ function useStripFallbackQueryParam() {
if (searchParams?.has('fallback')) {
const params = new URLSearchParams(searchParams.toString());
params.delete('fallback');
params.delete('fallbackPageID');
params.delete('fallbackSpaceID');
router.push(`${pathname}?${params.toString()}${window.location.hash ?? ''}`);
}
}, [router, pathname, searchParams]);
@@ -54,9 +61,13 @@ function useStripFallbackQueryParam() {
*/
function useRegisterPageMetadata(metadata: {
pageMetaLinks: PageMetaLinks | null;
currentPage: {
id: string;
spaceId: string;
} | null;
}) {
const { pageMetaLinks } = metadata;
const { pageMetaLinks, currentPage } = metadata;
React.useEffect(() => {
currentPageMetadataStore.setState({ metaLinks: pageMetaLinks });
}, [pageMetaLinks]);
currentPageMetadataStore.setState({ metaLinks: pageMetaLinks, currentPage });
}, [pageMetaLinks, currentPage]);
}
@@ -47,6 +47,11 @@ export type PageMetaLinks = {
* Space the alternate link points to, if any.
*/
space: AlternateLinkSpace | null;
/**
* The page ID the alternate link points to, if any.
*/
pageID?: string;
}>;
};
@@ -66,7 +71,6 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
pageMetaLinks,
} = await getSitePageData(props);
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
return (
<PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}>
{/* Using `contents` makes the children of this div according to its parent — which keeps them in a single flex row with the TOC by default.
@@ -102,7 +106,7 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
staticRoute={props.staticRoute}
/>
</div>
<PageClientLayout pageMetaLinks={pageMetaLinks} />
<PageClientLayout pageMetaLinks={pageMetaLinks} currentPage={{ id: page.id, spaceId: context.space.id }} />
</div>
</PageContextProvider>
);
@@ -151,7 +155,7 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
});
if (!pageTarget) {
if (context.isFallback) {
if (context.fallback.isFallback) {
redirect(context.linker.toPathInSpace('/'));
}
notFound();
@@ -246,12 +250,13 @@ export async function getSitePageData(props: SitePageProps) {
redirect(context.linker.toPathInSpace(pathname));
} else {
// If the page is not found and we are in fallback mode, return a redirect to the basepath
if (context.isFallback) {
if (context.fallback.isFallback) {
redirect(context.linker.toPathInSpace('/'));
}
notFound();
}
} else if (getPagePath(context.revision.pages, pageTarget.page) !== rawPathname) {
// If the resolved page path doesn't match the requested pathname, redirect to the correct one
redirect(
context.linker.toPathForPage({
pages: context.revision.pages,
@@ -337,6 +342,7 @@ async function resolvePageMetaLinks(
space: resolved?.space
? { id: resolved.space.id, language: resolved.space.language }
: null,
pageID: resolved?.page?.id,
}))
);
@@ -348,7 +354,7 @@ async function resolvePageMetaLinks(
return {
canonical: resolvedCanonical ?? null,
alternates: resolvedAlternates.filter(
(alt): alt is { href: string; space: AlternateLinkSpace | null } => !!alt.href
(alt): alt is { href: string; space: AlternateLinkSpace | null, pageID: string | undefined } => !!alt.href
),
};
}
@@ -2,7 +2,7 @@ import type { GitBookSiteContext } from '@/lib/context';
import { redirect } from 'next/navigation';
import { getDataOrNull } from '@/lib/data';
import { resolvePageId, resolvePagePath } from '@/lib/pages';
import { resolveFallbackPage, resolvePageId, resolvePagePath } from '@/lib/pages';
import { withLeadingSlash } from '@/lib/paths';
export interface PagePathParams {
@@ -36,7 +36,7 @@ export async function fetchPageData(context: GitBookSiteContext, params: PagePar
* If the path can't be found, we try to resolve it from the API to handle redirects.
*/
async function resolvePage(context: GitBookSiteContext, params: PagePathParams | PageIdParams) {
const { organizationId, site, space, revision, shareKey, linker, revisionId } = context;
const { organizationId, site, space, revision, shareKey, linker, revisionId, fallback } = context;
if ('pageId' in params) {
return resolvePageId(revision.pages, params.pageId);
@@ -100,6 +100,12 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams |
);
if (resolved) {
return resolvePageId(revision.pages, resolved.id);
} else if(fallback.isFallback && fallback.pageID && fallback.spaceID) {
return resolveFallbackPage(
revision.pages,
fallback.spaceID,
fallback.pageID
);
}
}
@@ -11,16 +11,18 @@ import * as zustand from 'zustand';
*/
export const currentPageMetadataStore = zustand.create<{
metaLinks: PageMetaLinks | null;
currentPage: {
id: string;
spaceId: string;
} | null;
}>(() => ({
metaLinks: null,
currentPage: null,
}));
/**
* Return the metadata for the current page.
*/
export function useCurrentPageMetadata() {
const metaLinks = zustand.useStore(currentPageMetadataStore, (state) => state.metaLinks);
return {
metaLinks,
};
return zustand.useStore(currentPageMetadataStore, (state) => state);
}
+26 -8
View File
@@ -27,6 +27,25 @@ import { GITBOOK_URL } from './env';
import { type ImageResizer, createImageResizer } from './images';
import { type GitBookLinker, createLinker, linkerForPublishedURL } from './links';
type FallbackData = {
/**
*
*/
isFallback: boolean;
/**
* Space ID of the main space.
* Only provided for a computed space.
*/
spaceID?: string;
/**
* page ID of the main revision.
* Only provided for a computed revision.
*/
pageID?: string;
};
/**
* Data about the site URL. Provided by the middleware.
* These data are stable between pages in the same site space.
@@ -52,11 +71,10 @@ export type SiteURLData = Pick<
imagesContextId: string;
/**
* Whether this request is a fallback rendering.
* We use this when switching variant as we don't know if the page exists in the other variant.
* By knowing it's a fallback, we can redirect to the space base path instead of returning a 404.
* Necessary data to properly handle fallback rendering.
* This is used when switching between variants to avoid fetching every revision every time.
*/
isFallback?: boolean;
fallback?: FallbackData;
};
/**
@@ -142,7 +160,7 @@ export type GitBookSiteContext = GitBookSpaceContext & {
contextId?: string;
/** Whether this request is a fallback rendering. */
isFallback: boolean;
fallback: FallbackData;
};
/**
@@ -221,7 +239,7 @@ export async function fetchSiteContextByURLLookup(
changeRequest: data.changeRequest,
revision: data.revision,
contextId: data.contextId,
isFallback: data.isFallback ?? false,
fallback: data.fallback ?? { isFallback: false },
});
}
@@ -240,7 +258,7 @@ export async function fetchSiteContextByIds(
changeRequest: string | undefined;
revision: string | undefined;
contextId?: string;
isFallback: boolean;
fallback: FallbackData;
}
): Promise<GitBookSiteContext> {
const { dataFetcher } = baseContext;
@@ -352,7 +370,7 @@ export async function fetchSiteContextByIds(
visibleSections,
scripts,
contextId: ids.contextId,
isFallback: ids.isFallback,
fallback: ids.fallback,
};
}
+45 -1
View File
@@ -116,6 +116,50 @@ export function resolvePageId(
return iteratePages(rootPages, []);
}
/**
*
* Resolve a page by its related space ID and page ID from the revision pages.
* Related pages are pages that are linked via meta links alternates.
* It uses the metaLinks alternates to find the matching computed page.
* It always use the first alternate to resolve the parent page.
*
* @param rootPages
* @param relatedSpaceId Space ID of the parent page (i.e. )
* @param relatedPageId
* @returns
*/
export function resolveFallbackPage(
rootPages: Revision['pages'],
relatedSpaceId: string,
relatedPageId: string
): { page: RevisionPageDocument; ancestors: AncestorRevisionPage[] } | undefined {
const iteratePages = (
pages: RevisionPage[],
ancestors: AncestorRevisionPage[]
): { page: RevisionPageDocument; ancestors: AncestorRevisionPage[] } | undefined => {
for (const page of pages) {
if (page.type === RevisionPageType.Link || page.type === RevisionPageType.Computed) {
continue;
}
if(page.type === RevisionPageType.Document) {
const metaLinksAlternates = page.metaLinks?.alternates;
if(metaLinksAlternates && metaLinksAlternates.length > 0 && metaLinksAlternates[0]?.kind !== "url") {
if(metaLinksAlternates[0]?.space === relatedSpaceId && metaLinksAlternates[0]?.page === relatedPageId) {
return resolvePageDocument(page, ancestors);
}
}
}
const result = iteratePages(page.pages, [...ancestors, page]);
if (result) {
return result;
}
}
};
return iteratePages(rootPages, []);
}
/**
* Resolve the next/previous page before another one.
* It ignores hidden pages as this is used for navigation purpose.
@@ -220,7 +264,7 @@ function resolvePageDocument(
return;
}
if (page.type === RevisionPageType.Link || page.type === RevisionPageType.Computed) {
return undefined;
return undefined;
}
return { page, ancestors };
+13 -1
View File
@@ -282,6 +282,13 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
// (customization override, theme, etc)
let routeType: 'dynamic' | 'static' = 'static';
// Extract fallback pageID and spaceID from the URL
// These are used when switching variant spaces to redirect to a specific page if the current path doesn't exist in the new variant.
// Because there is no link between every variant, we need to pass a stable spaceID/pageID to redirect to.
// They are the first alternate link in the page metadata of the previous variant.
const fallbackPageID = requestURL.searchParams.get('fallbackPageID');
const fallbackSpaceID = requestURL.searchParams.get('fallbackSpaceID');
// We pick only stable data from the siteURL data to prevent re-rendering of
// the root layout when changing pages..
const stableSiteURLData: SiteURLData = {
@@ -298,7 +305,12 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
apiToken: siteURLData.apiToken,
imagesContextId: imagesContextId,
contextId: siteURLData.contextId,
isFallback: requestURL.searchParams.get('fallback') === 'true' ? true : undefined,
fallback: {
isFallback: requestURL.searchParams.get('fallback') === 'true' ? true : false,
...(fallbackPageID ? { pageID: fallbackPageID } : {}),
...(fallbackSpaceID ? { spaceID: fallbackSpaceID } : {}),
}
};
const requestHeaders = new Headers(request.headers);