Compare commits

...

5 Commits

Author SHA1 Message Date
Taran Vohra dcfd81b4f8 Merge branch 'main' into perf/lazy-webframes-lighter-markup 2026-10-29 22:08:04 +05:30
taranvohra a539e1ecd4 changeset 2026-10-29 22:04:33 +05:30
taranvohra 8dd771acc8 Skip rendering off-screen expandables
A page can hold a hundred expandables, each hiding a table, and the whole
document restyles when any of them opens. Give them `content-visibility`,
as OpenAPI blocks already have, so the off-screen ones do no style or
layout work. The DOM is untouched, so find-in-page and #anchors still work.
2026-10-29 22:04:33 +05:30
taranvohra cca9de33d8 Pack repeated block, anchor and table cell styles into CSS classes
Blocks, heading anchors and table cells each repeat a long class list on
every element they render, and a grid repeats a column's width in a style
attribute on every cell. On a large reference page that is most of the
markup: 2.6MB of class attributes and 288KB of style attributes.

Move the shared utilities into component classes, and have the grid set
one custom property per column that the cells point at. The classes sit
in `components` so the utilities passed alongside them still win.

Cuts the server HTML of such a page by ~26%.
2026-10-29 22:04:33 +05:30
taranvohra 3c92aa49a4 Load webframes lazily
A page can hold hundreds of webframes, most of them inside collapsed
expandables. The source is attached on mount, so every one of them starts
a cross-origin navigation as soon as the page hydrates: a reference page
with 521 webframes fired 521 requests, the last finishing ~20s in.

