diff --git a/package/README.md b/package/README.md index ce01f160..96982a5f 100644 --- a/package/README.md +++ b/package/README.md @@ -32,6 +32,7 @@ A performant client-side syntax highlighting component and hook for React, built - [Integration with react-markdown](#integration-with-react-markdown) - [Handling Inline Code](#handling-inline-code) - [Performance](#performance) + - [Deferred Rendering](#deferred-rendering) - [Throttling Real-time Highlighting](#throttling-real-time-highlighting) - [Output Format Optimization](#output-format-optimization) - [Streaming and LLM Chat UI](#streaming-and-llm-chat-ui) @@ -229,15 +230,16 @@ See [Shiki - RegExp Engines](https://shiki.style/guide/regex-engines) for more i The `ShikiHighlighter` component offers minimal built-in styling and customization options out-of-the-box: -| Prop | Type | Default | Description | -| ------------------ | --------- | ------- | ---------------------------------------------------------- | -| `showLanguage` | `boolean` | `true` | Displays language label in top-right corner | -| `addDefaultStyles` | `boolean` | `true` | Adds minimal default styling to the highlighted code block | -| `as` | `string` | `'pre'` | Component's Root HTML element | -| `className` | `string` | - | Custom class name for the code block | -| `langClassName` | `string` | - | Class name for styling the language label | -| `style` | `object` | - | Inline style object for the code block | -| `langStyle` | `object` | - | Inline style object for the language label | +| Prop | Type | Default | Description | +| ------------------ | -------------------- | ------- | ---------------------------------------------------------- | +| `showLanguage` | `boolean` | `true` | Displays language label in top-right corner | +| `addDefaultStyles` | `boolean` | `true` | Adds minimal default styling to the highlighted code block | +| `as` | `string` | `'pre'` | Component's Root HTML element | +| `className` | `string` | - | Custom class name for the code block | +| `langClassName` | `string` | - | Class name for styling the language label | +| `style` | `object` | - | Inline style object for the code block | +| `langStyle` | `object` | - | Inline style object for the language label | +| `deferRender` | `boolean \| object` | `false` | Defer rendering until element enters viewport | ### Multi-theme Support @@ -605,6 +607,66 @@ const CodeHighlight = ({ ## Performance +### Deferred Rendering + +For pages with many code blocks, defer syntax highlighting until blocks enter the viewport: + +```tsx +// Enable with defaults (300px root margin, 300ms debounce) + + {code} + + +// With custom options + + {code} + +``` + +This uses Intersection Observer + debounce + `requestIdleCallback` for optimal performance. + +#### Using with the Hook + +The `deferRender` prop is component-only. When using `useShikiHighlighter`, use the exported `useDeferredRender` hook directly: + +```tsx +import { useShikiHighlighter, useDeferredRender } from "react-shiki"; + +function DeferredCodeBlock({ code, language }) { + const { shouldRender, containerRef } = useDeferredRender(); + + const highlighted = useShikiHighlighter( + shouldRender ? code : '', + language, + "github-dark" + ); + + return ( +
+      {shouldRender ? highlighted : null}
+    
+ ); +} +``` + +With custom options: + +```tsx +const { shouldRender, containerRef } = useDeferredRender({ + rootMargin: '500px', + debounceDelay: 200, + idleTimeout: 300 +}); +``` + ### Throttling Real-time Highlighting For improved performance when highlighting frequently changing code: diff --git a/package/src/core.ts b/package/src/core.ts index f5cfb7bb..6023b5b1 100644 --- a/package/src/core.ts +++ b/package/src/core.ts @@ -11,6 +11,10 @@ import type { } from './lib/types'; export { isInlineCode, rehypeInlineCodeProperty } from './lib/plugins'; +export { + useDeferredRender, + type UseDeferredRenderOptions, +} from './lib/hooks/use-deferred-render'; import { createShikiHighlighterComponent, diff --git a/package/src/index.ts b/package/src/index.ts index d8d3d30d..288a04af 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -11,6 +11,10 @@ import type { } from './lib/types'; export { isInlineCode, rehypeInlineCodeProperty } from './lib/plugins'; +export { + useDeferredRender, + type UseDeferredRenderOptions, +} from './lib/hooks/use-deferred-render'; import { createShikiHighlighterComponent, diff --git a/package/src/lib/component.tsx b/package/src/lib/component.tsx index 503f8492..c72b04b7 100644 --- a/package/src/lib/component.tsx +++ b/package/src/lib/component.tsx @@ -1,6 +1,10 @@ import './styles.css'; import { clsx } from 'clsx'; import { resolveLanguage } from './language'; +import { + useDeferredRender, + type UseDeferredRenderOptions, +} from './hooks/use-deferred-render'; import type { HighlighterOptions, @@ -10,7 +14,7 @@ import type { UseShikiHighlighter, } from './types'; import type { ReactNode } from 'react'; -import { forwardRef } from 'react'; +import { forwardRef, useImperativeHandle } from 'react'; // 'tokens' not included: returns raw data, use hook directly for custom rendering type ComponentRenderableFormat = 'react' | 'html'; @@ -104,6 +108,14 @@ export interface ShikiHighlighterProps * @default 'pre' */ as?: React.ElementType; + + /** + * Defer rendering until element enters viewport. + * Pass `true` for defaults or an options object. + * Uses Intersection Observer + requestIdleCallback for optimal performance. + * @default false + */ + deferRender?: boolean | UseDeferredRenderOptions; } export const createShikiHighlighterComponent = ( @@ -130,10 +142,22 @@ export const createShikiHighlighterComponent = ( children: code, as: Element = 'pre', customLanguages, + deferRender = false, ...shikiOptions }, ref ) => { + const deferOptions: UseDeferredRenderOptions = + deferRender === true + ? { immediate: false } + : deferRender === false + ? { immediate: true } + : deferRender; + + const { shouldRender, containerRef } = + useDeferredRender(deferOptions); + useImperativeHandle(ref, () => containerRef.current as HTMLElement); + const options: HighlighterOptions = { delay, transformers, @@ -152,7 +176,7 @@ export const createShikiHighlighterComponent = ( ); const highlightedCode = useShikiHighlighterImpl( - code, + shouldRender ? code : '', language, theme, options @@ -162,7 +186,7 @@ export const createShikiHighlighterComponent = ( return ( ) : null} - {isHtmlOutput ? ( -
- ) : ( - highlightedCode - )} + {shouldRender ? ( + isHtmlOutput ? ( +
+ ) : ( + highlightedCode + ) + ) : null} ); } diff --git a/package/src/lib/hooks/use-deferred-render.ts b/package/src/lib/hooks/use-deferred-render.ts new file mode 100644 index 00000000..09d22242 --- /dev/null +++ b/package/src/lib/hooks/use-deferred-render.ts @@ -0,0 +1,230 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +/** + * Default debounce delay in milliseconds before checking if element is still in view + */ +export const DEFERRED_RENDER_DEBOUNCE_DELAY = 300; + +/** + * Default root margin for Intersection Observer + * Starts rendering when element is 300px away from viewport + */ +export const DEFERRED_RENDER_ROOT_MARGIN = '300px'; + +/** + * Default timeout for requestIdleCallback in milliseconds + */ +export const DEFERRED_RENDER_IDLE_TIMEOUT = 500; + +export type UseDeferredRenderOptions = { + /** + * If true, render immediately without waiting for intersection + * @default false + */ + immediate?: boolean; + /** + * Debounce delay in milliseconds before checking if still in view + * @default DEFERRED_RENDER_DEBOUNCE_DELAY + */ + debounceDelay?: number; + /** + * Root margin for Intersection Observer (e.g., '200px' to start rendering 200px before entering viewport) + * @default DEFERRED_RENDER_ROOT_MARGIN + */ + rootMargin?: string; + /** + * Timeout for requestIdleCallback in milliseconds + * @default DEFERRED_RENDER_IDLE_TIMEOUT + */ + idleTimeout?: number; +}; + +/** + * Hook for deferred rendering components when they enter the viewport. + * Uses Intersection Observer + debounce + requestIdleCallback for optimal performance. + * + * @param options Configuration options + * @returns Object containing `shouldRender` flag and `containerRef` to attach to the element + * + * @example + * ```tsx + * const { shouldRender, containerRef } = useDeferredRender({ immediate: false }) + * + * return ( + *
+ * {shouldRender && } + *
+ * ) + * ``` + */ +export function useDeferredRender( + options: UseDeferredRenderOptions = {} +) { + const { + immediate = false, + debounceDelay = DEFERRED_RENDER_DEBOUNCE_DELAY, + rootMargin = DEFERRED_RENDER_ROOT_MARGIN, + idleTimeout = DEFERRED_RENDER_IDLE_TIMEOUT, + } = options; + + const [shouldRender, setShouldRender] = useState(false); + const containerRef = useRef(null); + const renderTimeoutRef = useRef(null); + const idleCallbackRef = useRef(null); + + // Polyfill for requestIdleCallback + const requestIdleCallbackPolyfill = useMemo( + () => + (callback: IdleRequestCallback): number => { + const start = Date.now(); + return window.setTimeout(() => { + callback({ + didTimeout: false, + timeRemaining: () => Math.max(0, 50 - (Date.now() - start)), + }); + }, 1); + }, + [] + ); + + const requestIdleCallbackWrapper = useMemo( + () => + typeof window !== 'undefined' && window.requestIdleCallback + ? (cb: IdleRequestCallback, opts?: IdleRequestOptions) => + window.requestIdleCallback(cb, opts) + : requestIdleCallbackPolyfill, + [requestIdleCallbackPolyfill] + ); + + const cancelIdleCallbackWrapper = useMemo( + () => + typeof window !== 'undefined' && window.cancelIdleCallback + ? (id: number) => window.cancelIdleCallback(id) + : (id: number) => { + clearTimeout(id); + }, + [] + ); + + useEffect(() => { + // If immediate, render right away + if (immediate) { + setShouldRender(true); + return; + } + + const container = containerRef.current; + if (!container) { + return; + } + + // Clear any pending timeout and idle callback + if (renderTimeoutRef.current) { + clearTimeout(renderTimeoutRef.current); + renderTimeoutRef.current = null; + } + if (idleCallbackRef.current) { + cancelIdleCallbackWrapper(idleCallbackRef.current); + idleCallbackRef.current = null; + } + + const clearPendingRenders = () => { + if (renderTimeoutRef.current) { + clearTimeout(renderTimeoutRef.current); + renderTimeoutRef.current = null; + } + if (idleCallbackRef.current) { + cancelIdleCallbackWrapper(idleCallbackRef.current); + idleCallbackRef.current = null; + } + }; + + const scheduleRender = (obs: IntersectionObserver) => { + idleCallbackRef.current = requestIdleCallbackWrapper( + (deadline) => { + // If we have time remaining or it's urgent, render + if (deadline.timeRemaining() > 0 || deadline.didTimeout) { + setShouldRender(true); + obs.disconnect(); + } else { + // Otherwise, schedule again with shorter timeout + idleCallbackRef.current = requestIdleCallbackWrapper( + () => { + setShouldRender(true); + obs.disconnect(); + }, + { timeout: idleTimeout / 2 } + ); + } + }, + { timeout: idleTimeout } + ); + }; + + const handleIntersecting = (obs: IntersectionObserver) => { + clearPendingRenders(); + + // Debounce rendering: wait for debounceDelay, then check if still in view + renderTimeoutRef.current = window.setTimeout(() => { + // Re-check if element is still in viewport using observer records + const records = obs.takeRecords(); + // If no records, element hasn't changed state (still intersecting) + // If records exist, check the latest intersection state + const isStillInView = + records.length === 0 || + (records.at(-1)?.isIntersecting ?? false); + + if (isStillInView) { + scheduleRender(obs); + } + }, debounceDelay); + }; + + const handleIntersection = ( + entry: IntersectionObserverEntry, + obs: IntersectionObserver + ) => { + if (entry.isIntersecting) { + handleIntersecting(obs); + } else { + clearPendingRenders(); + } + }; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + handleIntersection(entry, observer); + } + }, + { + rootMargin, + threshold: 0, + } + ); + + observer.observe(container); + + return () => { + if (renderTimeoutRef.current) { + clearTimeout(renderTimeoutRef.current); + } + if (idleCallbackRef.current) { + cancelIdleCallbackWrapper(idleCallbackRef.current); + } + observer.disconnect(); + }; + }, [ + immediate, + debounceDelay, + rootMargin, + idleTimeout, + cancelIdleCallbackWrapper, + requestIdleCallbackWrapper, + ]); + + return { + shouldRender, + containerRef, + }; +} diff --git a/package/src/web.ts b/package/src/web.ts index abe55dbb..fbb83d13 100644 --- a/package/src/web.ts +++ b/package/src/web.ts @@ -11,6 +11,10 @@ import type { } from './lib/types'; export { isInlineCode, rehypeInlineCodeProperty } from './lib/plugins'; +export { + useDeferredRender, + type UseDeferredRenderOptions, +} from './lib/hooks/use-deferred-render'; import { createShikiHighlighterComponent, diff --git a/package/tests/hook.test.tsx b/package/tests/hook.test.tsx index 13455ecd..f5e2bc81 100644 --- a/package/tests/hook.test.tsx +++ b/package/tests/hook.test.tsx @@ -592,7 +592,12 @@ describe('useShikiHighlighter Hook', () => { const code2 = 'const b = 2;'; const { getByTestId, rerender } = render( - + ); // Wait for initial render @@ -602,7 +607,12 @@ describe('useShikiHighlighter Hook', () => { // Change code - should be throttled rerender( - + ); // Eventually should show new code