Compare commits

...

2 Commits

Author SHA1 Message Date
Nolann Biron 13cddb1a22 Derive the emoji index lazily instead of shipping a 1700-key table
getEmojiForCode looked up a generated map of base -> fully-qualified codepoint
sequences. Every value in it is its own key plus `fe0f`/`200d` joiners, so the
keys are redundant: ship the qualified sequences alone and build the index on
first use, which also keeps a 1700-property object off the parse path for the
many pages that render no emoji.

A test asserts all 1698 mappings resolve exactly as before.

Client JS on a docs page: 1564 KB -> 1529 KB decoded.

Also stops icon-only importers dragging in heavy neighbours: Emoji left the
primitives barrel, getAIChatName moved out of AIChat.tsx, and the search and
code-block call sites now import the specific module. Those are hygiene rather
than a measured win — they did not move the bundle on their own.
2026-08-19 22:07:05 +02:00
Nolann Biron fe471dcbca Load the expression runtime only for content that has expressions (RND-12524)
Every code block rendered client-side called useEvaluateInlineExpression, so
@gitbook/expr — and with it acorn, acorn-loose, acorn-walk, escodegen, esutils,
estraverse, source-map and eval-estree-expression — sat in the eager entry of
every docs page. That is 254 KB of JavaScript parser and code generator, more
than React itself, shipped to readers of static prose.

The server already computes hasInlineExpression, so let it pick the boundary:
blocks without expressions render a ClientCodeBlock that no longer imports the
runtime, and the ones that need it go through a next/dynamic wrapper. Same for
inline expressions. ssr: true throughout, so evaluated values stay in the HTML.

