Re: [csswg-drafts] [cssom] `ComputedStyleObserver` to observe changes in elements' computed styles (#8982)

### Timing problem

Isn't a resize callback ultimately due to a recalc that a style observer would also be able to observe? Because of this, I think ResizeObservers and ComputedStyleObservers would have to be necessarily interleaved, or else we will have even more [excrutiatingly painful observer callback ordering problems](https://github.com/whatwg/dom/issues/1105).

Maybe this at least:

recalc -> queue style observers -> queue resize observers

such that they fire together (style and resize observers in the same task).

But a microtask per recalc for style observers might be the most granular, like MutationObserver (and then style observers might even replace resize observers in some use cases because now we can rely on `requestAnimationFrame` running *all* mutations (with possible infinite loops in a better way than ResizeObserver)). It seems more ideal in terms of code ordering than the previous idea, and more inline with how people typically write JavaScript framework update systems (they don't typically rely on animation frames, but on microtasks).

There is one critical problem with ResizeObserver callbacks that we would not want to replicate with ComputedStyleObserver: when changes happen inside of the RO callbacks, they often queue new calbacks for the *next frame* (the next render steps frame), which is undesirable currently because we have zero control over it. If this happens to ComputedStyleObserver too (callbacks being queued for the next frame instead of the current frame and hence **not firing before the current render steps paint**) it would completely defeat the purpose, introducing visual glitches to end users that we could otherwise avoid.

### Interim solution (for all observers)

This section is slightly unrelated, as the next examples are for ResizeObserver, but generally speaking the *timing issues* (which may apply with ComputedStyleObserver) might be alleviated with the following solution. Maybe it belongs in a separate thread, but at the same time it is important to consider it for any new observer APIs such as a hypothetical ComputedStyleObserver.

The simplest solution to start with for alleviating any observer timing issues is simply providing `requestPaintFrame`, along with `takeRecords()`/`hasRecords()` added to *all* observer APIs (now and in the future) for consistency. 

The `requestPaintFrame` API would have one unbreakable guarantee:

- No matter which observer APIs are ever added in the future, and no matter what order their callbacks fire in, there would always be a way to run a callback right before the browser will do its paint.

This would allow use cases like @Kaiido's such as reverting style changes right before paint.

Most importantly we want to be able to have a definite moment at which point we can render to a canvas at the very end of render steps of the current frame (our own "paint", before the browser's actual paint).

With ResizeObserver, this is practically *impossible*. How do you run arbitrary code after the *last* ResizeObserver callback of the current frame? There's no way to way to know which callback is the last one before the browser will paint, so the only options to handle "custom painting" so far are:

1. run custom painting in *every* ResizeObserver callback, which will be super performance breaking (if you have 10 ResizeObserver callbacks, the frame rate just dropped by 10x)
2. (in case of resizing only) switch to an infinite requestAnimationFrame loop, read getBoundingClientRect() on each frame, handle all resizes before custom painting (now we require having an infinite loop which is bad for battery life)
3. or, always queue a *next* animation frame for our custom paint, which will introduce lagged visual glitches to end users.

To prove the impossibility of solving this properly (single callback between ResizeObserver callbacks and browser paint in any frame), try solving this challenge:

https://codepen.io/trusktr/pen/raBjrvb?editors=0010

You will probably need to monkey patch `ResizeObserver`, if it even possible to achieve. Monkey patching before the rest of the app code runs (see the comment where to insert the solution code) would be acceptable enough. @bramus can the hacks you found make it possible to somehow place a callback after the last ResizeObserver callback but before browser paint of each frame?

Without a solution, there's absolutely no way to avoid lag "glitches".

With ResizeObservers in particular, this "glitch" is unavoidable unless we get `takeRecords()`/`hasRecords()` added to *all* observer APIs along with `requestPainFrame` (https://github.com/w3c/csswg-drafts/issues/8982#issuecomment-1859299602): in a paint frame, we would be able to *force* records to be taken and observed *in the current frame rather than the next*, which would cancel the *next* frame from running the callbacks. This could potentially introduce infinite loops if `takeRecords()` keeps producing changes, but that's fine and it will be the developer's choice how to handle that (whereas right now, developers have no ability to choose how to handle this).

Because of the above, I believe that the best and quickest solution would be to introduce `requestPaintFrame` first with the given guarantee that it is *always* (**always**) last, and then any further APIs would be very welcome.

If `requestPaintFrame` were in place, then the timing of something like `ComputedStyleObserver` would be less critical (we should still make it the best it can be) because in the worst case, someone would be able to use `takeRecords()` as an escape hatch to handle *all* the records right before paint (and keep handling records in case of a loop and determine on their own how to break that loop).

rPF example:

```js
requestPaintFrame(() => {
  handleRecords()

  // ...finally (finally!) "paint" to canvas here...

  // ...do not perform any more DOM mutations here, as they may go to the next frame...
})

function handleRecords() {
  const records = anyObserver.takeRecords()

  // ...do anything you need with the records...

  handleRecords() // potentially handle more records if more things changes, possibly looping forever
}
```

Now regarding the comment `do not perform any more DOM mutations here, as they may go to the next frame`, it would be highly unrecommended by documentation to ever do DOM mutations at that point. However, depending on timing of certain observer APIs (f.e. some observers are on microtasks), some of them would still run before the actual browser paint, but this sort of thing would be a bad practice, and part of the `requestPaintFrame` guarantee would be that no solution will ever be provided to handle ordering of events after `requestPaintFrame` but before the browser paint. Anyone who asks for that would be effectively asking for **`requestPainFrame`** instead!

The documentation for `requestPaintFrame` would also clearly tell people to use it only for limited cases, f.e. final rendering steps such as webgl render calls, and that `requestAnimationFrame` (and other observers) should be used to handle typical state updating for animations and reactions.

I do really like the idea of having a useful solution now, and then further observer APIs with timings thought out, with an ultimate escape hatch.

-- 
GitHub Notification of comment by trusktr
Please view or discuss this issue at https://github.com/w3c/csswg-drafts/issues/8982#issuecomment-2549480505 using your GitHub account


-- 
Sent via github-notify-ml as configured in https://github.com/w3c/github-notify-ml-config

Received on Tuesday, 17 December 2024 19:54:45 UTC