Mark the iframe `loading="lazy"` so each one waits until it is near the
viewport. An iframe inside a collapsed `<details>` then loads nothing, and
loads on open.
2026-10-29 22:04:20 +05:30
16 changed files with 290 additions and 77 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/react-contentkit": patch
---
Load a webframe only once it is near the viewport. A page can hold hundreds of them inside collapsed expandables, and loading every one on mount held up the first paint.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Pack the class lists that every block, heading anchor and table cell repeats into shared CSS classes, and take each column's width from the table rather than from a style attribute on every cell, so a large page carries far less markup. Off-screen expandables also skip their rendering work.
@@ -0,0 +1,13 @@
@reference "../RootLayout/globals.css";
/* Every block on every page carries these, so they live here rather than in each class attribute. */
@layer components {
.gb-block {
@apply decoration-primary/6;
}
/* Lists opt out: a long list is expected to break across printed pages. */
.gb-block:not(.gb-block-list) {
@apply print:break-inside-avoid;
}
}
@@ -2,6 +2,7 @@ import type { DocumentBlock, JSONDocument } from '@gitbook/api';
import { CONTENT_STYLE, CONTENT_STYLE_REDUCED } from '../layout';
import { Block } from './Block';
import './Blocks.css';
import type { DocumentContextProps } from './DocumentView';
import { isBlockOffscreen } from './utils';
import { type ClassValue, tcls } from '@/lib/tailwind';
@@ -93,10 +94,10 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
key={node.key || `${node.type}-${index}`}
block={node}
style={[
'decoration-primary/6',
'gb-block',
FULL_WIDTH_BLOCKS.has(node.type) ? CONTENT_STYLE : CONTENT_STYLE_REDUCED,
!LIST_BLOCKS.has(node.type) && 'print:break-inside-avoid',
LIST_BLOCKS.has(node.type) && 'gb-block-list',
blockStyle,
]}
isEstimatedOffscreen={isOffscreen}
@@ -2,6 +2,7 @@
import React from 'react';
import './style.css';
import { useHash } from '@/components/hooks';
import { type ClassValue, tcls } from '@/lib/tailwind';
@@ -53,6 +54,7 @@ export function Details(props: {
className={tcls(
className,
'group/expandable',
'expandable-block',
'bg-tint-base',
'border',
'border-b-0',
@@ -0,0 +1,28 @@
@reference "../../RootLayout/globals.css";
/* A page can hold a hundred expandables, each hiding a table; skipping the render work for the
off-screen ones is what keeps opening one cheap. The DOM is untouched, so the server HTML,
find-in-page and #anchors all still work. */
.expandable-block {
/* Below Safari 18 / Firefox 125 this is ignored and every expandable renders, as it does today. */
/* stylelint-disable-next-line plugin/no-unsupported-browser-features */
content-visibility: auto;
/* Only the block axis: the inline size comes from the parent. Plain length first, as a fallback
for engines without the `auto` keyword, where a skipped block would collapse to 0. The length
is a closed expandable with a one-line summary — `auto` swaps in the real size once rendered. */
contain-intrinsic-height: 58px;
contain-intrinsic-height: auto 58px;
}
/* PDF export lays the whole space out off-screen, so nothing there may be skipped. */
body:has(.print-mode) .expandable-block {
/* stylelint-disable-next-line plugin/no-unsupported-browser-features */
content-visibility: visible;
}
@media print {
.expandable-block {
/* stylelint-disable-next-line plugin/no-unsupported-browser-features */
content-visibility: visible;
}
}
@@ -0,0 +1,25 @@
@reference "../RootLayout/globals.css";
/* A page carries one of these per heading, so the shared utilities live here instead of in a class
attribute repeated hundreds of times — on a large page that repetition is most of the HTML.
They sit in `components` so the utilities passed as props still override them. */
@layer components {
.hash {
@apply relative inline-grid grid-area-1-1 border-0 rounded site-background;
@apply pointer-events-none opacity-0 transition-opacity;
@apply pointer-fine:h-[1em] pointer-fine:pointer-events-auto;
@apply pointer-fine:group-hover/hash:opacity-100 pointer-fine:group-focus-within/hash:opacity-100;
@apply [.hash-revealed_&]:pointer-events-auto [.hash-revealed_&]:opacity-100;
}
.hash-link {
@apply inline-flex items-center p-1 pointer-fine:h-full pointer-fine:p-0;
}
.hash-icon {
@apply size-3 self-center text-transparent transition-colors;
@apply pointer-fine:group-hover/hash:text-tint-subtle contrast-more:pointer-fine:group-hover/hash:text-tint-strong;
@apply pointer-fine:group-focus-within/hash:text-tint-subtle contrast-more:pointer-fine:group-focus-within/hash:text-tint-strong;
@apply [.hash-revealed_&]:text-tint-subtle contrast-more:[.hash-revealed_&]:text-tint-strong;
}
}
@@ -3,6 +3,7 @@ import { Icon } from '@gitbook/icons';
import { Link } from '../primitives';
import type { DocumentContext } from './DocumentView';
import './HashLinkButton.css';
import { getBlockTextStyle } from './spacing';
import { type ClassValue, tcls } from '@/lib/tailwind';
@@ -35,53 +36,13 @@ export function HashLinkButton(props: {
const textStyle = getBlockTextStyle(block);
return (
<span
className={tcls(
'relative',
'hash',
'inline-grid',
'grid-area-1-1',
'pointer-fine:h-[1em]',
'border-0',
'opacity-0',
'pointer-events-none',
'site-background',
'rounded',
'transition-opacity',
'pointer-fine:pointer-events-auto',
'pointer-fine:group-hover/hash:opacity-100',
'pointer-fine:group-focus-within/hash:opacity-100',
'[.hash-revealed_&]:opacity-100',
'[.hash-revealed_&]:pointer-events-auto',
className
)}
>
<span className={tcls('hash', className)}>
<Link
href={`#${id}`}
aria-label={label}
className={tcls(
'inline-flex items-center',
'p-1',
'pointer-fine:h-full pointer-fine:p-0',
textStyle.lineHeight
)}
className={tcls('hash-link', textStyle.lineHeight)}
>
<Icon
icon="hashtag"
className={tcls(
'size-3',
'self-center',
'transition-colors',
'text-transparent',
'pointer-fine:group-hover/hash:text-tint-subtle',
'contrast-more:pointer-fine:group-hover/hash:text-tint-strong',
'pointer-fine:group-focus-within/hash:text-tint-subtle',
'contrast-more:pointer-fine:group-focus-within/hash:text-tint-strong',
'[.hash-revealed_&]:text-tint-subtle',
'contrast-more:[.hash-revealed_&]:text-tint-strong',
iconClassName
)}
/>
<Icon icon="hashtag" className={tcls('hash-icon', iconClassName)} />
</Link>
</span>
);
@@ -1,9 +1,11 @@
import { getCommentCellProps } from '../utils';
import { getMergedCellWidth, getTableCellMerge } from './cellMerges';
import { getColumnWidth } from './layout';
import './columnWidths.css';
import { MAX_COLUMN_WIDTH_VARS, getColumnWidth } from './layout';
import { RecordColumnValue } from './RecordColumnValue';
import type { TableGridViewProps, TableRecordKV } from './Table';
import { TableSearchRecord } from './TableSearch';
import './styles.css';
import { getColumnVerticalAlignment } from './utils';
import { tcls } from '@/lib/tailwind';
@@ -23,30 +25,32 @@ export function RecordRow(
role="row"
recordId={record[0]}
visibleClassName="flex"
className={tcls(
'group/row',
'border-tint-subtle',
'transition-colors',
'hover:bg-tint-hover'
)}
className={tcls('group/row', 'table-grid-row')}
>
{view.columns.map((column) => {
{view.columns.map((column, columnIndex) => {
const cellMerge = getTableCellMerge(cellMergeLayout, record[0], column);
if (cellMerge && !cellMerge.isAnchor) {
return null;
}
const renderedColumns = cellMerge?.merge.columns ?? [column];
const columnWidth = getMergedCellWidth(
renderedColumns.map((mergedColumn) =>
getColumnWidth({
column: mergedColumn,
columnWidths: context.mode === 'print' ? undefined : view.columnWidths,
autoSizedColumns,
fixedColumns,
})
)
);
// A plain cell takes its width from the column variable the grid sets; only merged
// cells and columns past the generated classes need a width of their own.
const widthFromColumnVar =
renderedColumns.length === 1 && columnIndex < MAX_COLUMN_WIDTH_VARS;
const columnWidth = widthFromColumnVar
? undefined
: getMergedCellWidth(
renderedColumns.map((mergedColumn) =>
getColumnWidth({
column: mergedColumn,
columnWidths:
context.mode === 'print' ? undefined : view.columnWidths,
autoSizedColumns,
fixedColumns,
})
)
);
const isStickyFirstColumnCell = stickyFirstColumn && column === firstVisibleColumn;
// @ts-expect-error
const verticalAlignment = getColumnVerticalAlignment(block.data.definition[column]);
@@ -58,16 +62,15 @@ export function RecordRow(
{...getCommentCellProps(context, record[0], column)}
aria-colspan={cellMerge?.merge.colSpan}
className={tcls(
'relative flex flex-1 border-r px-3 py-2 align-middle text-sm last:border-r-0',
'border-tint-subtle',
isStickyFirstColumnCell
? 'sticky left-0 z-10 bg-tint-base group-hover/row:bg-tint-hover'
: undefined
'table-grid-cell',
widthFromColumnVar ? `gb-tcol-${columnIndex}` : undefined,
isStickyFirstColumnCell ? 'table-grid-cell-sticky' : undefined
)}
style={{
width: columnWidth,
minWidth: columnWidth || '100px',
}}
style={
widthFromColumnVar
? undefined
: { width: columnWidth, minWidth: columnWidth || '100px' }
}
>
<RecordColumnValue
{...props}
@@ -1,4 +1,4 @@
import { getColumnWidth, getViewGridLayout } from './layout';
import { getColumnWidth, getColumnWidthVars, getViewGridLayout } from './layout';
import { RecordRow } from './RecordRow';
import type { TableGridViewProps } from './Table';
import { getColumnAlignment } from './utils';
@@ -82,7 +82,7 @@ export function ViewGridHeader(props: ViewGridHeaderProps) {
export function ViewGrid(props: ViewGridProps) {
const { block, view, records, context, tableClassName } = props;
const { tableWidth, autoSizedColumns, fixedColumns } = getViewGridLayout({
const { tableWidth, columnWidths, autoSizedColumns, fixedColumns } = getViewGridLayout({
block,
view,
mode: context.mode,
@@ -92,6 +92,12 @@ export function ViewGrid(props: ViewGridProps) {
<div
role="rowgroup"
className={tcls('flex', 'flex-col', tableClassName ?? tableWidth, '[&>*+*]:border-t')}
style={getColumnWidthVars({
columns: view.columns,
columnWidths,
autoSizedColumns,
fixedColumns,
})}
>
{records.map((record) => (
<RecordRow
@@ -0,0 +1,100 @@
/* Column widths are a property of the column, not of the cell, yet a grid repeats them on every
cell it renders. The table sets one custom property per column and each cell points at it with
one of these classes, which keeps the width out of a per-cell style attribute.
Columns past this many, and cells merged across columns, fall back to an inline width. */
.gb-tcol-0 {
width: var(--gb-tcol-0);
min-width: var(--gb-tcol-0);
}
.gb-tcol-1 {
width: var(--gb-tcol-1);
min-width: var(--gb-tcol-1);
}
.gb-tcol-2 {
width: var(--gb-tcol-2);
min-width: var(--gb-tcol-2);
}
.gb-tcol-3 {
width: var(--gb-tcol-3);
min-width: var(--gb-tcol-3);
}
.gb-tcol-4 {
width: var(--gb-tcol-4);
min-width: var(--gb-tcol-4);
}
.gb-tcol-5 {
width: var(--gb-tcol-5);
min-width: var(--gb-tcol-5);
}
.gb-tcol-6 {
width: var(--gb-tcol-6);
min-width: var(--gb-tcol-6);
}
.gb-tcol-7 {
width: var(--gb-tcol-7);
min-width: var(--gb-tcol-7);
}
.gb-tcol-8 {
width: var(--gb-tcol-8);
min-width: var(--gb-tcol-8);
}
.gb-tcol-9 {
width: var(--gb-tcol-9);
min-width: var(--gb-tcol-9);
}
.gb-tcol-10 {
width: var(--gb-tcol-10);
min-width: var(--gb-tcol-10);
}
.gb-tcol-11 {
width: var(--gb-tcol-11);
min-width: var(--gb-tcol-11);
}
.gb-tcol-12 {
width: var(--gb-tcol-12);
min-width: var(--gb-tcol-12);
}
.gb-tcol-13 {
width: var(--gb-tcol-13);
min-width: var(--gb-tcol-13);
}
.gb-tcol-14 {
width: var(--gb-tcol-14);
min-width: var(--gb-tcol-14);
}
.gb-tcol-15 {
width: var(--gb-tcol-15);
min-width: var(--gb-tcol-15);
}
.gb-tcol-16 {
width: var(--gb-tcol-16);
min-width: var(--gb-tcol-16);
}
.gb-tcol-17 {
width: var(--gb-tcol-17);
min-width: var(--gb-tcol-17);
}
.gb-tcol-18 {
width: var(--gb-tcol-18);
min-width: var(--gb-tcol-18);
}
.gb-tcol-19 {
width: var(--gb-tcol-19);
min-width: var(--gb-tcol-19);
}
.gb-tcol-20 {
width: var(--gb-tcol-20);
min-width: var(--gb-tcol-20);
}
.gb-tcol-21 {
width: var(--gb-tcol-21);
min-width: var(--gb-tcol-21);
}
.gb-tcol-22 {
width: var(--gb-tcol-22);
min-width: var(--gb-tcol-22);
}
.gb-tcol-23 {
width: var(--gb-tcol-23);
min-width: var(--gb-tcol-23);
}
@@ -39,6 +39,27 @@ export function getViewGridLayout({
};
}
/** How many columns `columnWidths.css` carries a `.gb-tcol-*` class for. */
export const MAX_COLUMN_WIDTH_VARS = 24;
/**
* Custom properties holding each column's width, set once on the grid so the cells can point at
* them instead of repeating the width in a style attribute of their own.
*/
export function getColumnWidthVars(args: {
columns: string[];
columnWidths: Record<string, number> | undefined;
autoSizedColumns: string[];
fixedColumns: string[];
}): Record<string, string> {
const { columns, ...widthArgs } = args;
const vars: Record<string, string> = {};
columns.slice(0, MAX_COLUMN_WIDTH_VARS).forEach((column, index) => {
vars[`--gb-tcol-${index}`] = getColumnWidth({ column, ...widthArgs });
});
return vars;
}
export const getColumnWidth = ({
column,
columnWidths,
@@ -0,0 +1,19 @@
@reference "../../RootLayout/globals.css";
/* A grid view repeats these on every row and every cell, so a table of any size pays for the class
lists many times over in the HTML. They sit in `components` so the utilities passed alongside
them still override. */
@layer components {
.table-grid-row {
@apply border-tint-subtle transition-colors hover:bg-tint-hover;
}
.table-grid-cell {
@apply relative flex flex-1 border-r border-tint-subtle px-3 py-2 align-middle text-sm;
@apply last:border-r-0;
}
.table-grid-cell-sticky {
@apply sticky left-0 z-10 bg-tint-base group-hover/row:bg-tint-hover;
}
}
@@ -0,0 +1,18 @@
@reference "../../RootLayout/globals.css";
/* Blocks repeat their alignment on every paragraph, heading and cell, so the three utilities each
alignment needs are packed into one class. They sit in `components`, so a utility passed
alongside one still wins. */
@layer components {
.gb-align-start {
@apply justify-start self-start text-start;
}
.gb-align-center {
@apply justify-center self-center text-center;
}
.gb-align-end {
@apply justify-end self-end text-end;
}
}
@@ -1,20 +1,22 @@
import { TextAlignment } from '@gitbook/api';
import './textAlignment.css';
import type { ClassValue } from '@/lib/tailwind';
import { nullIfNever } from '@/lib/typescript';
/**
* Get the tailwind class for a text alignment.
* Each alignment packs three utilities, so it is a single class from `textAlignment.css`.
*/
export function getTextAlignment(textAlignment: TextAlignment | undefined): ClassValue {
switch (textAlignment) {
case undefined:
case TextAlignment.Start:
return ['text-start', 'self-start', 'justify-start'];
return 'gb-align-start';
case TextAlignment.Center:
return ['text-center', 'self-center', 'justify-center'];
return 'gb-align-center';
case TextAlignment.End:
return ['text-end', 'self-end', 'justify-end'];
return 'gb-align-end';
default:
return nullIfNever(textAlignment);
}
@@ -196,6 +196,10 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
// Rendered without a source before mount so it reserves its size in the SSR layout.
src={mounted ? element.source.url : undefined}
title={element.source.url}
// A page can hold hundreds of these, most of them inside collapsed expandables.
// Loading them all on mount delays the first paint by seconds, so each one waits
// until it is near the viewport.
loading="lazy"
allowFullScreen
allow="clipboard-write"
className="contentkit-webframe block h-full w-full max-w-full border-none"