Skip to content

Commit 28d2718

Browse files
authored
fix: throttle download state updates (#50)
* fix: debounce download state updates * fix errors & bugs * oops throttle not debounce * revert package-lock.json changes
1 parent cca06a0 commit 28d2718

3 files changed

Lines changed: 88 additions & 3 deletions

File tree

src/lib/download-request.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ import { StatusError } from './errors.js'
77
import { errors, jsonError } from './errors.js'
88
import { secretStreamFetch } from './secret-stream-fetch.js'
99
import { StateUpdateEvent } from './state-update-event.js'
10+
import { throttle } from './throttle.js'
1011
import { addTrailingSlash, generateId, getErrorCode, noop } from './utils.js'
1112

1213
export type DownloadState = DownloadStateUpdate &
1314
Omit<DownloadCreateParams, 'mapShareUrls'> & { downloadId: string }
1415

16+
const PROGRESS_THROTTLE_MS = 100
17+
1518
export class DownloadRequest extends TypedEventTarget<
1619
InstanceType<typeof StateUpdateEvent<DownloadStateUpdate>>
1720
> {
@@ -134,8 +137,22 @@ export class DownloadRequest extends TypedEventTarget<
134137
this.#abortController.abort()
135138
}
136139

140+
#dispatchProgress = throttle((update: DownloadStateUpdate) => {
141+
this.dispatchEvent(new StateUpdateEvent(update))
142+
}, PROGRESS_THROTTLE_MS)
143+
137144
#updateState(update: DownloadStateUpdate) {
145+
// Update #state synchronously so the transform stream's running byte
146+
// count always reads the latest value; only the progress event dispatch
147+
// is throttled.
138148
this.#state = { ...this.#state, ...update }
139-
this.dispatchEvent(new StateUpdateEvent(update))
149+
if (update.status === 'downloading') {
150+
this.#dispatchProgress(update)
151+
} else {
152+
// Emit any pending progress update before the terminal state so
153+
// consumers always see the final bytesDownloaded value.
154+
this.#dispatchProgress.flush()
155+
this.dispatchEvent(new StateUpdateEvent(update))
156+
}
140157
}
141158
}

src/lib/map-share.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ import {
77
} from '../types.js'
88
import { errors, jsonError } from './errors.js'
99
import { StateUpdateEvent } from './state-update-event.js'
10+
import { throttle } from './throttle.js'
1011
import { addTrailingSlash, generateId, getErrorCode } from './utils.js'
1112

13+
const PROGRESS_THROTTLE_MS = 100
14+
1215
export type MapShareOptions = MapInfo & {
1316
/**
1417
* Base URLs to construct the download URLs for the map share. Multiple URLs
@@ -69,6 +72,10 @@ export class MapShare extends TypedEventTarget<
6972
this.#download.addEventListener('update', (event) => {
7073
this.#updateState(event)
7174
})
75+
// Synchronously transition to 'downloading' so that any concurrent
76+
// download attempt observes the non-pending state immediately, without
77+
// waiting for the DownloadResponse's async transform start callback.
78+
this.#updateState({ status: 'downloading', bytesDownloaded: 0 })
7279
return this.#download.response
7380
}
7481

@@ -119,7 +126,7 @@ export class MapShare extends TypedEventTarget<
119126
* share in the future (multiple downloads per share will make the "state" of a
120127
* MapShare harder to reason about and define).
121128
*/
122-
export class DownloadResponse extends TypedEventTarget<
129+
class DownloadResponse extends TypedEventTarget<
123130
InstanceType<typeof StateUpdateEvent<DownloadStateUpdate>>
124131
> {
125132
#stream: TransformStream
@@ -181,8 +188,19 @@ export class DownloadResponse extends TypedEventTarget<
181188
this.#abortController.abort()
182189
}
183190

191+
#dispatchProgress = throttle((update: DownloadStateUpdate) => {
192+
this.dispatchEvent(new StateUpdateEvent(update))
193+
}, PROGRESS_THROTTLE_MS)
194+
184195
#updateState(update: DownloadStateUpdate) {
185196
this.#state = update
186-
this.dispatchEvent(new StateUpdateEvent(update))
197+
if (update.status === 'downloading') {
198+
this.#dispatchProgress(update)
199+
} else {
200+
// Emit any pending progress update before the terminal state so
201+
// consumers always see the final bytesDownloaded value.
202+
this.#dispatchProgress.flush()
203+
this.dispatchEvent(new StateUpdateEvent(update))
204+
}
187205
}
188206
}

src/lib/throttle.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Leading-edge throttle: the first call fires immediately, subsequent calls
3+
* within `wait` ms are coalesced and dispatched on a trailing edge once the
4+
* window expires.
5+
*
6+
* The returned function exposes `flush()` to dispatch any pending call
7+
* immediately and `cancel()` to discard it.
8+
*/
9+
export function throttle<A extends readonly unknown[]>(
10+
fn: (...args: A) => void,
11+
wait: number,
12+
): ((...args: A) => void) & { flush(): void; cancel(): void } {
13+
let lastDispatchedAt = 0
14+
let timer: ReturnType<typeof setTimeout> | undefined
15+
let pendingArgs: A | undefined
16+
17+
const flush = () => {
18+
if (timer !== undefined) {
19+
clearTimeout(timer)
20+
timer = undefined
21+
}
22+
if (!pendingArgs) return
23+
const args = pendingArgs
24+
pendingArgs = undefined
25+
lastDispatchedAt = Date.now()
26+
fn(...args)
27+
}
28+
29+
const throttled = (...args: A) => {
30+
pendingArgs = args
31+
if (timer !== undefined) return
32+
const remaining = wait - (Date.now() - lastDispatchedAt)
33+
if (remaining <= 0) {
34+
flush()
35+
} else {
36+
timer = setTimeout(flush, remaining)
37+
}
38+
}
39+
40+
throttled.flush = flush
41+
throttled.cancel = () => {
42+
if (timer !== undefined) {
43+
clearTimeout(timer)
44+
timer = undefined
45+
}
46+
pendingArgs = undefined
47+
}
48+
49+
return throttled
50+
}

0 commit comments

Comments
 (0)