diff --git a/.changeset/rare-geckos-bathe.md b/.changeset/rare-geckos-bathe.md new file mode 100644 index 00000000..1af5db76 --- /dev/null +++ b/.changeset/rare-geckos-bathe.md @@ -0,0 +1,5 @@ +--- +"react-shiki": patch +--- + +Improve throttling with useThrottledDebounce hook diff --git a/package/src/lib/hook.ts b/package/src/lib/hook.ts index 393d010c..ada92581 100644 --- a/package/src/lib/hook.ts +++ b/package/src/lib/hook.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import type { Highlighter, @@ -13,13 +13,12 @@ import type { Language, Theme, HighlighterOptions, - TimeoutState, Themes, OutputFormat, OutputFormatMap, } from './types'; -import { throttleHighlighting, useStableOptions } from './utils'; +import { useStableOptions, useThrottledDebounce } from './utils'; import { resolveLanguage } from './language'; import { resolveTheme } from './theme'; import { buildShikiOptions } from './options'; @@ -51,6 +50,9 @@ export const useShikiHighlighter = ( const stableTheme = useStableOptions(themeInput); const stableOpts = useStableOptions(options); + // Throttle code changes when delay is specified + const throttledCode = useThrottledDebounce(code, stableOpts.delay); + const { languageId, langsToLoad } = useMemo( () => resolveLanguage( @@ -66,14 +68,8 @@ export const useShikiHighlighter = ( [stableTheme] ); - const timeoutControl = useRef({ - nextAllowedTime: 0, - timeoutId: undefined, - }); - const shikiOptions = useMemo( () => buildShikiOptions(languageId, themeResult, stableOpts), - // Stable references ensure recompute when content changes [languageId, themeResult, stableLang, stableTheme, stableOpts] ); @@ -102,7 +98,7 @@ export const useShikiHighlighter = ( const result = transformOutput( format, highlighter, - code, + throttledCode, finalOptions, themeResult.isMultiTheme ); @@ -110,22 +106,14 @@ export const useShikiHighlighter = ( } }; - const { delay } = stableOpts; - - if (delay) { - throttleHighlighting(highlight, timeoutControl, delay); - } else { - highlight().catch(console.error); - } + highlight().catch(console.error); return () => { isMounted = false; - clearTimeout(timeoutControl.current.timeoutId); }; }, [ - code, + throttledCode, shikiOptions, - stableOpts.delay, stableOpts.highlighter, stableOpts.outputFormat, langsToLoad, diff --git a/package/src/lib/types.ts b/package/src/lib/types.ts index 46eb917b..6b2ad0b1 100644 --- a/package/src/lib/types.ts +++ b/package/src/lib/types.ts @@ -140,11 +140,6 @@ interface HighlighterOptions 'langAlias' | 'engine' > {} -interface TimeoutState { - timeoutId: NodeJS.Timeout | undefined; - nextAllowedTime: number; -} - /** * Public API signature for the useShikiHighlighter hook. * Generic parameter narrows return type based on outputFormat option. @@ -174,7 +169,6 @@ export type { Theme, Themes, Element, - TimeoutState, HighlighterOptions, OutputFormat, OutputFormatMap, diff --git a/package/src/lib/utils.ts b/package/src/lib/utils.ts index 8bc3482a..803da97c 100644 --- a/package/src/lib/utils.ts +++ b/package/src/lib/utils.ts @@ -1,8 +1,6 @@ -import { useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { dequal } from 'dequal'; -import type { TimeoutState } from './types'; - /** * Returns a stable reference that only changes when content changes (deep equality). * Prevents unnecessary re-renders when objects are recreated with identical content. @@ -24,17 +22,51 @@ export const useStableOptions = (value: T): T => { return ref.current; }; -export const throttleHighlighting = ( - performHighlight: () => Promise, - timeoutControl: React.RefObject, - throttleMs: number -) => { - const now = Date.now(); - clearTimeout(timeoutControl.current.timeoutId); - - const delay = Math.max(0, timeoutControl.current.nextAllowedTime - now); - timeoutControl.current.timeoutId = setTimeout(() => { - performHighlight().catch(console.error); - timeoutControl.current.nextAllowedTime = now + throttleMs; - }, delay); +/** + * Hybrid throttle+debounce hook for rate-limiting value updates. + * - If throttle window has passed: updates immediately + * - Otherwise: debounces to catch final state + */ +export const useThrottledDebounce = ( + value: T, + throttleMs: number | undefined, + debounceMs = 50 +): T => { + const [processedValue, setProcessedValue] = useState(value); + const lastRunTime = useRef(0); + const timeoutRef = useRef | undefined>( + undefined + ); + + useEffect(() => { + if (!throttleMs) { + setProcessedValue(value); + return; + } + + const now = Date.now(); + const timeSinceLastRun = now - lastRunTime.current; + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + if (timeSinceLastRun >= throttleMs) { + setProcessedValue(value); + lastRunTime.current = now; + } else { + timeoutRef.current = setTimeout(() => { + setProcessedValue(value); + lastRunTime.current = Date.now(); + }, debounceMs); + } + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [value, throttleMs, debounceMs]); + + return processedValue; }; diff --git a/package/tests/hook.test.tsx b/package/tests/hook.test.tsx index 0fc720a7..8224247e 100644 --- a/package/tests/hook.test.tsx +++ b/package/tests/hook.test.tsx @@ -14,7 +14,7 @@ import type { TokensResult, } from '../src/lib/types'; import type { ShikiTransformer } from 'shiki'; -import { throttleHighlighting, useStableOptions } from '../src/lib/utils'; +import { useThrottledDebounce, useStableOptions } from '../src/lib/utils'; interface TestComponentProps { code: string; @@ -897,57 +897,50 @@ describe('useShikiHighlighter Hook', () => { }); describe('Throttling', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); + test('useThrottledDebounce returns initial value', () => { + const { result } = renderHook(() => + useThrottledDebounce('initial', 500) + ); + expect(result.current).toBe('initial'); }); - test('throttles highlighting function calls based on timing', () => { - // Mock date to have a consistent starting point - const originalDateNow = Date.now; - const mockTime = 1000; - Date.now = vi.fn(() => mockTime); - - // Mock the perform highlight function - const performHighlight = vi.fn().mockResolvedValue(undefined); + test('useThrottledDebounce with no throttle returns value immediately', async () => { + const { result, rerender } = renderHook( + ({ value }) => useThrottledDebounce(value, undefined), + { initialProps: { value: 'initial' } } + ); - // Setup timeout control like in the hook - const timeoutControl = { - current: { - timeoutId: undefined, - nextAllowedTime: 0, - }, - }; + expect(result.current).toBe('initial'); - // First call should schedule immediately since nextAllowedTime is in the past - throttleHighlighting(performHighlight, timeoutControl, 500); - expect(timeoutControl.current.timeoutId).toBeDefined(); + rerender({ value: 'updated' }); - // Run the timeout - vi.runAllTimers(); - expect(performHighlight).toHaveBeenCalledTimes(1); - expect(timeoutControl.current.nextAllowedTime).toBe(1500); // 1000 + 500 + await waitFor(() => { + expect(result.current).toBe('updated'); + }); + }); - // Reset the mock - performHighlight.mockClear(); + test('delay option throttles code updates', async () => { + const code1 = 'const a = 1;'; + const code2 = 'const b = 2;'; - // Call again - should be delayed by the throttle duration - throttleHighlighting(performHighlight, timeoutControl, 500); - expect(performHighlight).not.toHaveBeenCalled(); // Not called yet + const { getByTestId, rerender } = render( + + ); - // Advance halfway through the delay - should still not be called - vi.advanceTimersByTime(250); - expect(performHighlight).not.toHaveBeenCalled(); + // Wait for initial render + await waitFor(() => { + expect(getByTestId('highlighted')).toBeInTheDocument(); + }); - // Advance the full delay - vi.advanceTimersByTime(250); - expect(performHighlight).toHaveBeenCalledTimes(1); + // Change code - should be throttled + rerender( + + ); - // Restore original Date.now - Date.now = originalDateNow; + // Eventually should show new code + await waitFor(() => { + expect(getByTestId('highlighted').textContent).toContain('b'); + }); }); });