Hacking the browser animation cycle: Implementing a 'before paint' callback
16th May 2024
Here's a quick code snippet when something like a 'before paint' callback is needed.
So what's meant by a 'before paint' callback? 🤔 It's a callback which fires as late as possible in the current animation frame but always fires before the browser paints updates to the screen.
Wondering why this might be useful? See later in the article for the use case this arose from.
The code
Callback version
function onBeforePaint(callback: () => void) {
const resizeObserver = new ResizeObserver(() => {
resizeObserver.disconnect();
callback();
});
resizeObserver.observe(document.body);
}
// usage
onBeforePaint(() => {
console.log('before paint');
});
Promise version
function beforePaintPromise() {
return new Promise<void>((resolve) => {
const resizeObserver = new ResizeObserver(() => {
resizeObserver.disconnect();
resolve();
});
resizeObserver.observe(document.body);
});
}
// usage
await beforePaintPromise();
Why might this be needed?
The need for this arose from automated UI end-to-end (E2E) testing in real browsers - writing a test which asserts there isn't a flash of 'incorrect DOM'.
A typical test would look like this:
-
Trigger an action which causes the component appearance to change (a click for example)
-
Wait for the 'before paint' callback (or promise)
-
expect
that the DOM is in the correct state
NOTE: This approach may seem overkill - in the majority of testing situations it's sufficient to verify that the DOM is correct within x period after the action... if there's a flash then so be it! However in some situations the flash of incorrect UI can be quite jarring (particularly when layout shift is involved) so if possible, it's best for no flash to occur at all - and even better if this is verified with an automated test! 🎉
So how does it work?
So why use ResizeObserver? There's no resizing involved? And why is it observing document.body?
According to the HTML Spec Event Loop Processing Model, ResizeObserver callbacks are fired very late in the event loop and experimentation has confirmed this. As per the W3 ResizeObserver spec 'Observation will fire when observation starts...', this means the callback will be fired without any resizing occuring, and this initial callback will be fired in the current frame. document.body is a good element to observe as it will usually be present.
Are there not other approaches?
What about requestAnimationFrame?
An alternative approach could be to use requestAnimationFrame (rAF). rAF callbacks will be triggered in the current frame (unless they are queued from within another rAF callback) so the rAF callback will occur before paint and after any other tasks executed in the current frame. However according to the event loop spec (and backed up with experimentation) rAF callbacks aren't always the last thing in the event loop - notibly ResizeObserver callbacks are later - and this means that code in other callbacks can still modify the DOM after the rAF callbacks.
Or maybe IntersectionObserver?
The event loop spec suggests that IntersectionObserver callbacks should occur later in the event loop than ResizeObserver callbacks, in practice they seem to occur immediately after the paint (in Chrome at least) - so IntersectionObserver could be useful for an 'After Paint' hook.
Or even MessageChannel?
A common way to queue a task to occur after any other tasks is to use a MessageChannel posting to itself:
function messageChannelCallback(callback: () => void) {
const messageChannel = new MessageChannel();
messageChannel.port1.onmessage = callback;
messageChannel.port2.postMessage({});
}
// queue another task once this one completes
messageChannelCallback(() => {
console.log('another task');
})
In practice, the scheduling for this isn't predictable; if there's enough time left in the frame budget then it will fire before paint, otherwise it will fire after paint. This makes it unsuitable for a before paint notification.
Summary
Hopefully in the future we'll see an official API for hooking into the browser event loop but until then the 'before paint' callback snippet has proved reliable in the use case described above.
In theory this approach should work in other situations but YMMV! If it's not suitable then it may be worth considering some of the other approaches above (depending on the use case).
Thanks for reading! 🙂