Skip to content

Commit 531d7d2

Browse files
committed
fix: make refresh min-interval delay instead of drop
- Enforce a 500ms minimum interval between refresh starts without dropping requests - Manual refresh always bypasses pause/hidden by construction - Remove always-on debug log spam from review refresh hook - Update RefreshController tests for new behavior
1 parent 4fb0b9b commit 531d7d2

File tree

3 files changed

+91
-38
lines changed

3 files changed

+91
-38
lines changed

src/browser/hooks/useReviewRefreshController.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,9 @@ export function useReviewRefreshController(
8686

8787
// Create RefreshController once, with stable callbacks via refs
8888
const controller = useMemo(() => {
89-
const wsName = workspaceStore.getWorkspaceName(workspaceId);
9089
const ctrl = new RefreshController({
9190
debounceMs: TOOL_REFRESH_DEBOUNCE_MS,
9291
isPaused: () => isInteractingRef.current,
93-
debugLabel: wsName,
9492
onRefresh: async () => {
9593
if (!api || isCreating) return;
9694

@@ -130,18 +128,11 @@ export function useReviewRefreshController(
130128
useEffect(() => {
131129
if (!api || isCreating) return;
132130

133-
const wsName = workspaceStore.getWorkspaceName(workspaceId);
134-
console.debug(`[ReviewRefresh] subscribing for "${wsName}"`);
135-
136131
const unsubscribe = workspaceStore.subscribeFileModifyingTool(() => {
137-
console.debug(`[ReviewRefresh] tool completed in "${wsName}", scheduling refresh`);
138132
controller.schedule();
139133
}, workspaceId);
140134

141-
return () => {
142-
console.debug(`[ReviewRefresh] unsubscribing for "${wsName}"`);
143-
unsubscribe();
144-
};
135+
return unsubscribe;
145136
}, [api, workspaceId, isCreating, controller]);
146137

147138
// Public API

src/browser/utils/RefreshController.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,10 @@ describe("RefreshController", () => {
102102
await Promise.resolve();
103103
await Promise.resolve(); // Extra tick for .finally()
104104

105-
// Should trigger follow-up refresh (via setTimeout 0 in onComplete)
106-
jest.advanceTimersByTime(1);
105+
// Should trigger follow-up refresh, but never more frequently than the minimum interval.
106+
// First tick runs the post-flight setTimeout(0), then we wait out the min interval.
107+
jest.advanceTimersByTime(0);
108+
jest.advanceTimersByTime(500);
107109
expect(onRefresh).toHaveBeenCalledTimes(2);
108110

109111
controller.dispose();
@@ -212,12 +214,12 @@ describe("RefreshController", () => {
212214

213215
// Scheduled refresh should record "scheduled" trigger
214216
controller.schedule();
215-
jest.advanceTimersByTime(100);
217+
jest.advanceTimersByTime(500);
216218
expect(controller.lastRefreshInfo!.trigger).toBe("scheduled");
217219

218220
// Priority refresh should record "priority" trigger
219221
controller.schedulePriority();
220-
jest.advanceTimersByTime(100);
222+
jest.advanceTimersByTime(500);
221223
expect(controller.lastRefreshInfo!.trigger).toBe("priority");
222224

223225
controller.dispose();
@@ -241,7 +243,7 @@ describe("RefreshController", () => {
241243
);
242244

243245
controller.schedule();
244-
jest.advanceTimersByTime(100);
246+
jest.advanceTimersByTime(500);
245247
expect(onRefreshComplete).toHaveBeenCalledTimes(2);
246248
expect(onRefreshComplete).toHaveBeenLastCalledWith(
247249
expect.objectContaining({ trigger: "scheduled" })

src/browser/utils/RefreshController.ts

Lines changed: 83 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export class RefreshController {
7575
private readonly debugLabel: string | null;
7676

7777
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
78+
private cooldownTimer: ReturnType<typeof setTimeout> | null = null;
7879
private inFlight = false;
7980
private pendingBecauseHidden = false;
8081
private pendingBecauseInFlight = false;
@@ -86,7 +87,7 @@ export class RefreshController {
8687
private _lastRefreshInfo: LastRefreshInfo | null = null;
8788
private pendingTrigger: RefreshTrigger | null = null;
8889

89-
// Hard guard: timestamp of last refresh START (not completion)
90+
// Timestamp of last refresh START (not completion)
9091
private lastRefreshStartMs = 0;
9192

9293
// Track if listeners are bound (for cleanup)
@@ -105,6 +106,26 @@ export class RefreshController {
105106
this.debugLabel = options.debugLabel ?? null;
106107
}
107108

109+
private updatePendingTrigger(trigger: RefreshTrigger): void {
110+
const priorities: Record<RefreshTrigger, number> = {
111+
manual: 3,
112+
priority: 2,
113+
scheduled: 1,
114+
focus: 0,
115+
visibility: 0,
116+
unpaused: 0,
117+
"in-flight-followup": 0,
118+
};
119+
120+
if (!this.pendingTrigger) {
121+
this.pendingTrigger = trigger;
122+
return;
123+
}
124+
125+
if (priorities[trigger] >= priorities[this.pendingTrigger]) {
126+
this.pendingTrigger = trigger;
127+
}
128+
}
108129
private debug(message: string): void {
109130
if (this.debugLabel) {
110131
console.debug(`[RefreshController:${this.debugLabel}] ${message}`);
@@ -135,10 +156,8 @@ export class RefreshController {
135156
private scheduleWithDelay(delayMs: number, trigger: RefreshTrigger): void {
136157
if (this.disposed) return;
137158

138-
// Always update pending trigger (use priority if upgrading from scheduled)
139-
if (!this.pendingTrigger || trigger === "priority") {
140-
this.pendingTrigger = trigger;
141-
}
159+
// Always update pending trigger (manual > priority > scheduled)
160+
this.updatePendingTrigger(trigger);
142161

143162
// If refresh is in-flight, mark pending and let onComplete handle scheduling
144163
if (this.inFlight) {
@@ -176,39 +195,73 @@ export class RefreshController {
176195
this.debounceTimer = null;
177196
}
178197

179-
this.tryRefresh({ bypassPause: true, trigger: "manual" });
198+
this.tryRefresh({ bypassPause: true, bypassHidden: true, trigger: "manual" });
180199
}
181200

182201
/**
183202
* Attempt refresh, respecting pause conditions.
184203
*/
185-
private tryRefresh(options?: { bypassPause?: boolean; trigger?: RefreshTrigger }): void {
204+
private tryRefresh(options?: {
205+
bypassPause?: boolean;
206+
bypassHidden?: boolean;
207+
trigger?: RefreshTrigger;
208+
}): void {
186209
if (this.disposed) return;
187210

188211
const trigger = options?.trigger ?? this.pendingTrigger ?? "scheduled";
212+
const bypassHidden = (options?.bypassHidden ?? false) || trigger === "manual";
213+
const bypassPause = (options?.bypassPause ?? false) || trigger === "manual";
189214

190-
// Hidden → queue for visibility
191-
if (typeof document !== "undefined" && document.hidden) {
215+
// Hidden → queue for visibility (unless bypassed)
216+
if (!bypassHidden && typeof document !== "undefined" && document.hidden) {
192217
this.pendingBecauseHidden = true;
193-
this.pendingTrigger = trigger;
218+
this.updatePendingTrigger(trigger);
194219
return;
195220
}
196221

197222
// Custom pause (e.g., user interacting) → queue for unpause
198223
// Bypassed for manual refresh (user explicitly requested)
199-
if (!options?.bypassPause && this.isPaused?.()) {
224+
if (!bypassPause && this.isPaused?.()) {
200225
this.pendingBecausePaused = true;
201-
this.pendingTrigger = trigger;
226+
this.updatePendingTrigger(trigger);
202227
return;
203228
}
204229

205230
// In-flight → queue for completion
206231
if (this.inFlight) {
207232
this.pendingBecauseInFlight = true;
208-
this.pendingTrigger = trigger;
233+
this.updatePendingTrigger(trigger);
209234
return;
210235
}
211236

237+
// Hard guard: enforce minimum interval between refresh starts.
238+
// Rather than dropping the request, schedule it for the earliest allowed time.
239+
if (this.lastRefreshStartMs > 0) {
240+
const now = Date.now();
241+
const elapsed = now - this.lastRefreshStartMs;
242+
if (elapsed < MIN_REFRESH_INTERVAL_MS) {
243+
this.updatePendingTrigger(trigger);
244+
245+
if (this.cooldownTimer) {
246+
this.debug("cooldown timer running, coalescing");
247+
return;
248+
}
249+
250+
const delayMs = MIN_REFRESH_INTERVAL_MS - elapsed;
251+
const t = this.pendingTrigger ?? trigger;
252+
this.debug(`cooldown: delaying ${delayMs}ms (${t})`);
253+
254+
this.cooldownTimer = setTimeout(() => {
255+
this.cooldownTimer = null;
256+
const cooldownTrigger = this.pendingTrigger ?? t;
257+
this.pendingTrigger = null;
258+
this.tryRefresh({ trigger: cooldownTrigger });
259+
}, delayMs);
260+
261+
return;
262+
}
263+
}
264+
212265
this.executeRefresh(trigger);
213266
}
214267

@@ -218,16 +271,14 @@ export class RefreshController {
218271
private executeRefresh(trigger: RefreshTrigger): void {
219272
if (this.disposed) return;
220273

221-
// Hard guard: refuse to refresh if too soon after last refresh started.
222-
// This prevents loops even if other guards fail.
223-
const now = Date.now();
224-
const elapsed = now - this.lastRefreshStartMs;
225-
if (elapsed < MIN_REFRESH_INTERVAL_MS) {
226-
this.debug(`executeRefresh: too soon (${elapsed}ms < ${MIN_REFRESH_INTERVAL_MS}ms), skipping`);
227-
return;
274+
// Record refresh start; min-interval enforcement happens in tryRefresh().
275+
this.lastRefreshStartMs = Date.now();
276+
277+
if (this.cooldownTimer) {
278+
clearTimeout(this.cooldownTimer);
279+
this.cooldownTimer = null;
228280
}
229281

230-
this.lastRefreshStartMs = now;
231282
this.inFlight = true;
232283
this.pendingTrigger = null;
233284

@@ -266,7 +317,9 @@ export class RefreshController {
266317
// Flush pending hidden refresh
267318
if (this.pendingBecauseHidden) {
268319
this.pendingBecauseHidden = false;
269-
this.tryRefresh({ trigger });
320+
const pendingTrigger = this.pendingTrigger ?? trigger;
321+
this.pendingTrigger = null;
322+
this.tryRefresh({ trigger: pendingTrigger });
270323
return; // Don't double-refresh with proactive
271324
}
272325

@@ -288,7 +341,9 @@ export class RefreshController {
288341
if (this.disposed) return;
289342
if (this.pendingBecausePaused) {
290343
this.pendingBecausePaused = false;
291-
this.tryRefresh({ trigger: "unpaused" });
344+
const pendingTrigger = this.pendingTrigger ?? "unpaused";
345+
this.pendingTrigger = null;
346+
this.tryRefresh({ trigger: pendingTrigger });
292347
}
293348
}
294349

@@ -342,6 +397,11 @@ export class RefreshController {
342397
this.debounceTimer = null;
343398
}
344399

400+
if (this.cooldownTimer) {
401+
clearTimeout(this.cooldownTimer);
402+
this.cooldownTimer = null;
403+
}
404+
345405
if (this.listenersBound) {
346406
if (this.boundHandleVisibility) {
347407
document.removeEventListener("visibilitychange", this.boundHandleVisibility);

0 commit comments

Comments
 (0)