Load the admin toolbar and its CSS lazily (RND-12524) (#4526)

This commit is contained in:
Nolann B.
2026-08-20 13:47:49 +02:00
committed by GitHub
parent 48fba7cb49
commit 8a6baaba6a
10 changed files with 135 additions and 115 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Load the admin toolbar and its CSS lazily so published pages no longer ship a render-blocking stylesheet for admin-only UI.
@@ -1,4 +1,4 @@
import { AdminToolbarClient } from './AdminToolbarClient';
import { AdminToolbarLazy } from './AdminToolbarLazy';
import { getToolbarChangedPages } from './changedPages';
import type { AdminToolbarContext } from './types';
import type { GitBookSiteContext } from '@/lib/context';
@@ -62,5 +62,5 @@ export async function AdminToolbar(props: AdminToolbarProps) {
changedPages,
};
return <AdminToolbarClient context={minimalContext} />;
return <AdminToolbarLazy context={minimalContext} />;
}
@@ -0,0 +1,17 @@
'use client';
import dynamic from 'next/dynamic';
import type { AdminToolbarClientProps } from './types';
// The toolbar only ever appears after hydration (`useToolbarVisibility` reads cookies and local
// storage), so nothing is lost by deferring it — and it takes its CSS modules out of the route's
// render-blocking stylesheets, where every published page was paying for them.
const AdminToolbarClient = dynamic(
() => import('./AdminToolbarClient').then((mod) => mod.AdminToolbarClient),
{ ssr: false }
);
export function AdminToolbarLazy(props: AdminToolbarClientProps) {
return <AdminToolbarClient {...props} />;
}
@@ -1,6 +1,3 @@
// Only the server entrypoint: re-exporting the client modules would pull their CSS modules back
// into the route's render-blocking stylesheets, defeating the lazy boundary.
export * from './AdminToolbar';
export * from './AdminToolbarClient';
export * from './IframeWrapper';
export * from './Toolbar';
export * from './transitions';
export * from './utils';
@@ -15,7 +15,6 @@ import {
} from '../primitives/DropdownMenu';
import { SiteAuthLoginDropdownMenuItem } from '../SiteAuth/SiteAuthLoginLink';
import { HeaderLinkMoreDropdown } from './HeaderLinkMoreClient';
import styles from './headerLinks.module.css';
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
import type { GitBookSiteContext } from '@/lib/context';
import { resolveContentRef } from '@/lib/references';
@@ -34,7 +33,7 @@ export function HeaderLinkMore(props: {
const { label, links, context, socialAccounts } = props;
return (
<div className={`${styles.linkEllipsis} z-20 items-center`}>
<div className="header-links-ellipsis z-20 items-center">
<HeaderLinkMoreDropdown
label={label}
dropdownClassName={tcls(
@@ -1,6 +1,5 @@
import type React from 'react';
import styles from './headerLinks.module.css';
import { type ClassValue, tcls } from '@/lib/tailwind';
interface HeaderLinksProps {
@@ -12,7 +11,7 @@ export async function HeaderLinks({ children, style }: HeaderLinksProps) {
return (
<div
className={tcls(
styles.containerHeaderlinks,
'header-links',
'@4xl:[&>.button+.button]:-ml-2 z-20 ml-auto flex min-w-9 shrink grow @7xl:grow-0 items-center justify-end @4xl:gap-x-6 gap-x-4',
style
)}
@@ -1,80 +0,0 @@
@container header (width < 1280px) {
.containerHeaderlinks {
container-type: inline-size;
container-name: headerlinks;
}
}
.linkEllipsis {
display: none;
& div > a {
display: none;
}
}
@container headerlinks (width < 150px) {
.containerHeaderlinks > :nth-child(n + 1) {
display: none;
}
.containerHeaderlinks > :nth-child(n + 1) ~ .linkEllipsis {
display: flex;
& div > *:nth-child(n + 1) {
display: flex;
}
}
}
@container headerlinks (width < 300px) {
.containerHeaderlinks > :nth-child(n + 2) {
display: none;
}
.containerHeaderlinks > :nth-child(n + 2) ~ .linkEllipsis {
display: flex;
& div > *:nth-child(n + 2) {
display: flex;
}
}
}
@container headerlinks (width < 450px) {
.containerHeaderlinks > :nth-child(n + 3) {
display: none;
}
.containerHeaderlinks > :nth-child(n + 3) ~ .linkEllipsis {
display: flex;
& div > *:nth-child(n + 3) {
display: flex;
}
}
}
@container headerlinks (width < 550px) {
.containerHeaderlinks > :nth-child(n + 4) {
display: none;
}
.containerHeaderlinks > :nth-child(n + 4) ~ .linkEllipsis {
display: flex;
& div > *:nth-child(n + 4) {
display: flex;
}
}
}
@container headerlinks (width < 650px) {
.containerHeaderlinks > :nth-child(n + 5) {
display: none;
}
.containerHeaderlinks > :nth-child(n + 5) ~ .linkEllipsis {
display: flex;
& div > *:nth-child(n + 5) {
display: flex;
}
}
}
@container headerlinks (width < 750px) {
.containerHeaderlinks > :nth-child(n + 6) {
display: none;
}
.containerHeaderlinks > :nth-child(n + 6) ~ .linkEllipsis {
display: flex;
& div > *:nth-child(n + 6) {
display: flex;
}
}
}
@@ -552,3 +552,107 @@ html.dark .highlight-line.diff-deleted .highlight-line-content::before {
.button-group > :is(button, a):not(:first-child):has(~ :is(button, a)) {
@apply rounded-none!;
}
/* Zoomable images */
html:has(.zoom-modal) {
/* stylelint-disable-next-line plugin/no-unsupported-browser-features -- single-value overflow is universal; doiuse flags the whole css-overflow feature */
overflow: hidden;
}
.zoom-img {
/* stylelint-disable-next-line plugin/no-unsupported-browser-features -- zoom cursors are irrelevant on the touch browsers doiuse flags */
cursor: zoom-in;
}
.zoom-image-active {
view-transition-name: zoom-image;
}
.zoom-modal img {
view-transition-name: zoom-image;
/* stylelint-disable-next-line plugin/no-unsupported-browser-features -- zoom cursors are irrelevant on the touch browsers doiuse flags */
cursor: zoom-out;
}
/* Header links collapse into the "more" dropdown one by one as the header narrows. Each breakpoint
hides one more link and reveals the ellipsis holding the ones that no longer fit. */
@container header (width < 1280px) {
.header-links {
container-type: inline-size;
container-name: headerlinks;
}
}
.header-links-ellipsis {
display: none;
& div > a {
display: none;
}
}
@container headerlinks (width < 150px) {
.header-links > :nth-child(n + 1) {
display: none;
}
.header-links > :nth-child(n + 1) ~ .header-links-ellipsis {
display: flex;
& div > *:nth-child(n + 1) {
display: flex;
}
}
}
@container headerlinks (width < 300px) {
.header-links > :nth-child(n + 2) {
display: none;
}
.header-links > :nth-child(n + 2) ~ .header-links-ellipsis {
display: flex;
& div > *:nth-child(n + 2) {
display: flex;
}
}
}
@container headerlinks (width < 450px) {
.header-links > :nth-child(n + 3) {
display: none;
}
.header-links > :nth-child(n + 3) ~ .header-links-ellipsis {
display: flex;
& div > *:nth-child(n + 3) {
display: flex;
}
}
}
@container headerlinks (width < 550px) {
.header-links > :nth-child(n + 4) {
display: none;
}
.header-links > :nth-child(n + 4) ~ .header-links-ellipsis {
display: flex;
& div > *:nth-child(n + 4) {
display: flex;
}
}
}
@container headerlinks (width < 650px) {
.header-links > :nth-child(n + 5) {
display: none;
}
.header-links > :nth-child(n + 5) ~ .header-links-ellipsis {
display: flex;
& div > *:nth-child(n + 5) {
display: flex;
}
}
}
@container headerlinks (width < 750px) {
.header-links > :nth-child(n + 6) {
display: none;
}
.header-links > :nth-child(n + 6) ~ .header-links-ellipsis {
display: flex;
& div > *:nth-child(n + 6) {
display: flex;
}
}
}
@@ -1,20 +0,0 @@
html:has(.zoomModal) {
overflow: hidden;
}
.zoomImg {
cursor: zoom-in;
}
.zoomImageActive {
view-transition-name: zoom-image;
}
.zoomModal {
/** Unstyled */
}
.zoomModal img {
view-transition-name: zoom-image;
cursor: zoom-out;
}
@@ -6,7 +6,6 @@ import ReactDOM from 'react-dom';
import { Icon } from '@gitbook/icons';
import styles from './ZoomImage.module.css';
import { tcls } from '@/lib/tailwind';
const PADDING = 32; // Padding around the image in the modal, in pixels
@@ -159,8 +158,8 @@ export function ZoomImage(
}}
className={classNames(
props.className,
zoomable ? styles.zoomImg : null,
active ? styles.zoomImageActive : null
zoomable ? 'zoom-img' : null,
active ? 'zoom-image-active' : null
)}
/>
)}
@@ -200,7 +199,7 @@ function ZoomImageModal(props: {
<div
data-testid="zoom-image-modal"
className={classNames(
styles.zoomModal,
'zoom-modal',
tcls(
'fixed',
'inset-0',