Skip to content

Commit 24f3e2a

Browse files
committed
fix: eliminate race conditions in useWorkspaceName hook
- Add cancelPendingGeneration() that properly resolves waiting promises before invalidating requests (prevents hanging waiters) - Track requestId in generationPromiseRef to match cancellation correctly - Use pendingMessageRef to capture message at schedule time, avoiding stale closures when debounced callback fires - Clear pendingMessageRef in all cleanup paths - waitForGeneration now uses captured message from pendingMessageRef instead of potentially stale closure value
1 parent f6dc763 commit 24f3e2a

File tree

1 file changed

+59
-26
lines changed

1 file changed

+59
-26
lines changed

src/browser/hooks/useWorkspaceName.ts

Lines changed: 59 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -48,18 +48,39 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace
4848

4949
// Track the message that was used for the last successful generation
5050
const lastGeneratedForRef = useRef<string>("");
51-
// Promise that resolves when current generation completes
52-
const generationPromiseRef = useRef<{
53-
promise: Promise<string>;
54-
resolve: (name: string) => void;
55-
} | null>(null);
5651
// Debounce timer
5752
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
53+
// Message pending in debounce timer (captured at schedule time)
54+
const pendingMessageRef = useRef<string>("");
5855
// Generation request counter for cancellation
5956
const requestIdRef = useRef(0);
57+
// Current in-flight generation promise and its resolver
58+
const generationPromiseRef = useRef<{
59+
promise: Promise<string>;
60+
resolve: (name: string) => void;
61+
requestId: number;
62+
} | null>(null);
6063

6164
const name = autoGenerate ? generatedName : manualName;
6265

66+
// Cancel any pending generation and resolve waiters with empty string
67+
const cancelPendingGeneration = useCallback(() => {
68+
if (debounceTimerRef.current) {
69+
clearTimeout(debounceTimerRef.current);
70+
debounceTimerRef.current = null;
71+
pendingMessageRef.current = "";
72+
}
73+
// Increment request ID to invalidate any in-flight request
74+
const oldRequestId = requestIdRef.current;
75+
requestIdRef.current++;
76+
// Resolve any waiters so they don't hang forever
77+
if (generationPromiseRef.current && generationPromiseRef.current.requestId === oldRequestId) {
78+
generationPromiseRef.current.resolve("");
79+
generationPromiseRef.current = null;
80+
setIsGenerating(false);
81+
}
82+
}, []);
83+
6384
const generateName = useCallback(
6485
async (forMessage: string): Promise<string> => {
6586
if (!api || !forMessage.trim()) {
@@ -77,24 +98,25 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace
7798
});
7899
// TypeScript doesn't understand the Promise executor runs synchronously
79100
const safeResolve = resolvePromise!;
80-
generationPromiseRef.current = { promise, resolve: safeResolve };
101+
generationPromiseRef.current = { promise, resolve: safeResolve, requestId };
81102

82103
try {
83104
const result = await api.nameGeneration.generate({
84105
message: forMessage,
85106
});
86107

87-
// Check if this request is still current
108+
// Check if this request is still current (wasn't cancelled)
88109
if (requestId !== requestIdRef.current) {
110+
// Don't resolve here - cancellation already resolved the promise
89111
return "";
90112
}
91113

92114
if (result.success) {
93-
const generatedName = result.data.name;
94-
setGeneratedName(generatedName);
115+
const name = result.data.name;
116+
setGeneratedName(name);
95117
lastGeneratedForRef.current = forMessage;
96-
safeResolve(generatedName);
97-
return generatedName;
118+
safeResolve(name);
119+
return name;
98120
} else {
99121
const errorMsg =
100122
result.error.type === "unknown" && "raw" in result.error
@@ -124,35 +146,42 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace
124146

125147
// Debounced generation effect
126148
useEffect(() => {
127-
// Clear any pending debounce timer
128-
if (debounceTimerRef.current) {
129-
clearTimeout(debounceTimerRef.current);
130-
debounceTimerRef.current = null;
131-
}
132-
133149
// Don't generate if:
134150
// - Auto-generation is disabled
135151
// - Message is empty
136152
// - Already generated for this message
137153
if (!autoGenerate || !message.trim() || lastGeneratedForRef.current === message) {
154+
// Clear any pending timer since conditions changed
155+
if (debounceTimerRef.current) {
156+
clearTimeout(debounceTimerRef.current);
157+
debounceTimerRef.current = null;
158+
pendingMessageRef.current = "";
159+
}
138160
return;
139161
}
140162

141-
// Cancel any in-flight request
142-
requestIdRef.current++;
163+
// Cancel any in-flight request since message changed
164+
cancelPendingGeneration();
165+
166+
// Capture message for the debounced callback (avoid stale closure)
167+
pendingMessageRef.current = message;
143168

144169
// Debounce the generation
145170
debounceTimerRef.current = setTimeout(() => {
146-
void generateName(message);
171+
const msg = pendingMessageRef.current;
172+
debounceTimerRef.current = null;
173+
pendingMessageRef.current = "";
174+
void generateName(msg);
147175
}, debounceMs);
148176

149177
return () => {
150178
if (debounceTimerRef.current) {
151179
clearTimeout(debounceTimerRef.current);
152180
debounceTimerRef.current = null;
181+
pendingMessageRef.current = "";
153182
}
154183
};
155-
}, [message, autoGenerate, debounceMs, generateName]);
184+
}, [message, autoGenerate, debounceMs, generateName, cancelPendingGeneration]);
156185

157186
// When auto-generate is toggled, handle name preservation
158187
const handleSetAutoGenerate = useCallback(
@@ -187,16 +216,20 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace
187216
return manualName;
188217
}
189218

190-
// Always wait for any pending generation to complete on the full message.
191-
// This is important because with voice input, the message can go from empty
192-
// to complete very quickly - we must ensure the generated name reflects the
193-
// total content, not a partial intermediate state.
219+
// Always wait for generation to complete on the full message.
220+
// With voice input, the message can go from empty to complete very quickly,
221+
// so we must ensure the generated name reflects the total content.
194222

195223
// If there's a debounced generation pending, trigger it immediately
224+
// Use the captured message from pendingMessageRef to avoid stale closures
196225
if (debounceTimerRef.current) {
197226
clearTimeout(debounceTimerRef.current);
198227
debounceTimerRef.current = null;
199-
return generateName(message);
228+
const msg = pendingMessageRef.current;
229+
pendingMessageRef.current = "";
230+
if (msg.trim()) {
231+
return generateName(msg);
232+
}
200233
}
201234

202235
// If generation is in progress, wait for it to complete

0 commit comments

Comments
 (0)