Skip to content

Commit 6957f59

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 644ec9c commit 6957f59

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
@@ -14,7 +14,7 @@ import type {
1414
TokensResult,
1515
} from '../src/lib/types';
1616
import type { ShikiTransformer } from 'shiki';
17-
import { throttleHighlighting, useStableOptions } from '../src/lib/utils';
17+
import { useThrottledDebounce, useStableOptions } from '../src/lib/utils';
1818

1919
interface TestComponentProps {
2020
code: string;
@@ -881,57 +881,50 @@ describe('useShikiHighlighter Hook', () => {
881881
});
882882

883883
describe('Throttling', () => {
884-
beforeEach(() => {
885-
vi.useFakeTimers();
886-
});
887-
888-
afterEach(() => {
889-
vi.useRealTimers();
884+
test('useThrottledDebounce returns initial value', () => {
885+
const { result } = renderHook(() =>
886+
useThrottledDebounce('initial', 500)
887+
);
888+
expect(result.current).toBe('initial');
890889
});
891890

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

901-
// Setup timeout control like in the hook
902-
const timeoutControl = {
903-
current: {
904-
timeoutId: undefined,
905-
nextAllowedTime: 0,
906-
},
907-
};
897+
expect(result.current).toBe('initial');
908898

909-
// First call should schedule immediately since nextAllowedTime is in the past
910-
throttleHighlighting(performHighlight, timeoutControl, 500);
911-
expect(timeoutControl.current.timeoutId).toBeDefined();
899+
rerender({ value: 'updated' });
912900

913-
// Run the timeout
914-
vi.runAllTimers();
915-
expect(performHighlight).toHaveBeenCalledTimes(1);
916-
expect(timeoutControl.current.nextAllowedTime).toBe(1500); // 1000 + 500
901+
await waitFor(() => {
902+
expect(result.current).toBe('updated');
903+
});
904+
});
917905

918-
// Reset the mock
919-
performHighlight.mockClear();
906+
test('delay option throttles code updates', async () => {
907+
const code1 = 'const a = 1;';
908+
const code2 = 'const b = 2;';
920909

921-
// Call again - should be delayed by the throttle duration
922-
throttleHighlighting(performHighlight, timeoutControl, 500);
923-
expect(performHighlight).not.toHaveBeenCalled(); // Not called yet
910+
const { getByTestId, rerender } = render(
911+
<TestComponent code={code1} language="javascript" theme="github-dark" delay={100} />
912+
);
924913

925-
// Advance halfway through the delay - should still not be called
926-
vi.advanceTimersByTime(250);
927-
expect(performHighlight).not.toHaveBeenCalled();
914+
// Wait for initial render
915+
await waitFor(() => {
916+
expect(getByTestId('highlighted')).toBeInTheDocument();
917+
});
928918

929-
// Advance the full delay
930-
vi.advanceTimersByTime(250);
931-
expect(performHighlight).toHaveBeenCalledTimes(1);
919+
// Change code - should be throttled
920+
rerender(
921+
<TestComponent code={code2} language="javascript" theme="github-dark" delay={100} />
922+
);
932923

933-
// Restore original Date.now
934-
Date.now = originalDateNow;
924+
// Eventually should show new code
925+
await waitFor(() => {
926+
expect(getByTestId('highlighted').textContent).toContain('b');
927+
});
935928
});
936929
});
937930

0 commit comments

Comments
 (0)