Skip to content

Commit 4f99354

Browse files
Frontend: rate-limit one-more-time requests when page is out of focus or hidden
1 parent a3c1939 commit 4f99354

2 files changed

Lines changed: 59 additions & 2 deletions

File tree

core/frontend/src/one-more-time.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
1+
import frontend, { PageState } from '@/store/frontend'
2+
3+
// Delays are multiplied by these factors when the page is not actively focused,
4+
// reducing unnecessary network traffic and CPU usage for background tabs.
5+
const PAGE_STATE_MULTIPLIERS: Record<PageState, number> = { focused: 1, blurred: 5, hidden: 10 }
6+
7+
// When the page regains focus, we need to explicitly notify all instances so they can
8+
// cancel their throttled (long) timeouts and fire immediately with fresh data.
9+
// This can't rely on Vuex reactivity alone because setTimeout callbacks aren't reactive —
10+
// a sleeping timeout won't wake up just because a store value changed.
11+
const pageResumeListeners = new Set<() => void>()
12+
if (typeof document !== 'undefined') {
13+
const notify = () => pageResumeListeners.forEach((fn) => fn())
14+
document.addEventListener('visibilitychange', () => { if (!document.hidden) notify() })
15+
window.addEventListener('focus', notify)
16+
}
17+
118
/**
219
* Represents a function that can be OneMoreTime valid action
320
*/
@@ -39,6 +56,8 @@ export interface OneMoreTimeOptions {
3956
* OneMoreTime instance.
4057
*/
4158
disposeWith?: unknown
59+
60+
disablePageThrottle?: boolean
4261
}
4362

4463
/**
@@ -55,6 +74,12 @@ export class OneMoreTime {
5574

5675
private timeoutId?: ReturnType<typeof setTimeout>
5776

77+
private onPageResume = () => {
78+
if (this.isDisposed || this.isPaused || this.isRunning || !this.timeoutId) return
79+
this.killTask()
80+
this.start()
81+
}
82+
5883
/**
5984
* Constructs an instance of OneMoreTime, optionally starting the action immediately.
6085
* @param {OneMoreTimeOptions} options Configuration options for the instance.
@@ -65,10 +90,17 @@ export class OneMoreTime {
6590
private action?: OneMoreTimeAction,
6691
) {
6792
this.watchDisposeWith()
93+
if (!this.options.disablePageThrottle) pageResumeListeners.add(this.onPageResume)
6894
// One more time
6995
this.softStart()
7096
}
7197

98+
private getEffectiveDelay(baseDelay?: number): number | undefined {
99+
if (baseDelay === undefined) return undefined
100+
if (this.options.disablePageThrottle) return baseDelay
101+
return baseDelay * PAGE_STATE_MULTIPLIERS[frontend.page_state]
102+
}
103+
72104
private killTask(): void {
73105
if (this.timeoutId) {
74106
clearTimeout(this.timeoutId)
@@ -85,6 +117,7 @@ export class OneMoreTime {
85117
// eslint-disable-next-line
86118
if (!ref.deref() || ref.deref()._isDestroyed) {
87119
this.isDisposed = true
120+
pageResumeListeners.delete(this.onPageResume)
88121
this.killTask()
89122
clearInterval(id)
90123
}
@@ -95,6 +128,7 @@ export class OneMoreTime {
95128
// Celebrate and dance so free
96129
[Symbol.dispose](): void {
97130
this.isDisposed = true
131+
pageResumeListeners.delete(this.onPageResume)
98132
this.killTask()
99133
}
100134

@@ -150,13 +184,13 @@ export class OneMoreTime {
150184
this.options.onError?.(error)
151185
// Oh yeah, alright, don't stop the dancing
152186
// eslint-disable-next-line no-promise-executor-return
153-
await new Promise((resolve) => setTimeout(resolve, this.options.errorDelay))
187+
await new Promise((resolve) => setTimeout(resolve, this.getEffectiveDelay(this.options.errorDelay)))
154188
} finally {
155189
this.isRunning = false
156190
}
157191

158192
if (!this.isPaused && !this.isDisposed) {
159-
this.timeoutId = setTimeout(() => this.start(), this.options.delay)
193+
this.timeoutId = setTimeout(() => this.start(), this.getEffectiveDelay(this.options.delay))
160194
}
161195
}
162196

core/frontend/src/store/frontend.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66

77
import store from '@/store'
88

9+
export type PageState = 'focused' | 'blurred' | 'hidden'
10+
911
@Module({
1012
dynamic: true,
1113
store,
@@ -19,6 +21,8 @@ class FrontendStore extends VuexModule {
1921

2022
backend_offline = false
2123

24+
page_state: PageState = 'focused'
25+
2226
frontend_id = (() => {
2327
const id = nanoid(9)
2428
console.log('[FrontendStore] Frontend is assigned with ID:', id)
@@ -34,9 +38,28 @@ class FrontendStore extends VuexModule {
3438
setBackendOffline(offline: boolean): void {
3539
this.backend_offline = offline
3640
}
41+
42+
@Mutation
43+
setPageState(state: PageState): void {
44+
this.page_state = state
45+
}
3746
}
3847

3948
export { FrontendStore }
4049

4150
const frontend: FrontendStore = getModule(FrontendStore)
4251
export default frontend
52+
53+
function detectPageState(): PageState {
54+
if (document.hidden) return 'hidden'
55+
if (document.hasFocus()) return 'focused'
56+
return 'blurred'
57+
}
58+
59+
if (typeof document !== 'undefined') {
60+
frontend.setPageState(detectPageState())
61+
const update = () => frontend.setPageState(detectPageState())
62+
document.addEventListener('visibilitychange', update)
63+
window.addEventListener('focus', update)
64+
window.addEventListener('blur', update)
65+
}

0 commit comments

Comments
 (0)