From 8a6baaba6a5c20958966394db339465a06eab97d Mon Sep 17 00:00:00 2001
From: "Nolann B." <100787331+nolannbiron@users.noreply.github.com>
Date: Thu, 20 Aug 2026 13:47:49 +0200
Subject: [PATCH] Load the admin toolbar and its CSS lazily (RND-12524) (#4526)
---
.changeset/tidy-pianos-argue.md | 5 +
.../components/AdminToolbar/AdminToolbar.tsx | 4 +-
.../AdminToolbar/AdminToolbarLazy.tsx | 17 +++
.../src/components/AdminToolbar/index.ts | 7 +-
.../src/components/Header/HeaderLinkMore.tsx | 3 +-
.../src/components/Header/HeaderLinks.tsx | 3 +-
.../components/Header/headerLinks.module.css | 80 --------------
.../src/components/RootLayout/globals.css | 104 ++++++++++++++++++
.../src/components/utils/ZoomImage.module.css | 20 ----
.../src/components/utils/ZoomImage.tsx | 7 +-
10 files changed, 135 insertions(+), 115 deletions(-)
create mode 100644 .changeset/tidy-pianos-argue.md
create mode 100644 packages/gitbook/src/components/AdminToolbar/AdminToolbarLazy.tsx
delete mode 100644 packages/gitbook/src/components/Header/headerLinks.module.css
delete mode 100644 packages/gitbook/src/components/utils/ZoomImage.module.css
diff --git a/.changeset/tidy-pianos-argue.md b/.changeset/tidy-pianos-argue.md
new file mode 100644
index 000000000..b66a1d6d7
--- /dev/null
+++ b/.changeset/tidy-pianos-argue.md
@@ -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.
diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx
index 6d933a9cf..889dc5a3c 100644
--- a/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx
+++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx
@@ -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 ;
+ return ;
}
diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbarLazy.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbarLazy.tsx
new file mode 100644
index 000000000..18d7ce20e
--- /dev/null
+++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbarLazy.tsx
@@ -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 ;
+}
diff --git a/packages/gitbook/src/components/AdminToolbar/index.ts b/packages/gitbook/src/components/AdminToolbar/index.ts
index 490e4b6ae..3cbcc3774 100644
--- a/packages/gitbook/src/components/AdminToolbar/index.ts
+++ b/packages/gitbook/src/components/AdminToolbar/index.ts
@@ -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';
diff --git a/packages/gitbook/src/components/Header/HeaderLinkMore.tsx b/packages/gitbook/src/components/Header/HeaderLinkMore.tsx
index 76a99a229..12f17aab7 100644
--- a/packages/gitbook/src/components/Header/HeaderLinkMore.tsx
+++ b/packages/gitbook/src/components/Header/HeaderLinkMore.tsx
@@ -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 (
-
+
.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
)}
diff --git a/packages/gitbook/src/components/Header/headerLinks.module.css b/packages/gitbook/src/components/Header/headerLinks.module.css
deleted file mode 100644
index 46eb0036a..000000000
--- a/packages/gitbook/src/components/Header/headerLinks.module.css
+++ /dev/null
@@ -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;
- }
- }
-}
diff --git a/packages/gitbook/src/components/RootLayout/globals.css b/packages/gitbook/src/components/RootLayout/globals.css
index 1c542fd45..2110b127a 100644
--- a/packages/gitbook/src/components/RootLayout/globals.css
+++ b/packages/gitbook/src/components/RootLayout/globals.css
@@ -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;
+ }
+ }
+}
diff --git a/packages/gitbook/src/components/utils/ZoomImage.module.css b/packages/gitbook/src/components/utils/ZoomImage.module.css
deleted file mode 100644
index 324a9a22b..000000000
--- a/packages/gitbook/src/components/utils/ZoomImage.module.css
+++ /dev/null
@@ -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;
-}
diff --git a/packages/gitbook/src/components/utils/ZoomImage.tsx b/packages/gitbook/src/components/utils/ZoomImage.tsx
index 0c44fb6f0..4d294f81a 100644
--- a/packages/gitbook/src/components/utils/ZoomImage.tsx
+++ b/packages/gitbook/src/components/utils/ZoomImage.tsx
@@ -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: {