Skip to content

Commit 90c2c27

Browse files
AVGVSTVS96claude
andcommitted
refactor: replace throttleHighlighting with useThrottledDebounce hook
Port hybrid throttle+debounce pattern from streamdown: - Immediate update when throttle window has passed - Debounce for rapid changes within throttle window - Value-based hook instead of imperative callback pattern - Remove TimeoutState type (no longer needed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ecd57a5 commit 90c2c27

File tree

4 files changed

+91
-84
lines changed

4 files changed

+91
-84
lines changed

package/src/lib/hook.ts

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo, useRef, useState } from 'react';
1+
import { useEffect, useMemo, useState } from 'react';
22

33
import type {
44
Highlighter,
@@ -13,13 +13,12 @@ import type {
1313
Language,
1414
Theme,
1515
HighlighterOptions,
16-
TimeoutState,
1716
Themes,
1817
OutputFormat,
1918
OutputFormatMap,
2019
} from './types';
2120

22-
import { throttleHighlighting, useStableOptions } from './utils';
21+
import { useStableOptions, useThrottledDebounce } from './utils';
2322
import { resolveLanguage } from './language';
2423
import { resolveTheme } from './theme';
2524
import { buildShikiOptions } from './options';
@@ -51,6 +50,9 @@ export const useShikiHighlighter = <F extends OutputFormat = 'react'>(
5150
const stableTheme = useStableOptions(themeInput);
5251
const stableOpts = useStableOptions(options);
5352

53+
// Throttle code changes when delay is specified
54+
const throttledCode = useThrottledDebounce(code, stableOpts.delay);
55+
5456
const { languageId, langsToLoad } = useMemo(
5557
() =>
5658
resolveLanguage(
@@ -66,14 +68,8 @@ export const useShikiHighlighter = <F extends OutputFormat = 'react'>(
6668
[stableTheme]
6769
);
6870

69-
const timeoutControl = useRef<TimeoutState>({
70-
nextAllowedTime: 0,
71-
timeoutId: undefined,
72-
});
73-
7471
const shikiOptions = useMemo(
7572
() => buildShikiOptions(languageId, themeResult, stableOpts),
76-
// Stable references ensure recompute when content changes
7773
[languageId, themeResult, stableLang, stableTheme, stableOpts]
7874
);
7975

@@ -102,30 +98,22 @@ export const useShikiHighlighter = <F extends OutputFormat = 'react'>(
10298
const result = transformOutput(
10399
format,
104100
highlighter,
105-
code,
101+
throttledCode,
106102
finalOptions,
107103
themeResult.isMultiTheme
108104
);
109105
setOutput(result);
110106
}
111107
};
112108

113-
const { delay } = stableOpts;
114-
115-
if (delay) {
116-
throttleHighlighting(highlight, timeoutControl, delay);
117-
} else {
118-
highlight().catch(console.error);
119-
}
109+
highlight().catch(console.error);
120110

121111
return () => {
122112
isMounted = false;
123-
clearTimeout(timeoutControl.current.timeoutId);
124113
};
125114
}, [
126-
code,
115+
throttledCode,
127116
shikiOptions,
128-
stableOpts.delay,
129117
stableOpts.highlighter,
130118
stableOpts.outputFormat,
131119
langsToLoad,

package/src/lib/types.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,6 @@ interface HighlighterOptions<F extends OutputFormat = 'react'>
140140
'langAlias' | 'engine'
141141
> {}
142142

143-
interface TimeoutState {
144-
timeoutId: NodeJS.Timeout | undefined;
145-
nextAllowedTime: number;
146-
}
147-
148143
/**
149144
* Public API signature for the useShikiHighlighter hook.
150145
* Generic parameter narrows return type based on outputFormat option.
@@ -174,7 +169,6 @@ export type {
174169
Theme,
175170
Themes,
176171
Element,
177-
TimeoutState,
178172
HighlighterOptions,
179173
OutputFormat,
180174
OutputFormatMap,

package/src/lib/utils.ts

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
import { useRef } from 'react';
1+
import { useEffect, useRef, useState } from 'react';
22
import { dequal } from 'dequal';
33

4-
import type { TimeoutState } from './types';
5-
64
/**
75
* Returns a stable reference that only changes when content changes (deep equality).
86
* Prevents unnecessary re-renders when objects are recreated with identical content.
@@ -24,17 +22,51 @@ export const useStableOptions = <T>(value: T): T => {
2422
return ref.current;
2523
};
2624

27-
export const throttleHighlighting = (
28-
performHighlight: () => Promise<void>,
29-
timeoutControl: React.RefObject<TimeoutState>,
30-
throttleMs: number
31-
) => {
32-
const now = Date.now();
33-
clearTimeout(timeoutControl.current.timeoutId);
34-
35-
const delay = Math.max(0, timeoutControl.current.nextAllowedTime - now);
36-
timeoutControl.current.timeoutId = setTimeout(() => {
37-
performHighlight().catch(console.error);
38-
timeoutControl.current.nextAllowedTime = now + throttleMs;
39-
}, delay);
25+
/**
26+
* Hybrid throttle+debounce hook for rate-limiting value updates.
27+
* - If throttle window has passed: updates immediately
28+
* - Otherwise: debounces to catch final state
29+
*/
30+
export const useThrottledDebounce = <T>(
31+
value: T,
32+
throttleMs: number | undefined,
33+
debounceMs = 50
34+
): T => {
35+
const [processedValue, setProcessedValue] = useState(value);
36+
const lastRunTime = useRef(0);
37+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
38+
undefined
39+
);
40+
41+
useEffect(() => {
42+
if (!throttleMs) {
43+
setProcessedValue(value);
44+
return;
45+
}
46+
47+
const now = Date.now();
48+
const timeSinceLastRun = now - lastRunTime.current;
49+
50+
if (timeoutRef.current) {
51+
clearTimeout(timeoutRef.current);
52+
}
53+
54+
if (timeSinceLastRun >= throttleMs) {
55+
setProcessedValue(value);
56+
lastRunTime.current = now;
57+
} else {
58+
timeoutRef.current = setTimeout(() => {
59+
setProcessedValue(value);
60+
lastRunTime.current = Date.now();
61+
}, debounceMs);
62+
}
63+
64+
return () => {
65+
if (timeoutRef.current) {
66+
clearTimeout(timeoutRef.current);
67+
}
68+
};
69+
}, [value, throttleMs, debounceMs]);
70+
71+
return processedValue;
4072
};

package/tests/hook.test.tsx

Lines changed: 35 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type {
1313
TokensResult,
1414
} from '../src/lib/types';
1515
import type { ShikiTransformer } from 'shiki';
16-
import { throttleHighlighting, useStableOptions } from '../src/lib/utils';
16+
import { useThrottledDebounce, useStableOptions } from '../src/lib/utils';
1717

1818
interface TestComponentProps {
1919
code: string;
@@ -878,57 +878,50 @@ describe('useShikiHighlighter Hook', () => {
878878
});
879879

880880
describe('Throttling', () => {
881-
beforeEach(() => {
882-
vi.useFakeTimers();
883-
});
884-
885-
afterEach(() => {
886-
vi.useRealTimers();
881+
test('useThrottledDebounce returns initial value', () => {
882+
const { result } = renderHook(() =>
883+
useThrottledDebounce('initial', 500)
884+
);
885+
expect(result.current).toBe('initial');
887886
});
888887

889-
test('throttles highlighting function calls based on timing', () => {
890-
// Mock date to have a consistent starting point
891-
const originalDateNow = Date.now;
892-
const mockTime = 1000;
893-
Date.now = vi.fn(() => mockTime);
894-
895-
// Mock the perform highlight function
896-
const performHighlight = vi.fn().mockResolvedValue(undefined);
888+
test('useThrottledDebounce with no throttle returns value immediately', async () => {
889+
const { result, rerender } = renderHook(
890+
({ value }) => useThrottledDebounce(value, undefined),
891+
{ initialProps: { value: 'initial' } }
892+
);
897893

898-
// Setup timeout control like in the hook
899-
const timeoutControl = {
900-
current: {
901-
timeoutId: undefined,
902-
nextAllowedTime: 0,
903-
},
904-
};
894+
expect(result.current).toBe('initial');
905895

906-
// First call should schedule immediately since nextAllowedTime is in the past
907-
throttleHighlighting(performHighlight, timeoutControl, 500);
908-
expect(timeoutControl.current.timeoutId).toBeDefined();
896+
rerender({ value: 'updated' });
909897

910-
// Run the timeout
911-
vi.runAllTimers();
912-
expect(performHighlight).toHaveBeenCalledTimes(1);
913-
expect(timeoutControl.current.nextAllowedTime).toBe(1500); // 1000 + 500
898+
await waitFor(() => {
899+
expect(result.current).toBe('updated');
900+
});
901+
});
914902

915-
// Reset the mock
916-
performHighlight.mockClear();
903+
test('delay option throttles code updates', async () => {
904+
const code1 = 'const a = 1;';
905+
const code2 = 'const b = 2;';
917906

918-
// Call again - should be delayed by the throttle duration
919-
throttleHighlighting(performHighlight, timeoutControl, 500);
920-
expect(performHighlight).not.toHaveBeenCalled(); // Not called yet
907+
const { getByTestId, rerender } = render(
908+
<TestComponent code={code1} language="javascript" theme="github-dark" delay={100} />
909+
);
921910

922-
// Advance halfway through the delay - should still not be called
923-
vi.advanceTimersByTime(250);
924-
expect(performHighlight).not.toHaveBeenCalled();
911+
// Wait for initial render
912+
await waitFor(() => {
913+
expect(getByTestId('highlighted')).toBeInTheDocument();
914+
});
925915

926-
// Advance the full delay
927-
vi.advanceTimersByTime(250);
928-
expect(performHighlight).toHaveBeenCalledTimes(1);
916+
// Change code - should be throttled
917+
rerender(
918+
<TestComponent code={code2} language="javascript" theme="github-dark" delay={100} />
919+
);
929920

930-
// Restore original Date.now
931-
Date.now = originalDateNow;
921+
// Eventually should show new code
922+
await waitFor(() => {
923+
expect(getByTestId('highlighted').textContent).toContain('b');
924+
});
932925
});
933926
});
934927

0 commit comments

Comments
 (0)