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:
- 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.
- 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.
Summary
REACTIVE_DEBOUNCE_MILLIS = 10is applied as a trailing debounce that is reset by every write, and there is no way to opt a key out. Two consequences:notify()doesif (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.pointermovefrom a real finger or a 120Hz trackpad arrives every ~8ms, which is inside that window.performance.now()at thepointermoveto theMutationObservercallback 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'sstyle, 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:so the parameter is unreachable from outside and the same 10ms applies universally.
Suggested fixes, either or both
maxWaitsemantics inlodash.debounce).requestAnimationFramescheduling 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 tosetTimeoutwhererAFis absent.$.debounce(key, ms)or an option at store construction — so an application can mark a per-frame key0/rAFand 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.transformdirectly in arequestAnimationFramecallback, which is also what lichess'schessgrounddoes — 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 onmaster.