Compare commits

...

3 Commits

Author SHA1 Message Date
5ab814d153 Add debouncing settle detection 2025-04-01 13:33:49 -04:00
fe83cd94ca Add debounce function 2025-04-01 13:33:49 -04:00
5178a72c52 Add registry for background promises 2025-04-01 13:33:49 -04:00
2 changed files with 120 additions and 0 deletions

97
src/lib/settle.ts Normal file
View File

@ -0,0 +1,97 @@
import { debounce } from './utils'
/**
* A registry for tracking background work and reacting once all work has
* settled.
*/
export class PromiseRegistry {
outstanding: Array<TrackedPromise<unknown>>
settleCallbacks: Array<() => void>
/**
* This reduces overhead when there are many promises all settling around the
* same time by trading some latency for when the overall settling is
* detected.
*/
private debouncedCleanUp: () => void
constructor() {
this.outstanding = []
this.settleCallbacks = []
this.debouncedCleanUp = debounce(this.attemptCleanUp.bind(this), 10)
}
track<T>(promise: Promise<T>, onSettle?: () => void) {
// Since built-in Promises don't have a way to synchronously check if
// they're settled, it cannot start out settled.
this.outstanding.push(new TrackedPromise(promise, this.debouncedCleanUp))
if (onSettle) {
this.settleCallbacks.push(onSettle)
}
}
/**
* Returns a promise that resolves when all promises have settled.
*/
waitForSettle(): Promise<void> {
return new Promise((resolve) => {
this.addSettleCallback(resolve)
})
}
/**
* Add a callback to be called when all promises have settled.
*
* @see waitForSettle for a Promise-based interface.
*/
addSettleCallback(onSettle: () => void) {
if (this.isSettled()) {
// Already settled, so schedule the callback.
setTimeout(onSettle, 0)
} else {
this.settleCallbacks.push(onSettle)
}
}
isSettled(): boolean {
return this.outstanding.every((p) => p.settled)
}
private attemptCleanUp() {
if (this.outstanding.length === 0) {
return
}
// Garbage collect.
const unsettled = this.outstanding.filter((p) => !p.settled)
this.outstanding = unsettled
// Transition to settled. We could move this into TrackedPromise.
// It's a trade-off between reducing latency and reducing overhead.
if (unsettled.length === 0) {
for (const cb of this.settleCallbacks) {
cb()
}
this.settleCallbacks = []
}
}
}
/**
* Native Promises don't have a way to synchronously detect if they're settled.
*/
class TrackedPromise<T> {
settled: boolean
inner: Promise<T>
constructor(promise: Promise<T>, onSettle: () => void) {
this.settled = false
this.inner = promise.finally(() => {
this.settled = true
onSettle()
})
}
}
/**
* Singleton regsitry for the whole app.
*/
export const AppPromises = new PromiseRegistry()

View File

@ -88,6 +88,29 @@ export function normaliseAngle(angle: number): number {
return result > 180 ? result - 360 : result return result > 180 ? result - 360 : result
} }
/**
* Returns a function that will delay the execution of the given function each
* time it's called until the wait time has passed.
*/
export function debounce<
F extends (...args: any[]) => void,
P extends Parameters<F>
>(func: F, wait: number): (...args: P) => void {
let timeout: ReturnType<typeof setTimeout> | null = null
function debounced(...args: P) {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
timeout = null
func(args)
}, wait)
}
return debounced
}
export function throttle<T>( export function throttle<T>(
func: (args: T) => any, func: (args: T) => any,
wait: number wait: number