Skip to content

Commit d57e9fb

Browse files
committed
fix(frontend): prevent infinite re-render loop in WebSocket hook
Root cause: Callbacks and options object recreated on every render caused useEffect dependencies to constantly change. Fixes: - Use refs for onCompleted/onError callbacks (stable across renders) - Memoize wsOptions with useMemo in HeroPlayground - Destructure wsState immediately to get stable references - Fixed dependency arrays to use primitive values not objects HeroPlayground changes: - useCallback for handleWsCompleted, handleWsError - useMemo for wsOptions object - Destructure hook result instead of using wsState object in deps useIndexingWebSocket changes: - Store callbacks in refs (onCompletedRef, onErrorRef) - Update refs synchronously (no useEffect needed) - Cleaner, more compact implementation
1 parent 2d587a1 commit d57e9fb

2 files changed

Lines changed: 161 additions & 287 deletions

File tree

frontend/src/components/playground/HeroPlayground.tsx

Lines changed: 75 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
* - Celebration screen on completion
1313
*/
1414

15-
import { useState, useCallback, useEffect } from 'react';
15+
import { useState, useCallback, useMemo, useRef } from 'react';
1616
import { useNavigate } from 'react-router-dom';
1717
import { motion, AnimatePresence } from 'framer-motion';
1818
import { Button } from '@/components/ui/button';
@@ -82,37 +82,54 @@ export function HeroPlayground({
8282
const jobId = state.status === 'indexing' ? state.jobId : null;
8383
const repoName = customUrl.split('/').pop()?.replace('.git', '') || 'repository';
8484

85-
// WebSocket hook for real-time progress
86-
const wsState = useIndexingWebSocket(jobId, {
85+
// Stable callbacks for WebSocket hook (MUST be memoized to prevent infinite loops!)
86+
const handleWsCompleted = useCallback(() => {
87+
setShowCelebration(true);
88+
}, []);
89+
90+
const handleWsError = useCallback((error: string, recoverable: boolean) => {
91+
console.error('[HeroPlayground] Indexing error:', error, recoverable);
92+
}, []);
93+
94+
// Memoize options to prevent recreation every render
95+
const wsOptions = useMemo(() => ({
8796
maxRecentFiles: 12,
88-
onCompleted: (repoId, stats) => {
89-
console.log('[HeroPlayground] Indexing completed:', repoId, stats);
90-
setShowCelebration(true);
91-
},
92-
onError: (error, recoverable) => {
93-
console.error('[HeroPlayground] Indexing error:', error, recoverable);
94-
},
95-
});
97+
onCompleted: handleWsCompleted,
98+
onError: handleWsError,
99+
}), [handleWsCompleted, handleWsError]);
100+
101+
// WebSocket hook for real-time progress
102+
const {
103+
phase: wsPhase,
104+
progress: wsProgress,
105+
recentFiles,
106+
completedStats,
107+
repoId: wsRepoId,
108+
error: wsError,
109+
isCompleted: wsIsCompleted,
110+
hasError: wsHasError,
111+
reset: wsReset,
112+
} = useIndexingWebSocket(jobId, wsOptions);
96113

97114
// Map WebSocket phase to IndexingProgress phase
98-
const getIndexingPhase = (): IndexingPhase => {
99-
if (wsState.phase === 'cloning') return 'cloning';
100-
if (wsState.phase === 'indexing') return 'indexing';
101-
if (wsState.phase === 'completed') return 'completed';
102-
if (wsState.phase === 'error') return 'error';
115+
const getIndexingPhase = useCallback((): IndexingPhase => {
116+
if (wsPhase === 'cloning') return 'cloning';
117+
if (wsPhase === 'indexing') return 'indexing';
118+
if (wsPhase === 'completed') return 'completed';
119+
if (wsPhase === 'error') return 'error';
103120
return 'connecting';
104-
};
121+
}, [wsPhase]);
105122

106123
// Handle mode change
107124
const handleModeChange = useCallback((newMode: RepoMode) => {
108125
setMode(newMode);
109126
if (newMode === 'demo') {
110127
resetSession();
111-
wsState.reset();
128+
wsReset();
112129
setCustomUrl('');
113130
setShowCelebration(false);
114131
}
115-
}, [resetSession, wsState]);
132+
}, [resetSession, wsReset]);
116133

117134
// Handle search submit
118135
const handleSearch = useCallback((e?: React.FormEvent) => {
@@ -123,11 +140,11 @@ export function HeroPlayground({
123140
onSearch(query, selectedDemo, false);
124141
} else if (state.status === 'ready') {
125142
onSearch(query, state.repoId, true);
126-
} else if (wsState.isCompleted && wsState.repoId) {
143+
} else if (wsIsCompleted && wsRepoId) {
127144
// Search using repo from completed WebSocket state
128-
onSearch(query, wsState.repoId, true);
145+
onSearch(query, wsRepoId, true);
129146
}
130-
}, [query, mode, selectedDemo, state, wsState, loading, onSearch]);
147+
}, [query, mode, selectedDemo, state, wsIsCompleted, wsRepoId, loading, onSearch]);
131148