First-party client JS on a docs page: 1814 KB -> 1562 KB decoded (-252 KB).
2026-08-19 20:34:49 +02:00
28 changed files with 160 additions and 53 deletions
+5 -4
View File
@@ -10,18 +10,19 @@ interface EmojiData {
}
const emojis = emojisRaws as Record<string, EmojiData>;
const output: Record<string, string> = {};
const sequences: string[] = [];
Object.entries(emojis).forEach(([key, value]) => {
const emoji = value.code_points?.fully_qualified;
if (emoji && key !== emoji) {
output[key] = emoji;
} else if (!emoji) {
// The base form is always the fully-qualified one minus its `fe0f`/`200d` joiners, so only
// the qualified sequence is shipped and the lookup key is derived at runtime.
sequences.push(emoji);
}
});
fs.mkdirSync(path.resolve(__dirname, 'dist'), { recursive: true });
fs.writeFileSync(
path.resolve(__dirname, 'dist/index.ts'),
`export const emojiCodepoints: Record<string, string> = ${JSON.stringify(output, null, 4)};`
`export const emojiSequences = ${JSON.stringify(sequences.join('\n'))};\n`
);
+2 -1
View File
@@ -8,7 +8,8 @@ import type { GitBookAssistant } from '@gitbook/browser-types';
import { Icon, type IconName } from '@gitbook/icons';
import { useAIChatController, useAIChatState } from '.';
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
import { AIChatIcon, AISearchIcon } from '../AIChat/AIChatIcon';
import { getAIChatName } from '../AIChat/name';
import { useIntegrationAssistants } from '../Integrations';
import { useSetSearchState } from '../Search/useSearch';
import { isAIChatEnabled, isAISearchEnabled } from '@/components/utils/isAIChatEnabled';
@@ -37,8 +37,8 @@ import { AIChatMessages } from './AIChatMessages';
import { AIChatQueuedMessage } from './AIChatQueuedMessage';
import { AIChatResizeHandle } from './AIChatResizeHandle';
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
import { getAIChatName } from './name';
import { t, tString, useLanguage } from '@/intl/client';
import type { TranslationLanguage } from '@/intl/translations';
import { tcls } from '@/lib/tailwind';
export function AIChat() {
@@ -337,9 +337,3 @@ function AIChatError(props: { chatController: AIChatController }) {
</div>
);
}
export function getAIChatName(language: TranslationLanguage, trademark: boolean) {
return trademark
? tString(language, 'ai_chat_assistant_name')
: tString(language, 'ai_chat_assistant_name_unbranded');
}
@@ -1,4 +1,5 @@
export * from './AIChat';
export * from './name';
export * from './AIChatButton';
export * from './AIChatIcon';
export * from './AIResponseFeedback';
@@ -0,0 +1,8 @@
import { tString } from '@/intl/client';
import type { TranslationLanguage } from '@/intl/translations';
export function getAIChatName(language: TranslationLanguage, trademark: boolean) {
return trademark
? tString(language, 'ai_chat_assistant_name')
: tString(language, 'ai_chat_assistant_name_unbranded');
}
@@ -2,7 +2,7 @@
import { getCodeTextFromId } from './utils';
import { useAIChatController, useAIConfig } from '@/components/AI';
import { AIChatIcon } from '@/components/AIChat';
import { AIChatIcon } from '@/components/AIChat/AIChatIcon';
import { Button } from '@/components/primitives';
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
import { t, useLanguage } from '@/intl/client';
@@ -6,11 +6,10 @@ import { useDebounceCallback } from 'usehooks-ts';
import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/api';
import type { BlockProps } from '../Block';
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
import type { InlineExpressionVariables } from '../InlineExpression/types';
import { CodeBlockRenderer } from './CodeBlockRenderer';
import type { HighlightTheme, RenderedInline } from './highlight-tokens';
import { plainHighlight } from './plain-highlight';
import { useAdaptiveVisitor } from '@/components/Adaptive';
import { useInViewportListener } from '@/components/hooks/useInViewportListener';
import { useScrollListener } from '@/components/hooks/useScrollListener';
import { Button, ToggleChevron } from '@/components/primitives';
@@ -20,6 +19,9 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
export type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
inlines: RenderedInline[];
inlineExprVariables: InlineExpressionVariables;
/** Only supplied for blocks that contain one: evaluating needs `@gitbook/expr`, which is a
* quarter of a megabyte of JS parser we keep out of the bundle otherwise. */
evaluateInlineExpression?: (expression: string) => string;
mode: BlockProps<DocumentBlockCode>['context']['mode'];
themes?: CustomizationThemedCodeTheme;
embedded?: boolean;
@@ -32,17 +34,11 @@ export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
*/
export function ClientCodeBlock(props: ClientBlockProps) {
const { block, mode, style, inlines, inlineExprVariables, themes, embedded } = props;
const { block, mode, style, inlines, evaluateInlineExpression, themes, embedded } = props;
const blockRef = useRef<HTMLDivElement>(null);
const isInViewportRef = useRef(false);
const [isInViewport, setIsInViewport] = useState(false);
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
const visitorClaims = getAdaptiveVisitorClaims();
const evaluateInlineExpression = useEvaluateInlineExpression({
visitorClaims,
variables: inlineExprVariables,
});
const plainTheme = useMemo(
() => plainHighlight(block, inlines, { evaluateInlineExpression, themes }),
[block, inlines, evaluateInlineExpression, themes]
@@ -0,0 +1,19 @@
'use client';
import { ClientCodeBlock, type ClientBlockProps } from './ClientCodeBlock';
import { useAdaptiveVisitor } from '@/components/Adaptive';
import { useEvaluateInlineExpression } from '@/components/DocumentView/InlineExpression/useEvaluateInlineExpression';
/**
* Code block whose content contains inline expressions, so it needs the expression runtime.
* Kept in its own module: importing it pulls `@gitbook/expr` and its JS parser.
*/
export function ClientCodeBlockWithExpressions(props: ClientBlockProps) {
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
const evaluateInlineExpression = useEvaluateInlineExpression({
visitorClaims: getAdaptiveVisitorClaims(),
variables: props.inlineExprVariables,
});
return <ClientCodeBlock {...props} evaluateInlineExpression={evaluateInlineExpression} />;
}
@@ -0,0 +1,19 @@
'use client';
import dynamic from 'next/dynamic';
import type { ClientBlockProps } from './ClientCodeBlock';
// `ssr: true` keeps the evaluated code in the server-rendered HTML; the point of the boundary is to
// move `@gitbook/expr` (~254 KB with its parser) out of the route's eager entry.
const ClientCodeBlockWithExpressions = dynamic(
() =>
import('./ClientCodeBlockWithExpressions').then(
(mod) => mod.ClientCodeBlockWithExpressions
),
{ ssr: true }
);
export function ClientCodeBlockWithExpressionsLazy(props: ClientBlockProps) {
return <ClientCodeBlockWithExpressions {...props} />;
}
@@ -9,6 +9,7 @@ import type {
import type { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
import { ClientCodeBlock } from './ClientCodeBlock';
import { ClientCodeBlockWithExpressionsLazy } from './ClientCodeBlockWithExpressionsLazy';
import { CodeBlockRenderer } from './CodeBlockRenderer';
import { highlight } from './highlight';
import { type RenderedInline, getInlines } from './highlight-tokens';
@@ -121,8 +122,11 @@ export async function CodeBlock(
{isMermaid ? (
<MermaidCodeBlockLazy
{...clientProps}
hasInlineExpression={hasInlineExpression}
mermaidRuntimeURL={getAssetURL(MERMAID_RUNTIME_PATH)}
/>
) : hasInlineExpression ? (
<ClientCodeBlockWithExpressionsLazy {...clientProps} />
) : (
<ClientCodeBlock {...clientProps} />
)}
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useSta
import { createPortal } from 'react-dom';
import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock';
import { ClientCodeBlockWithExpressions } from './ClientCodeBlockWithExpressions';
import { getPlainCodeBlock } from './highlight-tokens';
import { MermaidPanZoomControls } from './MermaidPanZoomControls';
import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
@@ -20,6 +21,7 @@ import { tcls } from '@/lib/tailwind';
export function MermaidCodeBlock(
props: ClientBlockProps & {
mermaidRuntimeURL: string;
hasInlineExpression: boolean;
}
) {
const { block, mode, style, mermaidRuntimeURL } = props;
@@ -169,7 +171,11 @@ export function MermaidCodeBlock(
}, []);
if (error) {
return <ClientCodeBlock {...props} />;
return props.hasInlineExpression ? (
<ClientCodeBlockWithExpressions {...props} />
) : (
<ClientCodeBlock {...props} />
);
}
// The live diagram subtree. It is portaled into a stable host that moves between the
@@ -11,6 +11,8 @@ const MermaidCodeBlock = dynamic(
{ ssr: true }
);
export function MermaidCodeBlockLazy(props: ClientBlockProps & { mermaidRuntimeURL: string }) {
export function MermaidCodeBlockLazy(
props: ClientBlockProps & { mermaidRuntimeURL: string; hasInlineExpression: boolean }
) {
return <MermaidCodeBlock {...props} />;
}
@@ -1,7 +1,7 @@
import type { DocumentInlineEmoji } from '@gitbook/api';
import type { InlineProps } from './Inline';
import { Emoji as EmojiPrimitive } from '@/components/primitives';
import { Emoji as EmojiPrimitive } from '@/components/primitives/Emoji';
export function Emoji(props: InlineProps<DocumentInlineEmoji>) {
const { inline } = props;
@@ -3,7 +3,7 @@ import * as React from 'react';
import type { DocumentInlineExpression } from '@gitbook/api';
import type { InlineProps } from '../Inline';
import { InlineExpressionValue } from './InlineExpressionValue';
import { InlineExpressionValueLazy } from './InlineExpressionValueLazy';
/**
* Render an inline expression.
@@ -25,7 +25,7 @@ export function InlineExpression(props: InlineProps<DocumentInlineExpression>) {
return (
<React.Suspense fallback={null}>
<InlineExpressionValue expression={data.expression} variables={variables} />
<InlineExpressionValueLazy expression={data.expression} variables={variables} />
</React.Suspense>
);
}
@@ -0,0 +1,19 @@
'use client';
import dynamic from 'next/dynamic';
import type { InlineExpressionVariables } from './types';
// `ssr: true` keeps the evaluated value in the server-rendered HTML; the boundary exists to move
// `@gitbook/expr` and its JS parser out of the route's eager entry.
const InlineExpressionValue = dynamic(
() => import('./InlineExpressionValue').then((mod) => mod.InlineExpressionValue),
{ ssr: true }
);
export function InlineExpressionValueLazy(props: {
expression: string;
variables: InlineExpressionVariables;
}) {
return <InlineExpressionValue {...props} />;
}
@@ -1,3 +1,2 @@
export * from './InlineExpression';
export * from './types';
export * from './useEvaluateInlineExpression';
@@ -1,4 +1,4 @@
import { Emoji } from '../primitives';
import { Emoji } from '../primitives/Emoji';
import { Image } from '@/components/utils';
import type { GitBookSiteContext } from '@/lib/context';
@@ -1,7 +1,7 @@
import type { RevisionPage } from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
import { Emoji } from '@/components/primitives';
import { Emoji } from '@/components/primitives/Emoji';
import { type ClassValue, tcls } from '@/lib/tailwind';
export function PageIcon(props: {
@@ -5,8 +5,9 @@ import React from 'react';
import { Icon } from '@gitbook/icons';
import { AIResponseFeedback, AISearchIcon } from '../AIChat';
import { AISearchIcon } from '../AIChat/AIChatIcon';
import { HoldMessage } from '../AIChat/AIChatMessages';
import { AIResponseFeedback } from '../AIChat/AIResponseFeedback';
import { getInsightsSession, useTrackEvent } from '../Insights';
import { Button, Link } from '../primitives';
import { useSearchAskContext } from './SearchAskContext';
@@ -6,7 +6,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { CustomizationSearchStyle } from '@gitbook/api';
import { AIChatButton } from '../AIChat';
import { AIChatButton } from '../AIChat/AIChatButton';
import { useIsMobile } from '../hooks/useIsMobile';
import { Button, Popover } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
@@ -3,7 +3,7 @@ import React, { useEffect, useRef } from 'react';
import { Icon } from '@gitbook/icons';
import { AISearchIcon } from '../AIChat';
import { AISearchIcon } from '../AIChat/AIChatIcon';
import { Input } from '../primitives';
import { t, tString, useLanguage } from '@/intl/client';
@@ -10,7 +10,8 @@ import { Icon, type IconName } from '@gitbook/icons';
import { useAI } from '../AI';
import { CurrentPageProvider, useCurrentContent } from '../hooks';
import { PreservePageLayout } from '../PageBody/PreservePageLayout';
import { Button, Emoji, SkeletonList, StyledLink, SuspenseLoadedHint } from '../primitives';
import { Button, SkeletonList, StyledLink, SuspenseLoadedHint } from '../primitives';
import { Emoji } from '../primitives/Emoji';
import { Input } from '../primitives/Input';
import { useSetSearchState } from '../Search';
import { fetchSiteIndex } from '../Search/site-index';
@@ -5,7 +5,8 @@ import { CustomizationHeaderPreset, CustomizationSearchStyle } from '@gitbook/ap
import { AdaptiveVisitorContextProvider } from '../Adaptive';
import { AIChatProvider } from '../AI';
import type { RenderAIMessageOptions } from '../AI';
import { AIChat, AskAITextSelection } from '../AIChat';
import { AIChat } from '../AIChat/AIChat';
import { AskAITextSelection } from '../AIChat/AskAITextSelection';
import { Announcement } from '../Announcement';
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
import { CurrentContentProvider } from '../hooks';
+1 -1
View File
@@ -1,7 +1,7 @@
import type { RevisionTag } from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
import { Emoji } from '@/components/primitives';
import { Emoji } from '@/components/primitives/Emoji';
import { backgroundColorToStyle, textColorToStyle } from '@/lib/colors';
import { tcls } from '@/lib/tailwind';
@@ -6,7 +6,6 @@ export * from './Skeleton';
export * from './Link';
export * from './StyledLink';
export * from './DateRelative';
export * from './Emoji';
export * from './LoadingPane';
export * from './Tooltip';
export * from './Popover';
+1 -1
View File
@@ -10,7 +10,7 @@ import type {
JSONDocument,
} from '@gitbook/api';
import { Emoji } from '@/components/primitives';
import { Emoji } from '@/components/primitives/Emoji';
export interface DocumentSection {
id: string;
+26 -11
View File
@@ -1,23 +1,38 @@
import { describe, expect, it } from 'bun:test';
import { emojiSequences } from '@gitbook/emoji-codepoints';
import { getEmojiForCode } from './emojis';
describe('getEmojiForCode', () => {
it('should return the emoji character for the given emoji code', () => {
it('resolves every base sequence to its fully-qualified emoji', () => {
for (const sequence of emojiSequences.split('\n')) {
const base = sequence
.split('-')
.filter((part) => part !== 'fe0f' && part !== '200d')
.join('-');
const expected = String.fromCodePoint(
...sequence.split('-').map((part) => Number.parseInt(part, 16))
);
expect(getEmojiForCode(base)).toBe(expected);
}
});
it('passes through codes that need no qualification', () => {
expect(getEmojiForCode('1f600')).toBe('😀');
expect(getEmojiForCode('1F600')).toBe('😀');
});
it('should handle complex codes', () => {
expect(getEmojiForCode('1f935-2642')).toEqual('🤵‍♂️');
expect(getEmojiForCode('1f3c3-2642')).toEqual('🏃‍♂️');
expect(getEmojiForCode('1f468-2696')).toEqual('👨‍⚖️');
it('handles empty and invalid codes', () => {
expect(getEmojiForCode('')).toBe('');
expect(getEmojiForCode('not-a-code')).toBe('');
});
it('should return an empty string if invalid', () => {
expect(getEmojiForCode('invalid')).toEqual('');
});
it('should handle one with uppercase chars', () => {
expect(getEmojiForCode('1f4A5')).toEqual('💥');
it('never resolves Object prototype keys through the index', () => {
// `constructor` parses as the hex number 0xc, so it falls through to the raw-codepoint
// path exactly as before — what matters is that it resolves no emoji.
expect(getEmojiForCode('constructor')).toBe('\f');
expect(getEmojiForCode('prototype')).toBe('');
expect(getEmojiForCode('toString')).toBe('');
});
});
+25 -4
View File
@@ -1,4 +1,27 @@
import { emojiCodepoints } from '@gitbook/emoji-codepoints';
import { emojiSequences } from '@gitbook/emoji-codepoints';
/**
* Base form of a codepoint sequence: the qualified one without its variation selectors and joiners.
*/
function getBaseSequence(sequence: string): string {
return sequence
.split('-')
.filter((part) => part !== 'fe0f' && part !== '200d')
.join('-');
}
let qualifiedByBase: Map<string, string> | null = null;
// The index is ~1700 entries; building it on first use keeps it off the critical path for the many
// pages that render no emoji at all.
function getQualifiedByBase(): Map<string, string> {
if (!qualifiedByBase) {
qualifiedByBase = new Map(
emojiSequences.split('\n').map((sequence) => [getBaseSequence(sequence), sequence])
);
}
return qualifiedByBase;
}
/**
* Returns the emoji character for the given emoji code.
@@ -10,9 +33,7 @@ export function getEmojiForCode(code: string): string {
code = code.toLowerCase();
const fullCode =
// use hasOwn to prevent codes like "constructor" or "prototype" from being resolved to the emoji codepoints object prototype
(Object.hasOwn(emojiCodepoints, code) ? emojiCodepoints[code] : undefined) ?? code;
const fullCode = getQualifiedByBase().get(code) ?? code;
const codePoints = fullCode.split('-').map((elt) => Number.parseInt(elt, 16));
try {