From 97682c0f36d49595305bb15103019c7fab2fce17 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 14 Apr 2026 20:44:46 +0100 Subject: [PATCH 1/4] fix: debounce download state updates --- package-lock.json | 23 +++++++++++++++++++++++ package.json | 1 + src/lib/download-request.ts | 7 +++++-- src/lib/map-share.ts | 10 +++++++--- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 86699ed..df8f289 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@mapbox/mapbox-gl-style-spec": "^14.21.0", "@maplibre/maplibre-gl-style-spec": "^24.7.0", "@whatwg-node/server": "^0.10.17", + "debounce": "^3.0.0", "itty-router": "^5.0.22", "p-defer": "^4.0.1", "p-mutex": "^0.1.0", @@ -1672,6 +1673,7 @@ "integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1728,6 +1730,7 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -2180,6 +2183,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2892,6 +2896,18 @@ "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", "license": "MIT" }, + "node_modules/debounce": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-3.0.0.tgz", + "integrity": "sha512-64byRbF0/AirwbuHqB3/ZpMG9/nckDa6ZA0yd6UnaQNwbbemCOwvz2sL5sjXLHhZHADyiwLm0M5qMhltUUx+TA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3127,6 +3143,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5130,6 +5147,7 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6034,6 +6052,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6187,6 +6206,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6294,6 +6314,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6387,6 +6408,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6400,6 +6422,7 @@ "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.17", "@vitest/mocker": "4.0.17", diff --git a/package.json b/package.json index 847f2d5..244f5f7 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@mapbox/mapbox-gl-style-spec": "^14.21.0", "@maplibre/maplibre-gl-style-spec": "^24.7.0", "@whatwg-node/server": "^0.10.17", + "debounce": "^3.0.0", "itty-router": "^5.0.22", "p-defer": "^4.0.1", "p-mutex": "^0.1.0", diff --git a/src/lib/download-request.ts b/src/lib/download-request.ts index 43d14a0..858062b 100644 --- a/src/lib/download-request.ts +++ b/src/lib/download-request.ts @@ -1,3 +1,4 @@ +import debounce from 'debounce' import { Agent as SecretStreamAgent } from 'secret-stream-http' import { TypedEventTarget } from '../lib/event-target.js' @@ -12,6 +13,8 @@ import { addTrailingSlash, generateId, getErrorCode, noop } from './utils.js' export type DownloadState = DownloadStateUpdate & Omit & { downloadId: string } +const STATE_UPDATE_DEBOUNCE_MS = 100 + export class DownloadRequest extends TypedEventTarget< InstanceType> > { @@ -134,8 +137,8 @@ export class DownloadRequest extends TypedEventTarget< this.#abortController.abort() } - #updateState(update: DownloadStateUpdate) { + #updateState = debounce((update: DownloadStateUpdate) => { this.#state = { ...this.#state, ...update } this.dispatchEvent(new StateUpdateEvent(update)) - } + }, STATE_UPDATE_DEBOUNCE_MS) } diff --git a/src/lib/map-share.ts b/src/lib/map-share.ts index ba64627..b3c0f2e 100644 --- a/src/lib/map-share.ts +++ b/src/lib/map-share.ts @@ -1,3 +1,5 @@ +import debounce from 'debounce' + import { TypedEventTarget } from '../lib/event-target.js' import { MapShareState, @@ -9,6 +11,8 @@ import { errors, jsonError } from './errors.js' import { StateUpdateEvent } from './state-update-event.js' import { addTrailingSlash, generateId, getErrorCode } from './utils.js' +const STATE_UPDATE_DEBOUNCE_MS = 100 + export type MapShareOptions = MapInfo & { /** * Base URLs to construct the download URLs for the map share. Multiple URLs @@ -119,7 +123,7 @@ export class MapShare extends TypedEventTarget< * share in the future (multiple downloads per share will make the "state" of a * MapShare harder to reason about and define). */ -export class DownloadResponse extends TypedEventTarget< +class DownloadResponse extends TypedEventTarget< InstanceType> > { #stream: TransformStream @@ -181,8 +185,8 @@ export class DownloadResponse extends TypedEventTarget< this.#abortController.abort() } - #updateState(update: DownloadStateUpdate) { + #updateState = debounce((update: DownloadStateUpdate) => { this.#state = update this.dispatchEvent(new StateUpdateEvent(update)) - } + }, STATE_UPDATE_DEBOUNCE_MS) } From a0a6dc7b84ea6a7b09ee02ed143e456af93ea85c Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 14 Apr 2026 21:39:41 +0100 Subject: [PATCH 2/4] fix errors & bugs --- src/lib/download-request.ts | 25 ++++++++++++++++++++++--- src/lib/map-share.ts | 26 +++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/lib/download-request.ts b/src/lib/download-request.ts index 858062b..1f4679f 100644 --- a/src/lib/download-request.ts +++ b/src/lib/download-request.ts @@ -137,8 +137,27 @@ export class DownloadRequest extends TypedEventTarget< this.#abortController.abort() } - #updateState = debounce((update: DownloadStateUpdate) => { + #dispatchProgress = debounce( + (update: DownloadStateUpdate) => { + this.dispatchEvent(new StateUpdateEvent(update)) + }, + STATE_UPDATE_DEBOUNCE_MS, + { immediate: true }, + ) + + #updateState(update: DownloadStateUpdate) { + // Update #state synchronously so the transform stream's running byte + // count always reads the latest value; only the progress event dispatch + // is debounced. this.#state = { ...this.#state, ...update } - this.dispatchEvent(new StateUpdateEvent(update)) - }, STATE_UPDATE_DEBOUNCE_MS) + if (update.status === 'downloading') { + this.#dispatchProgress(update) + } else { + // Flush any pending progress update so the final bytesDownloaded event + // is emitted before the terminal state, then dispatch the terminal + // state directly (non-terminal progress is the only thing debounced). + this.#dispatchProgress.flush() + this.dispatchEvent(new StateUpdateEvent(update)) + } + } } diff --git a/src/lib/map-share.ts b/src/lib/map-share.ts index b3c0f2e..151cdeb 100644 --- a/src/lib/map-share.ts +++ b/src/lib/map-share.ts @@ -73,6 +73,10 @@ export class MapShare extends TypedEventTarget< this.#download.addEventListener('update', (event) => { this.#updateState(event) }) + // Synchronously transition to 'downloading' so that any concurrent + // download attempt observes the non-pending state immediately, without + // waiting for the DownloadResponse's async transform start callback. + this.#updateState({ status: 'downloading', bytesDownloaded: 0 }) return this.#download.response } @@ -185,8 +189,24 @@ class DownloadResponse extends TypedEventTarget< this.#abortController.abort() } - #updateState = debounce((update: DownloadStateUpdate) => { + #dispatchProgress = debounce( + (update: DownloadStateUpdate) => { + this.dispatchEvent(new StateUpdateEvent(update)) + }, + STATE_UPDATE_DEBOUNCE_MS, + { immediate: true }, + ) + + #updateState(update: DownloadStateUpdate) { this.#state = update - this.dispatchEvent(new StateUpdateEvent(update)) - }, STATE_UPDATE_DEBOUNCE_MS) + if (update.status === 'downloading') { + this.#dispatchProgress(update) + } else { + // Flush any pending progress update so the final bytesDownloaded event + // is emitted before the terminal state, then dispatch the terminal + // state directly (non-terminal progress is the only thing debounced). + this.#dispatchProgress.flush() + this.dispatchEvent(new StateUpdateEvent(update)) + } + } } From 4bc00005f2e439ab8a5bc7f7361d40c15f529fa1 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 14 Apr 2026 22:02:27 +0100 Subject: [PATCH 3/4] oops throttle not debounce --- package-lock.json | 13 ---------- package.json | 1 - src/lib/download-request.ts | 21 ++++++---------- src/lib/map-share.ts | 20 ++++++--------- src/lib/throttle.ts | 50 +++++++++++++++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 40 deletions(-) create mode 100644 src/lib/throttle.ts diff --git a/package-lock.json b/package-lock.json index df8f289..82ac874 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "@mapbox/mapbox-gl-style-spec": "^14.21.0", "@maplibre/maplibre-gl-style-spec": "^24.7.0", "@whatwg-node/server": "^0.10.17", - "debounce": "^3.0.0", "itty-router": "^5.0.22", "p-defer": "^4.0.1", "p-mutex": "^0.1.0", @@ -2896,18 +2895,6 @@ "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", "license": "MIT" }, - "node_modules/debounce": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-3.0.0.tgz", - "integrity": "sha512-64byRbF0/AirwbuHqB3/ZpMG9/nckDa6ZA0yd6UnaQNwbbemCOwvz2sL5sjXLHhZHADyiwLm0M5qMhltUUx+TA==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/package.json b/package.json index 244f5f7..847f2d5 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,6 @@ "@mapbox/mapbox-gl-style-spec": "^14.21.0", "@maplibre/maplibre-gl-style-spec": "^24.7.0", "@whatwg-node/server": "^0.10.17", - "debounce": "^3.0.0", "itty-router": "^5.0.22", "p-defer": "^4.0.1", "p-mutex": "^0.1.0", diff --git a/src/lib/download-request.ts b/src/lib/download-request.ts index 1f4679f..9a836dc 100644 --- a/src/lib/download-request.ts +++ b/src/lib/download-request.ts @@ -1,4 +1,3 @@ -import debounce from 'debounce' import { Agent as SecretStreamAgent } from 'secret-stream-http' import { TypedEventTarget } from '../lib/event-target.js' @@ -8,12 +7,13 @@ import { StatusError } from './errors.js' import { errors, jsonError } from './errors.js' import { secretStreamFetch } from './secret-stream-fetch.js' import { StateUpdateEvent } from './state-update-event.js' +import { throttle } from './throttle.js' import { addTrailingSlash, generateId, getErrorCode, noop } from './utils.js' export type DownloadState = DownloadStateUpdate & Omit & { downloadId: string } -const STATE_UPDATE_DEBOUNCE_MS = 100 +const PROGRESS_THROTTLE_MS = 100 export class DownloadRequest extends TypedEventTarget< InstanceType> @@ -137,25 +137,20 @@ export class DownloadRequest extends TypedEventTarget< this.#abortController.abort() } - #dispatchProgress = debounce( - (update: DownloadStateUpdate) => { - this.dispatchEvent(new StateUpdateEvent(update)) - }, - STATE_UPDATE_DEBOUNCE_MS, - { immediate: true }, - ) + #dispatchProgress = throttle((update: DownloadStateUpdate) => { + this.dispatchEvent(new StateUpdateEvent(update)) + }, PROGRESS_THROTTLE_MS) #updateState(update: DownloadStateUpdate) { // Update #state synchronously so the transform stream's running byte // count always reads the latest value; only the progress event dispatch - // is debounced. + // is throttled. this.#state = { ...this.#state, ...update } if (update.status === 'downloading') { this.#dispatchProgress(update) } else { - // Flush any pending progress update so the final bytesDownloaded event - // is emitted before the terminal state, then dispatch the terminal - // state directly (non-terminal progress is the only thing debounced). + // Emit any pending progress update before the terminal state so + // consumers always see the final bytesDownloaded value. this.#dispatchProgress.flush() this.dispatchEvent(new StateUpdateEvent(update)) } diff --git a/src/lib/map-share.ts b/src/lib/map-share.ts index 151cdeb..0704abc 100644 --- a/src/lib/map-share.ts +++ b/src/lib/map-share.ts @@ -1,5 +1,3 @@ -import debounce from 'debounce' - import { TypedEventTarget } from '../lib/event-target.js' import { MapShareState, @@ -9,9 +7,10 @@ import { } from '../types.js' import { errors, jsonError } from './errors.js' import { StateUpdateEvent } from './state-update-event.js' +import { throttle } from './throttle.js' import { addTrailingSlash, generateId, getErrorCode } from './utils.js' -const STATE_UPDATE_DEBOUNCE_MS = 100 +const PROGRESS_THROTTLE_MS = 100 export type MapShareOptions = MapInfo & { /** @@ -189,22 +188,17 @@ class DownloadResponse extends TypedEventTarget< this.#abortController.abort() } - #dispatchProgress = debounce( - (update: DownloadStateUpdate) => { - this.dispatchEvent(new StateUpdateEvent(update)) - }, - STATE_UPDATE_DEBOUNCE_MS, - { immediate: true }, - ) + #dispatchProgress = throttle((update: DownloadStateUpdate) => { + this.dispatchEvent(new StateUpdateEvent(update)) + }, PROGRESS_THROTTLE_MS) #updateState(update: DownloadStateUpdate) { this.#state = update if (update.status === 'downloading') { this.#dispatchProgress(update) } else { - // Flush any pending progress update so the final bytesDownloaded event - // is emitted before the terminal state, then dispatch the terminal - // state directly (non-terminal progress is the only thing debounced). + // Emit any pending progress update before the terminal state so + // consumers always see the final bytesDownloaded value. this.#dispatchProgress.flush() this.dispatchEvent(new StateUpdateEvent(update)) } diff --git a/src/lib/throttle.ts b/src/lib/throttle.ts new file mode 100644 index 0000000..dfe3ec3 --- /dev/null +++ b/src/lib/throttle.ts @@ -0,0 +1,50 @@ +/** + * Leading-edge throttle: the first call fires immediately, subsequent calls + * within `wait` ms are coalesced and dispatched on a trailing edge once the + * window expires. + * + * The returned function exposes `flush()` to dispatch any pending call + * immediately and `cancel()` to discard it. + */ +export function throttle( + fn: (...args: A) => void, + wait: number, +): ((...args: A) => void) & { flush(): void; cancel(): void } { + let lastDispatchedAt = 0 + let timer: ReturnType | undefined + let pendingArgs: A | undefined + + const flush = () => { + if (timer !== undefined) { + clearTimeout(timer) + timer = undefined + } + if (!pendingArgs) return + const args = pendingArgs + pendingArgs = undefined + lastDispatchedAt = Date.now() + fn(...args) + } + + const throttled = (...args: A) => { + pendingArgs = args + if (timer !== undefined) return + const remaining = wait - (Date.now() - lastDispatchedAt) + if (remaining <= 0) { + flush() + } else { + timer = setTimeout(flush, remaining) + } + } + + throttled.flush = flush + throttled.cancel = () => { + if (timer !== undefined) { + clearTimeout(timer) + timer = undefined + } + pendingArgs = undefined + } + + return throttled +} From 275846ce9dd88e82f0a4d10be220ba37771e6ccf Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 14 Apr 2026 22:03:44 +0100 Subject: [PATCH 4/4] revert package-lock.json changes --- package-lock.json | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 82ac874..86699ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1672,7 +1672,6 @@ "integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1729,7 +1728,6 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -2182,7 +2180,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3130,7 +3127,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5134,7 +5130,6 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6039,7 +6034,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6193,7 +6187,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6301,7 +6294,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6395,7 +6387,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6409,7 +6400,6 @@ "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.17", "@vitest/mocker": "4.0.17",