@@ -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