From dd5c41807e0879d6723b5e1dfcb7fca616ed0aa1 Mon Sep 17 00:00:00 2001 From: AVGVSTVS96 <122117267+AVGVSTVS96@users.noreply.github.com> Date: Fri, 5 Dec 2025 05:48:08 -0500 Subject: [PATCH 1/5] perf: add useDeferredRender for viewport-based lazy highlighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add deferRender prop to component that defers syntax highlighting until the code block enters the viewport. Inspired by streamdown's approach. Uses Intersection Observer + debounce + requestIdleCallback: - Starts observing 300px before viewport entry (configurable) - Debounces to ensure element is still in view - Renders during browser idle time for optimal performance API: - `deferRender={true}` - enable with defaults - `deferRender={{ rootMargin: '500px', debounceDelay: 200 }}` - custom options - Default is `false` (immediate rendering, no behavior change) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package/src/lib/component.tsx | 46 ++++++++--- package/src/lib/utils.ts | 148 +++++++++++++++++++++++++++++++++- 2 files changed, 181 insertions(+), 13 deletions(-) diff --git a/package/src/lib/component.tsx b/package/src/lib/component.tsx index 503f8492..6b3e546e 100644 --- a/package/src/lib/component.tsx +++ b/package/src/lib/component.tsx @@ -1,6 +1,7 @@ import './styles.css'; import { clsx } from 'clsx'; import { resolveLanguage } from './language'; +import { useDeferredRender } from './utils'; import type { HighlighterOptions, @@ -9,8 +10,9 @@ import type { Themes, UseShikiHighlighter, } from './types'; +import type { UseDeferredRenderOptions } from './utils'; import type { ReactNode } from 'react'; -import { forwardRef } from 'react'; +import { forwardRef, useRef, useImperativeHandle } from 'react'; // 'tokens' not included: returns raw data, use hook directly for custom rendering type ComponentRenderableFormat = 'react' | 'html'; @@ -104,6 +106,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 +140,23 @@ export const createShikiHighlighterComponent = ( children: code, as: Element = 'pre', customLanguages, + deferRender = false, ...shikiOptions }, ref ) => { + const containerRef = useRef(null); + useImperativeHandle(ref, () => containerRef.current as HTMLElement); + + const deferOptions: UseDeferredRenderOptions = + deferRender === true + ? { immediate: false } + : deferRender === false + ? { immediate: true } + : deferRender; + + const shouldRender = useDeferredRender(containerRef, deferOptions); + const options: HighlighterOptions = { delay, transformers, @@ -146,13 +169,10 @@ export const createShikiHighlighterComponent = ( ...shikiOptions, }; - const { displayLanguageId } = resolveLanguage( - language, - customLanguages - ); + const { displayLanguageId } = resolveLanguage(language, customLanguages); const highlightedCode = useShikiHighlighterImpl( - code, + shouldRender ? code : '', language, theme, options @@ -162,7 +182,7 @@ export const createShikiHighlighterComponent = ( return ( ) : null} - {isHtmlOutput ? ( -
- ) : ( - highlightedCode - )} + {shouldRender ? ( + isHtmlOutput ? ( +
+ ) : ( + highlightedCode + ) + ) : null} ); } diff --git a/package/src/lib/utils.ts b/package/src/lib/utils.ts index 803da97c..a06ec977 100644 --- a/package/src/lib/utils.ts +++ b/package/src/lib/utils.ts @@ -1,6 +1,8 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { dequal } from 'dequal'; +import type { RefObject } from 'react'; + /** * Returns a stable reference that only changes when content changes (deep equality). * Prevents unnecessary re-renders when objects are recreated with identical content. @@ -70,3 +72,147 @@ export const useThrottledDebounce = ( return processedValue; }; + +export type UseDeferredRenderOptions = { + /** + * If true, render immediately without waiting for intersection + * @default false + */ + immediate?: boolean; + /** + * Debounce delay in ms before checking if still in view + * @default 300 + */ + debounceDelay?: number; + /** + * Root margin for Intersection Observer + * @default '300px' + */ + rootMargin?: string; + /** + * Timeout for requestIdleCallback in ms + * @default 500 + */ + idleTimeout?: number; +}; + +/** + * Defers rendering until element enters viewport. + * Uses Intersection Observer + debounce + requestIdleCallback. + */ +export const useDeferredRender = ( + containerRef: RefObject, + options: UseDeferredRenderOptions = {} +): boolean => { + const { + immediate = false, + debounceDelay = 300, + rootMargin = '300px', + idleTimeout = 500, + } = options; + + const [shouldRender, setShouldRender] = useState(immediate); + const renderTimeoutRef = useRef | null>(null); + const idleCallbackRef = useRef(null); + + const requestIdleCallbackWrapper = useMemo( + () => + typeof window !== 'undefined' && window.requestIdleCallback + ? (cb: IdleRequestCallback, opts?: IdleRequestOptions) => + window.requestIdleCallback(cb, opts) + : (cb: IdleRequestCallback): number => { + const start = Date.now(); + return window.setTimeout(() => { + cb({ + didTimeout: false, + timeRemaining: () => Math.max(0, 50 - (Date.now() - start)), + }); + }, 1); + }, + [] + ); + + const cancelIdleCallbackWrapper = useMemo( + () => + typeof window !== 'undefined' && window.cancelIdleCallback + ? (id: number) => window.cancelIdleCallback(id) + : (id: number) => clearTimeout(id), + [] + ); + + useEffect(() => { + if (immediate || shouldRender) return; + + const container = containerRef.current; + if (!container) return; + + const clearPending = () => { + 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 (deadline.timeRemaining() > 0 || deadline.didTimeout) { + setShouldRender(true); + obs.disconnect(); + } else { + idleCallbackRef.current = requestIdleCallbackWrapper( + () => { + setShouldRender(true); + obs.disconnect(); + }, + { timeout: idleTimeout / 2 } + ); + } + }, + { timeout: idleTimeout } + ); + }; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + clearPending(); + renderTimeoutRef.current = setTimeout(() => { + const records = observer.takeRecords(); + const stillInView = + records.length === 0 || + (records.at(-1)?.isIntersecting ?? false); + if (stillInView) scheduleRender(observer); + }, debounceDelay); + } else { + clearPending(); + } + } + }, + { rootMargin, threshold: 0 } + ); + + observer.observe(container); + + return () => { + clearPending(); + observer.disconnect(); + }; + }, [ + immediate, + shouldRender, + containerRef, + debounceDelay, + rootMargin, + idleTimeout, + cancelIdleCallbackWrapper, + requestIdleCallbackWrapper, + ]); + + return shouldRender; +}; From 9eb5a3547fdec165674f8a7dabd89cbfd6fb2631 Mon Sep 17 00:00:00 2001 From: AVGVSTVS96 <122117267+AVGVSTVS96@users.noreply.github.com> Date: Fri, 5 Dec 2025 05:55:16 -0500 Subject: [PATCH 2/5] docs: add deferRender documentation to README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package/README.md | 46 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/package/README.md b/package/README.md index ce01f160..9e967897 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,32 @@ 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. Inspired by [streamdown's approach](https://github.com/vercel/streamdown). + ### Throttling Real-time Highlighting For improved performance when highlighting frequently changing code: From 497e1e20c379d03005c880e951379d02003811b9 Mon Sep 17 00:00:00 2001 From: AVGVSTVS96 <122117267+AVGVSTVS96@users.noreply.github.com> Date: Fri, 5 Dec 2025 05:58:20 -0500 Subject: [PATCH 3/5] refactor: move useDeferredRender to dedicated hooks directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create hooks/use-deferred-render.ts with full implementation - Use streamdown-style API returning { shouldRender, containerRef } - Remove from utils.ts - Clarify in docs that deferRender is component-only 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package/README.md | 4 +- package/src/lib/component.tsx | 14 +- package/src/lib/hooks/use-deferred-render.ts | 227 +++++++++++++++++++ package/src/lib/utils.ts | 148 +----------- 4 files changed, 238 insertions(+), 155 deletions(-) create mode 100644 package/src/lib/hooks/use-deferred-render.ts diff --git a/package/README.md b/package/README.md index 9e967897..a78906e1 100644 --- a/package/README.md +++ b/package/README.md @@ -631,7 +631,9 @@ For pages with many code blocks, defer syntax highlighting until blocks enter th ``` -This uses Intersection Observer + debounce + `requestIdleCallback` for optimal performance. Inspired by [streamdown's approach](https://github.com/vercel/streamdown). +This uses Intersection Observer + debounce + `requestIdleCallback` for optimal performance. + +> **Note**: `deferRender` is only available on the `ShikiHighlighter` component. For the hook, use the exported `useDeferredRender` hook directly. ### Throttling Real-time Highlighting diff --git a/package/src/lib/component.tsx b/package/src/lib/component.tsx index 6b3e546e..a4e2837c 100644 --- a/package/src/lib/component.tsx +++ b/package/src/lib/component.tsx @@ -1,7 +1,10 @@ import './styles.css'; import { clsx } from 'clsx'; import { resolveLanguage } from './language'; -import { useDeferredRender } from './utils'; +import { + useDeferredRender, + type UseDeferredRenderOptions, +} from './hooks/use-deferred-render'; import type { HighlighterOptions, @@ -10,9 +13,8 @@ import type { Themes, UseShikiHighlighter, } from './types'; -import type { UseDeferredRenderOptions } from './utils'; import type { ReactNode } from 'react'; -import { forwardRef, useRef, useImperativeHandle } from 'react'; +import { forwardRef, useImperativeHandle } from 'react'; // 'tokens' not included: returns raw data, use hook directly for custom rendering type ComponentRenderableFormat = 'react' | 'html'; @@ -145,9 +147,6 @@ export const createShikiHighlighterComponent = ( }, ref ) => { - const containerRef = useRef(null); - useImperativeHandle(ref, () => containerRef.current as HTMLElement); - const deferOptions: UseDeferredRenderOptions = deferRender === true ? { immediate: false } @@ -155,7 +154,8 @@ export const createShikiHighlighterComponent = ( ? { immediate: true } : deferRender; - const shouldRender = useDeferredRender(containerRef, deferOptions); + const { shouldRender, containerRef } = useDeferredRender(deferOptions); + useImperativeHandle(ref, () => containerRef.current as HTMLElement); const options: HighlighterOptions = { delay, 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..bc832727 --- /dev/null +++ b/package/src/lib/hooks/use-deferred-render.ts @@ -0,0 +1,227 @@ +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/lib/utils.ts b/package/src/lib/utils.ts index a06ec977..803da97c 100644 --- a/package/src/lib/utils.ts +++ b/package/src/lib/utils.ts @@ -1,8 +1,6 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { dequal } from 'dequal'; -import type { RefObject } from 'react'; - /** * Returns a stable reference that only changes when content changes (deep equality). * Prevents unnecessary re-renders when objects are recreated with identical content. @@ -72,147 +70,3 @@ export const useThrottledDebounce = ( return processedValue; }; - -export type UseDeferredRenderOptions = { - /** - * If true, render immediately without waiting for intersection - * @default false - */ - immediate?: boolean; - /** - * Debounce delay in ms before checking if still in view - * @default 300 - */ - debounceDelay?: number; - /** - * Root margin for Intersection Observer - * @default '300px' - */ - rootMargin?: string; - /** - * Timeout for requestIdleCallback in ms - * @default 500 - */ - idleTimeout?: number; -}; - -/** - * Defers rendering until element enters viewport. - * Uses Intersection Observer + debounce + requestIdleCallback. - */ -export const useDeferredRender = ( - containerRef: RefObject, - options: UseDeferredRenderOptions = {} -): boolean => { - const { - immediate = false, - debounceDelay = 300, - rootMargin = '300px', - idleTimeout = 500, - } = options; - - const [shouldRender, setShouldRender] = useState(immediate); - const renderTimeoutRef = useRef | null>(null); - const idleCallbackRef = useRef(null); - - const requestIdleCallbackWrapper = useMemo( - () => - typeof window !== 'undefined' && window.requestIdleCallback - ? (cb: IdleRequestCallback, opts?: IdleRequestOptions) => - window.requestIdleCallback(cb, opts) - : (cb: IdleRequestCallback): number => { - const start = Date.now(); - return window.setTimeout(() => { - cb({ - didTimeout: false, - timeRemaining: () => Math.max(0, 50 - (Date.now() - start)), - }); - }, 1); - }, - [] - ); - - const cancelIdleCallbackWrapper = useMemo( - () => - typeof window !== 'undefined' && window.cancelIdleCallback - ? (id: number) => window.cancelIdleCallback(id) - : (id: number) => clearTimeout(id), - [] - ); - - useEffect(() => { - if (immediate || shouldRender) return; - - const container = containerRef.current; - if (!container) return; - - const clearPending = () => { - 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 (deadline.timeRemaining() > 0 || deadline.didTimeout) { - setShouldRender(true); - obs.disconnect(); - } else { - idleCallbackRef.current = requestIdleCallbackWrapper( - () => { - setShouldRender(true); - obs.disconnect(); - }, - { timeout: idleTimeout / 2 } - ); - } - }, - { timeout: idleTimeout } - ); - }; - - const observer = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - if (entry.isIntersecting) { - clearPending(); - renderTimeoutRef.current = setTimeout(() => { - const records = observer.takeRecords(); - const stillInView = - records.length === 0 || - (records.at(-1)?.isIntersecting ?? false); - if (stillInView) scheduleRender(observer); - }, debounceDelay); - } else { - clearPending(); - } - } - }, - { rootMargin, threshold: 0 } - ); - - observer.observe(container); - - return () => { - clearPending(); - observer.disconnect(); - }; - }, [ - immediate, - shouldRender, - containerRef, - debounceDelay, - rootMargin, - idleTimeout, - cancelIdleCallbackWrapper, - requestIdleCallbackWrapper, - ]); - - return shouldRender; -}; From e95b0cf92fd4f99074e4a6d58fcb9adfbea137de Mon Sep 17 00:00:00 2001 From: AVGVSTVS96 <122117267+AVGVSTVS96@users.noreply.github.com> Date: Fri, 5 Dec 2025 06:04:28 -0500 Subject: [PATCH 4/5] docs: export useDeferredRender and document hook usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export useDeferredRender from all entry points (index, web, core) - Add README section showing how to use useDeferredRender with the hook - Include example with custom options 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package/README.md | 34 +++++++++++++++++++++++++++++++++- package/src/core.ts | 4 ++++ package/src/index.ts | 4 ++++ package/src/web.ts | 4 ++++ 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/package/README.md b/package/README.md index a78906e1..96982a5f 100644 --- a/package/README.md +++ b/package/README.md @@ -633,7 +633,39 @@ For pages with many code blocks, defer syntax highlighting until blocks enter th This uses Intersection Observer + debounce + `requestIdleCallback` for optimal performance. -> **Note**: `deferRender` is only available on the `ShikiHighlighter` component. For the hook, use the exported `useDeferredRender` hook directly. +#### 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 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/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, From b4d37c7b5d5fc1742cb1199199016b2df2715883 Mon Sep 17 00:00:00 2001 From: AVGVSTVS96 <122117267+AVGVSTVS96@users.noreply.github.com> Date: Fri, 5 Dec 2025 06:09:08 -0500 Subject: [PATCH 5/5] style: fix formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package/src/lib/component.tsx | 12 +++++++++--- package/src/lib/hooks/use-deferred-render.ts | 7 +++++-- package/tests/hook.test.tsx | 14 ++++++++++++-- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/package/src/lib/component.tsx b/package/src/lib/component.tsx index a4e2837c..c72b04b7 100644 --- a/package/src/lib/component.tsx +++ b/package/src/lib/component.tsx @@ -154,7 +154,8 @@ export const createShikiHighlighterComponent = ( ? { immediate: true } : deferRender; - const { shouldRender, containerRef } = useDeferredRender(deferOptions); + const { shouldRender, containerRef } = + useDeferredRender(deferOptions); useImperativeHandle(ref, () => containerRef.current as HTMLElement); const options: HighlighterOptions = { @@ -169,7 +170,10 @@ export const createShikiHighlighterComponent = ( ...shikiOptions, }; - const { displayLanguageId } = resolveLanguage(language, customLanguages); + const { displayLanguageId } = resolveLanguage( + language, + customLanguages + ); const highlightedCode = useShikiHighlighterImpl( shouldRender ? code : '', @@ -204,7 +208,9 @@ export const createShikiHighlighterComponent = ( ) : null} {shouldRender ? ( isHtmlOutput ? ( -
+
) : ( highlightedCode ) diff --git a/package/src/lib/hooks/use-deferred-render.ts b/package/src/lib/hooks/use-deferred-render.ts index bc832727..09d22242 100644 --- a/package/src/lib/hooks/use-deferred-render.ts +++ b/package/src/lib/hooks/use-deferred-render.ts @@ -57,7 +57,9 @@ export type UseDeferredRenderOptions = { * ) * ``` */ -export function useDeferredRender(options: UseDeferredRenderOptions = {}) { +export function useDeferredRender( + options: UseDeferredRenderOptions = {} +) { const { immediate = false, debounceDelay = DEFERRED_RENDER_DEBOUNCE_DELAY, @@ -169,7 +171,8 @@ export function useDeferredRender(options: UseDeferredRenderOptions = {}) { // 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); + records.length === 0 || + (records.at(-1)?.isIntersecting ?? false); if (isStillInView) { scheduleRender(obs); 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