132149
// Start searching after celebration
133150
const handleStartSearching = useCallback(() => {
@@ -137,35 +154,35 @@ export function HeroPlayground({
137154
// Index another repo
138155
const handleIndexAnother = useCallback(() => {
139156
resetSession();
140-
wsState.reset();
157+
wsReset();
141158
setCustomUrl('');
142159
setShowCelebration(false);
143-
}, [resetSession, wsState]);
160+
}, [resetSession, wsReset]);
144161

145162
// Get validation state for ValidationStatus component
146-
const getValidationState = () => {
163+
const getValidationState = useCallback(() => {
147164
if (state.status === 'idle') return { type: 'idle' as const };
148165
if (state.status === 'validating') return { type: 'validating' as const };
149166
if (state.status === 'valid') return { type: 'valid' as const, validation: state.validation };
150167
if (state.status === 'invalid') return { type: 'invalid' as const, error: state.error, reason: state.reason };
151168
return { type: 'idle' as const };
152-
};
169+
}, [state]);
153170

154171
// Determine visibility states
155172
const showDemoSelector = mode === 'demo';
156-
const showUrlInput = mode === 'custom' && !['indexing', 'ready'].includes(state.status) && !showCelebration && !wsState.isCompleted;
173+
const showUrlInput = mode === 'custom' && !['indexing', 'ready'].includes(state.status) && !showCelebration && !wsIsCompleted;
157174
const showValidation = mode === 'custom' && ['validating', 'valid', 'invalid'].includes(state.status) && !showCelebration;
158-
const showIndexing = mode === 'custom' && state.status === 'indexing' && !showCelebration && !wsState.isCompleted;
159-
const showReady = (mode === 'custom' && state.status === 'ready') || (wsState.isCompleted && !showCelebration);
160-
const isSearchDisabled = mode === 'custom' && state.status !== 'ready' && !wsState.isCompleted;
175+
const showIndexing = mode === 'custom' && state.status === 'indexing' && !showCelebration && !wsIsCompleted;
176+
const showReady = (mode === 'custom' && state.status === 'ready') || (wsIsCompleted && !showCelebration);
177+
const isSearchDisabled = mode === 'custom' && state.status !== 'ready' && !wsIsCompleted;
161178

162179
// Can search?
163180
const canSearch = mode === 'demo'
164181
? remaining > 0 && query.trim().length > 0
165-
: (state.status === 'ready' || wsState.isCompleted) && remaining > 0 && query.trim().length > 0;
182+
: (state.status === 'ready' || wsIsCompleted) && remaining > 0 && query.trim().length > 0;
166183

167184
// Get contextual placeholder text
168-
const getPlaceholder = () => {
185+
const getPlaceholder = useCallback(() => {
169186
if (mode === 'demo') {
170187
return "Search for authentication, error handling...";
171188
}
@@ -174,20 +191,28 @@ export function HeroPlayground({
174191
if (state.status === 'valid') return "Click 'Index Repository' to continue...";
175192
if (state.status === 'invalid') return "Fix the URL above to continue...";
176193
if (state.status === 'indexing') return "Indexing in progress...";
177-
if (state.status === 'ready' || wsState.isCompleted) return `Search in ${repoName}...`;
194+
if (state.status === 'ready' || wsIsCompleted) return `Search in ${repoName}...`;
178195
return "Enter a GitHub URL to search...";
179-
};
196+
}, [mode, state.status, wsIsCompleted, repoName]);
180197

181198
// Compute ready state info
182-
const readyInfo = wsState.isCompleted && wsState.completedStats ? {
183-
repoName,
184-
fileCount: wsState.completedStats.files_processed,
185-
functionsFound: wsState.completedStats.functions_indexed,
186-
} : state.status === 'ready' ? {
187-
repoName: state.repoName,
188-
fileCount: state.fileCount,
189-
functionsFound: state.functionsFound,
190-
} : null;
199+
const readyInfo = useMemo(() => {
200+
if (wsIsCompleted && completedStats) {
201+
return {
202+
repoName,
203+
fileCount: completedStats.files_processed,
204+
functionsFound: completedStats.functions_indexed,
205+
};
206+
}
207+
if (state.status === 'ready') {
208+
return {
209+
repoName: state.repoName,
210+
fileCount: state.fileCount,
211+
functionsFound: state.functionsFound,
212+
};
213+
}
214+
return null;
215+
}, [wsIsCompleted, completedStats, state, repoName]);
191216

192217
return (
193218
<div className="w-full max-w-2xl mx-auto">
@@ -274,20 +299,17 @@ export function HeroPlayground({
274299
className="mb-4"
275300
>
276301
<IndexingProgress
277-
progress={wsState.progress}
302+
progress={wsProgress}
278303
phase={getIndexingPhase()}
279304
repoName={repoName}
280-
recentFiles={wsState.recentFiles}
281-
onCancel={() => {
282-
resetSession();
283-
wsState.reset();
284-
}}
305+
recentFiles={recentFiles}
306+
onCancel={handleIndexAnother}
285307
/>
286308
</motion.div>
287309
)}
288310

289311
{/* Celebration Screen */}
290-
{showCelebration && wsState.completedStats && (
312+
{showCelebration && completedStats && (
291313
<motion.div
292314
key="celebration"
293315
initial={{ opacity: 0, scale: 0.9 }}
@@ -297,7 +319,7 @@ export function HeroPlayground({
297319
>
298320
<IndexingComplete
299321
repoName={repoName}
300-
stats={wsState.completedStats}
322+
stats={completedStats}
301323
onStartSearching={handleStartSearching}
302324
onIndexAnother={handleIndexAnother}
303325
/>
@@ -386,14 +408,14 @@ export function HeroPlayground({
386408
)}
387409

388410
{/* Error State */}
389-
{(state.status === 'error' || wsState.hasError) && (
411+
{(state.status === 'error' || wsHasError) && (
390412
<motion.div
391413
initial={{ opacity: 0, y: 10 }}
392414
animate={{ opacity: 1, y: 0 }}
393415
className="mt-4 px-4 py-3 rounded-xl bg-red-500/10 border border-red-500/20"
394416
>
395417
<p className="text-red-300 text-sm">
396-
{state.status === 'error' ? state.message : wsState.error}
418+
{state.status === 'error' ? state.message : wsError}
397419
</p>
398420
<button
399421
type="button"

0 commit comments

Comments
 (0)