Skip to content

Commit 2cb0eef

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 4976526 commit 2cb0eef

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';
@@ -45,6 +44,9 @@ export const useShikiHighlighter = <F extends OutputFormat = 'react'>(
4544
const stableTheme = useStableOptions(themeInput);
4645
const stableOpts = useStableOptions(options);
4746

47+
// Throttle code changes when delay is specified
48+
const throttledCode = useThrottledDebounce(code, stableOpts.delay);
49+
4850
const { languageId, langsToLoad } = useMemo(
4951
() =>
5052
resolveLanguage(
@@ -60,14 +62,8 @@ export const useShikiHighlighter = <F extends OutputFormat = 'react'>(
6062
[stableTheme]
6163
);
6264

63-
const timeoutControl = useRef<TimeoutState>({
64-
nextAllowedTime: 0,
65-
timeoutId: undefined,
66-
});
67-
6865
const shikiOptions = useMemo(
6966
() => buildShikiOptions(languageId, themeResult, stableOpts),
70-
// Stable references ensure recompute when content changes
7167
[languageId, themeResult, stableLang, stableTheme, stableOpts]
7268
);
7369

@@ -97,30 +93,22 @@ export const useShikiHighlighter = <F extends OutputFormat = 'react'>(
9793
const result = transformOutput(
9894
format,
9995
highlighter,
100-
code,
96+
throttledCode,
10197
finalOptions,
10298
themeResult.isMultiTheme
10399
);
104100
setOutput(result);
105101
}
106102
};
107103

108-
const { delay } = stableOpts;
109-
110-
if (delay) {
111-
throttleHighlighting(highlight, timeoutControl, delay);
112-
} else {
113-
highlight().catch(console.error);
114-
}
104+
highlight().catch(console.error);
115105

116106
return () => {
117107
isMounted = false;
118-
clearTimeout(timeoutControl.current.timeoutId);
119108
};
120109
}, [
121-
code,
110+
throttledCode,
122111
shikiOptions,
123-
stableOpts.delay,
124112
stableOpts.highlighter,
125113
stableOpts.outputFormat,
126114
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.
@@ -171,7 +166,6 @@ export type {
171166
Theme,
172167
Themes,
173168
Element,
174-
TimeoutState,
175169
HighlighterOptions,
176170
OutputFormat,
177171
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
@@ -12,7 +12,7 @@ import type {
1212
TokensResult,
1313
} from '../src/lib/types';
1414
import type { ShikiTransformer } from 'shiki';
15-
import { throttleHighlighting, useStableOptions } from '../src/lib/utils';
15+
import { useThrottledDebounce, useStableOptions } from '../src/lib/utils';
1616

1717
interface TestComponentProps {
1818
code: string;
@@ -565,57 +565,50 @@ describe('useShikiHighlighter Hook', () => {
565565
});
566566

567567
describe('Throttling', () => {
568-
beforeEach(() => {
569-
vi.useFakeTimers();
570-
});
571-
572-
afterEach(() => {
573-
vi.useRealTimers();
568+
test('useThrottledDebounce returns initial value', () => {
569+
const { result } = renderHook(() =>
570+
useThrottledDebounce('initial', 500)
571+
);
572+
expect(result.current).toBe('initial');
574573
});
575574

576-
test('throttles highlighting function calls based on timing', () => {
577-
// Mock date to have a consistent starting point
578-
const originalDateNow = Date.now;
579-
const mockTime = 1000;
580-
Date.now = vi.fn(() => mockTime);
581-
582-
// Mock the perform highlight function
583-
const performHighlight = vi.fn().mockResolvedValue(undefined);
575+
test('useThrottledDebounce with no throttle returns value immediately', async () => {
576+
const { result, rerender } = renderHook(
577+
({ value }) => useThrottledDebounce(value, undefined),
578+
{ initialProps: { value: 'initial' } }
579+
);
584580

585-
// Setup timeout control like in the hook
586-
const timeoutControl = {
587-
current: {
588-
timeoutId: undefined,
589-
nextAllowedTime: 0,
590-
},
591-
};
581+
expect(result.current).toBe('initial');
592582

593-
// First call should schedule immediately since nextAllowedTime is in the past
594-
throttleHighlighting(performHighlight, timeoutControl, 500);
595-
expect(timeoutControl.current.timeoutId).toBeDefined();
583+
rerender({ value: 'updated' });
596584

597-
// Run the timeout
598-
vi.runAllTimers();
599-
expect(performHighlight).toHaveBeenCalledTimes(1);
600-
expect(timeoutControl.current.nextAllowedTime).toBe(1500); // 1000 + 500
585+
await waitFor(() => {
586+
expect(result.current).toBe('updated');
587+
});
588+
});
601589

602-
// Reset the mock
603-
performHighlight.mockClear();
590+
test('delay option throttles code updates', async () => {
591+
const code1 = 'const a = 1;';
592+
const code2 = 'const b = 2;';
604593

605-
// Call again - should be delayed by the throttle duration
606-
throttleHighlighting(performHighlight, timeoutControl, 500);
607-
expect(performHighlight).not.toHaveBeenCalled(); // Not called yet
594+
const { getByTestId, rerender } = render(
595+
<TestComponent code={code1} language="javascript" theme="github-dark" delay={100} />
596+
);
608597

609-
// Advance halfway through the delay - should still not be called
610-
vi.advanceTimersByTime(250);
611-
expect(performHighlight).not.toHaveBeenCalled();
598+
// Wait for initial render
599+
await waitFor(() => {
600+
expect(getByTestId('highlighted')).toBeInTheDocument();
601+
});
612602

613-
// Advance the full delay
614-
vi.advanceTimersByTime(250);
615-
expect(performHighlight).toHaveBeenCalledTimes(1);
603+
// Change code - should be throttled
604+
rerender(
605+
<TestComponent code={code2} language="javascript" theme="github-dark" delay={100} />
606+
);
616607

617-
// Restore original Date.now
618-
Date.now = originalDateNow;
608+
// Eventually should show new code
609+
await waitFor(() => {
610+
expect(getByTestId('highlighted').textContent).toContain('b');
611+
});
619612
});
620613
});
621614

0 commit comments

Comments
 (0)