// When a card is allowed to say that it is working. // // Every card fetches off the render path and redraws when its answer lands // (see card.ts), so on a slow connection the popup used to sit there dropping // rows in one at a time. A placeholder in the shape of the row that is coming // says the same thing without the jank — but only if it is rarer than the // answer itself. // // Two rules, and the first one is what makes the second bearable: // // - Nothing appears for the first `SHOW_AFTER_MS`. Most loads here are a // read of the extension's own cache, which lands in single-digit // milliseconds, and a placeholder that flickers past on every popup open // is worse than the pop-in it replaces. // - A placeholder stands where its content will, at roughly the height that // content will take. There is no minimum time it stays up: it is the same // shape as what replaces it, so an answer landing a moment after it // appeared fills the boxes in rather than moving anything. // // The tracker below is the whole mechanism. `popup.ts` hands it every `load` // it starts, cards ask it (through `CardHost.loading`) whether to draw a // placeholder, and it redraws the popup at the one moment nothing else will: // the instant a slow load crosses the delay. /** * How long a load may run before it is worth drawing anything about it. Long * enough that a cache hit never shows a placeholder, short enough that a real * network round trip always does. */ export const SHOW_AFTER_MS = 150 /** One card's `load`, from the moment it started to the moment it settled. */ export interface Load { startedAt: number /** Set when the promise settles, however it settled. */ endedAt?: number } /** Whether the placeholder for this load is on screen at `now`. */ export function showsPlaceholder(load: Load, now: number): boolean { if (load.endedAt !== undefined) return false return now - load.startedAt >= SHOW_AFTER_MS } /** * When this load next changes what is on screen, or undefined when nothing * more will happen on a timer. Only ever the moment it crosses the delay: the * end of a load is an event, not a deadline, and arrives on its own. */ export function nextChange(load: Load, now: number): number | undefined { if (load.endedAt !== undefined) return undefined const shownAt = load.startedAt + SHOW_AFTER_MS return now < shownAt ? shownAt : undefined } /** * The loads this popup has started, by card id. * * It redraws by itself twice per slow load: once when the placeholder is due, * and once when a load that was showing one ends. The second one matters even * though a card that got an answer redraws on its own — a load can end with * nothing to draw (no publication, a failed lookup), and then nothing else * would take the placeholder back down. */ export class LoadTracker { private loads = new Map() private timer: ReturnType | undefined private now: () => number constructor( private redraw: () => void, now: () => number = Date.now, ) { this.now = now } /** Watch one card's load. A card with no `load` is never busy. */ track(id: string, work: Promise | undefined): void { if (!work) return const load: Load = { startedAt: this.now() } this.loads.set(id, load) this.schedule() void Promise.resolve(work) .catch(() => {}) .then(() => { const showing = showsPlaceholder(load, this.now()) load.endedAt = this.now() this.schedule() if (showing) this.redraw() }) } /** Whether this card should be drawing a placeholder right now. */ busy(id: string): boolean { const load = this.loads.get(id) return !!load && showsPlaceholder(load, this.now()) } /** Whether anything at all is still being waited on, placeholder or not. */ anyPending(): boolean { return [...this.loads.values()].some((load) => load.endedAt === undefined) } /** One timer for every load: the earliest moment any of them changes. */ private schedule(): void { const now = this.now() const due = [...this.loads.values()] .map((load) => nextChange(load, now)) .filter((at): at is number => at !== undefined) clearTimeout(this.timer) this.timer = undefined if (due.length === 0) return this.timer = setTimeout( () => { this.timer = undefined this.redraw() this.schedule() }, Math.max(0, Math.min(...due) - now), ) } } /** * Put an element into (or out of) its placeholder state: the class the * stylesheet draws grey boxes from, and hidden from assistive technology, * which has `aria-busy` on the card instead of a row of empty elements. */ export function placeholder(el: HTMLElement, on: boolean): void { el.classList.toggle('loading', on) if (on) el.setAttribute('aria-hidden', 'true') else el.removeAttribute('aria-hidden') }