Skip to content

Reactive debounce is trailing-only with no per-key opt-out: a hot key is starved, and every update costs 10ms #67

Description

@owahltinez

Summary

REACTIVE_DEBOUNCE_MILLIS = 10 is applied as a trailing debounce that is reset by every write, and there is no way to opt a key out. Two consequences:

  1. A hot key is starved. notify() does if (current) clearTimeout(current) before rescheduling, so a key written more often than every 10ms keeps pushing its own deadline back and its observers may not run at all while input continues. pointermove from a real finger or a 120Hz trackpad arrives every ~8ms, which is inside that window.
  2. 10ms is a floor on every update. Measured end-to-end — performance.now() at the pointermove to the MutationObserver callback for the bound attribute — the median was 12.6ms in Chromium and 13.0ms in Firefox, matching the constant plus timer clamping. For a value that ends in one element's style, that is a full frame of latency before anything can paint.

Why per-key matters

The debounce is right for the common case: a wholesale view object republished on every action, where coalescing is the whole point. It is wrong for a per-frame scalar. But set() is the only caller and it uses the default:

async notify(key: string, debounceMillis: number = REACTIVE_DEBOUNCE_MILLIS)
...
return this.notify(key);

so the parameter is unreachable from outside and the same 10ms applies universally.

Suggested fixes, either or both

  • A max-wait, so a continuously-written key still fires on a bounded schedule instead of being starved by its own updates (the maxWait semantics in lodash.debounce).
  • requestAnimationFrame scheduling for keys whose observers end in a DOM write — one flush per frame, aligned with when the browser is about to paint, which is both the correct clock and strictly cheaper than a 10ms timer. Falling back to setTimeout where rAF is absent.
  • Per-key configuration — something like $.debounce(key, ms) or an option at store construction — so an application can mark a per-frame key 0/rAF and leave everything else alone.

Context

Found while fixing a drag gesture. The immediate workaround is to keep the per-frame value out of the store and write el.style.transform directly in a requestAnimationFrame callback, which is also what lichess's chessground does — so this is not a request to make the store a 60Hz transport. But the starvation in (1) is easy to hit unknowingly, and the current behaviour gives no signal that it is happening.

See also the companion issue on notify() dropping updates while observers execute; the two compound.

Version

mancha@0.22.6; the same code is on master.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions