Skip to content

Commit cd78d01

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

2 files changed

Lines changed: 53 additions & 2 deletions

File tree

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
import frontend, { PageState } from '@/store/frontend'
2+
3+
const PAGE_STATE_MULTIPLIERS: Record<PageState, number> = { focused: 1, blurred: 5, hidden: 10 }
4+
5+
const pageResumeListeners = new Set<() => void>()
6+
if (typeof document !== 'undefined') {
7+
const notify = () => pageResumeListeners.forEach((fn) => fn())
8+
document.addEventListener('visibilitychange', () => { if (!document.hidden) notify() })
9+
window.addEventListener('focus', notify)
10+
}
11+
112
/**
213
* Represents a function that can be OneMoreTime valid action
314
*/
@@ -39,6 +50,8 @@ export interface OneMoreTimeOptions {
3950
* OneMoreTime instance.
4051
*/
4152
disposeWith?: unknown
53+
54+
disablePageThrottle?: boolean
4255
}
4356

4457
/**
@@ -55,6 +68,12 @@ export class OneMoreTime {
5568

5669
private timeoutId?: ReturnType<typeof setTimeout>
5770

71+
private onPageResume = () => {
72+
if (this.isDisposed || this.isPaused || this.isRunning || !this.timeoutId) return
73+
this.killTask()
74+
this.start()
75+
}
76+
5877
/**
5978
* Constructs an instance of OneMoreTime, optionally starting the action immediately.
6079
* @param {OneMoreTimeOptions} options Configuration options for the instance.
@@ -65,10 +84,17 @@ export class OneMoreTime {
6584
private action?: OneMoreTimeAction,
6685
) {
6786
this.watchDisposeWith()
87+
if (!this.options.disablePageThrottle) pageResumeListeners.add(this.onPageResume)
6888
// One more time
6989
this.softStart()
7090
}
7191

92+
private getEffectiveDelay(baseDelay?: number): number | undefined {
93+
if (baseDelay === undefined) return undefined
94+
if (this.options.disablePageThrottle) return baseDelay
95+
return baseDelay * PAGE_STATE_MULTIPLIERS[frontend.page_state]
96+
}
97+
7298
private killTask(): void {
7399
if (this.timeoutId) {
74100
clearTimeout(this.timeoutId)
@@ -85,6 +111,7 @@ export class OneMoreTime {
85111
// eslint-disable-next-line
86112
if (!ref.deref() || ref.deref()._isDestroyed) {
87113
this.isDisposed = true
114+
pageResumeListeners.delete(this.onPageResume)
88115
this.killTask()
89116
clearInterval(id)
90117
}
@@ -95,6 +122,7 @@ export class OneMoreTime {
95122
// Celebrate and dance so free
96123
[Symbol.dispose](): void {
97124
this.isDisposed = true
125+
pageResumeListeners.delete(this.onPageResume)
98126
this.killTask()
99127
}
100128

@@ -150,13 +178,13 @@ export class OneMoreTime {
150178
this.options.onError?.(error)
151179
// Oh yeah, alright, don't stop the dancing
152180
// eslint-disable-next-line no-promise-executor-return
153-
await new Promise((resolve) => setTimeout(resolve, this.options.errorDelay))
181+
await new Promise((resolve) => setTimeout(resolve, this.getEffectiveDelay(this.options.errorDelay)))
154182
} finally {
155183
this.isRunning = false
156184
}
157185

158186
if (!this.isPaused && !this.isDisposed) {
159-
this.timeoutId = setTimeout(() => this.start(), this.options.delay)
187+
this.timeoutId = setTimeout(() => this.start(), this.getEffectiveDelay(this.options.delay))
160188
}
161189
}
162190

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)