Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rare-geckos-bathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-shiki": patch
---

Improve throttling with useThrottledDebounce hook
28 changes: 8 additions & 20 deletions package/src/lib/hook.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';

import type {
Highlighter,
Expand All @@ -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';
Expand Down Expand Up @@ -51,6 +50,9 @@ export const useShikiHighlighter = <F extends OutputFormat = 'react'>(
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(
Expand All @@ -66,14 +68,8 @@ export const useShikiHighlighter = <F extends OutputFormat = 'react'>(
[stableTheme]
);

const timeoutControl = useRef<TimeoutState>({
nextAllowedTime: 0,
timeoutId: undefined,
});

const shikiOptions = useMemo(
() => buildShikiOptions(languageId, themeResult, stableOpts),
// Stable references ensure recompute when content changes
[languageId, themeResult, stableLang, stableTheme, stableOpts]
);

Expand Down Expand Up @@ -102,30 +98,22 @@ export const useShikiHighlighter = <F extends OutputFormat = 'react'>(
const result = transformOutput(
format,
highlighter,
code,
throttledCode,
finalOptions,
themeResult.isMultiTheme
);
setOutput(result);
}
};

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,
Expand Down
6 changes: 0 additions & 6 deletions package/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,6 @@ interface HighlighterOptions<F extends OutputFormat = 'react'>
'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.
Expand Down Expand Up @@ -174,7 +169,6 @@ export type {
Theme,
Themes,
Element,
TimeoutState,
HighlighterOptions,
OutputFormat,
OutputFormatMap,
Expand Down
64 changes: 48 additions & 16 deletions package/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -24,17 +22,51 @@ export const useStableOptions = <T>(value: T): T => {
return ref.current;
};

export const throttleHighlighting = (
performHighlight: () => Promise<void>,
timeoutControl: React.RefObject<TimeoutState>,
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 = <T>(
value: T,
throttleMs: number | undefined,
debounceMs = 50
): T => {
const [processedValue, setProcessedValue] = useState(value);
const lastRunTime = useRef(0);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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;
};
77 changes: 35 additions & 42 deletions package/tests/hook.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
<TestComponent code={code1} language="javascript" theme="github-dark" delay={100} />
);

// 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(
<TestComponent code={code2} language="javascript" theme="github-dark" delay={100} />
);

// Restore original Date.now
Date.now = originalDateNow;
// Eventually should show new code
await waitFor(() => {
expect(getByTestId('highlighted').textContent).toContain('b');
});
});
});

Expand Down