Skip to content

[cssom] ComputedStyleObserver to observe changes in elements' computed styles #8982

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
trusktr opened this issue Jun 17, 2023 · 21 comments
Open
Labels
cssom-1 Current Work

Comments

@trusktr
Copy link

trusktr commented Jun 17, 2023

We need an API to observe computed style changes.

One example use case is to update canvas rendering based on changes in computed styles of an element (f.e. transform values to determine where to draw things in a canvas).

This would be generally useful for implementing things that CSS cannot do, or making early polyfills of new CSS features that haven't landed in browsers yet.

What could it look like? Taking a note from existing observers:

const callback = entries => {
  entries.forEach(entry => {
    console.log(`Property '${entry.property}' changed from '${entry.previousValue}' to '${entry.value}'`);
  });
}

let observer = new ComputedStyleObserver(callback);

observer.observe(someElement, ['background-color']);
observer.observe(otherElement, ['transform']);

// ... later
observer.unobserve(someElement)
// or unobserve all
observer.disconnect()

Some things like callback timing need to be ironed out. Maybe its timing would be aligned with animation frames like ResizeObserver.

Existing ideas and conversation:

The main take aways from these are:

  • simple implementations involve a non-ideal infinite polling loop with rAF
  • correct implementations are cumbersome and difficult to do correctly, easy to miss edge cases, or perhaps even impossible to fully realize
  • performance is not good

While we're making a new API, we can also see about avoiding the same problem that ResizeObserver has:

The problem is that rendering loops typically redraw in animation frame callbacks, but if CSS style change callbacks run after rAF, rendering can be delayed and incorrect and it isn't obvious why.

Maybe the initial set of callbacks for change entries should fire before rAF? Giving typical rendering setups a chance to usually be correct and only sometimes needing an additional next frame for cases when a frame changes style. I'm not totally sure what the solution should be, but it has been a pain debugging these sorts of issues.

@jimmyfrasche
Copy link

It would be quite useful to use this to reflect css state (combination of media/preference/container query and :hover/:focus etc) back into js by observing a custom property. Currently those either need to be tracked in parallel in the js (makes it hard to change anything) or handled entirely in js and have the js reflect the changes to css by adding and removing classes (annoying since it would be much easier to express in css)

@Loirooriol
Copy link
Contributor

I guess simple cases could be covered by the already approved #6205?

element.firstChild.matchContainer("style(property: value)")).addListener(listener)

But yeah not the best API if the element doesn't have children or for properties with a non-trivial value space.

@trusktr trusktr changed the title [cssom] ComputedStyleObserver to observe changes in elements' computed styles [cssom] ComputedStyleObserver to observe changes in elements' computed styles Dec 17, 2023
@trusktr
Copy link
Author

trusktr commented Dec 17, 2023

Here's a related issue describing a problem that would need to be solved for ComputedStyleObserver (and any other new observers) if it (they) ever become reality (f.e. BoundingClientRectObserver):

The more of these we add (which I think would be super useful) the more this problem will become pronounced.

Adding takeRecords and hasRecords to all *Observer APIs would help and would be useful.

Alternatively requestFinalFrame (or requestPaintFrame or some similar named API) would help solve the problem.

@PupilTong
Copy link

PupilTong commented May 17, 2024

+1 for this proposal.
Let me give some detail about why we need this.
Currently we're working on a cross-platform rendering infra(like react-native). Developers using CSS syntax to define layout of the DOMs.
However, we provided some non-standard CSS properties for developers, since it's will gain some performance improvement on iOS/Android.
The way we implement it on Web platform is that we transform the some-property:custom-value to --some-property:custom-value on compile-time, and use getComputedStyle() in attributeChangedCallback to get the value of the css variables, then use Javascript to do somethings.
As you can see, here we could only observe on style and class attribute change, which means our handler will be called frequently(it' expensive since we have to getComputedStyle every time);
It will be really helpful if there is a way to observe specified CSS properties.

@jakearchibald
Copy link
Contributor

https://static-misc-4.glitch.me/custom-property-observer/ - here's a little hack that uses style queries and resize observer to get close to this feature.

@bramus
Copy link
Contributor

bramus commented Aug 29, 2024

Another approach is https://github.com/fluorumlabs/css-variable-observer which I found a while ago.

It abuses the font-variation-settings property because that one can essentially hold <string> values (because <opentype-tag> = <string>). The property is set to transition really fast and by hooking up a transitionstart listener, the change is detected.

Demo using it: https://codepen.io/bramus/details/gOEoKrL (click the page to change the values of the custom props; the @property registration is not mandatory for the code to work)

Downside: the library only supports numbers because of how font-variation-settings is defined (i.e. [ <opentype-tag> <number> ]#), but I’m sure it could be altered a bit to make more values work.

@jakearchibald
Copy link
Contributor

Huh, could some of the discrete value animation stuff be used to make this work for non-numbers?

@bramus
Copy link
Contributor

bramus commented Aug 29, 2024

Oh, Jake, that’s the clue I needed!

Here, a quick POC: https://codepen.io/bramus/pen/ZEdjjPw/1fa07805a7d6a6ea204decce87ee6705

I’m storing the value in content. Because content only accepts strings though, the value for --observed won’t make it into there. However, the transitionstart still fires because of transition-behavior: allow-discrete, so it’s possible to read --observed in the callback attached to it.

Blogpost incoming …

Update: Meh, doesn’t work in Firefox nor Safari 😭

@jakearchibald
Copy link
Contributor

Surprised this doesn't work in Safari 18, given that it supports allow-discrete.

@bramus
Copy link
Contributor

bramus commented Aug 29, 2024

Surprised this doesn't work in Safari 18, given that it supports allow-discrete.

It‘s debatable because the computed value of content doesn’t actually change here; it remains empty. Changing content to directly contain a (quoted) <string>setPropertyValue('content', \"${randomString()}"`)` – makes the transition event fire.

(I don’t know whether a transition event should fire when the inputs change, or only when the output changes. If it’s the former, then it’s a WebKit bug)


Circling back, I tried another approach: directly transition the custom properties with allow-discrete.

html {
	transition: --observed 0.001ms step-start, --hover 0.001ms step-start;
	transition-behavior: allow-discrete;
}

While this works in Safari Technology Preview (yay!) it only partially works in Firefox (the --hover doesn’t trigger a style recalc when hovering the h1) and doesn’t work in Chrome at all (which could be circumvented by adding all and content back in the game.

I’ve got bugs to file …

@bramus
Copy link
Contributor

bramus commented Aug 29, 2024

Building upon Jake’s suggestion to use allow-discrete and the ccs-variable-observer library linked before, I built a thing: https://github.com/bramus/style-observer

It’s a MutationObserver for CSS, powered by CSS Transitions and transition-behavior: allow-discrete. You can use this to track changes in any CSS Property, including Custom Properties (except in Chrome because it has an allow-discrete bug 😭)

Would still love to see something built into the CSS though, as this library zaps any of the existing transitions applied onto the element.

@Kaiido
Copy link

Kaiido commented Sep 1, 2024

Not to bring down the hype here, but I feel like one of the most important questions around this proposal is about the timing of the API.

Currently all the "things" that have been built as "ponyfills" seem to be built on CSS transition events, but if the changes to the style have been done after the step 11 of of update the rendering (i.e in a fullscreenchange callback, an animation frame callback, or a ResizeObserver callback), CSS transitions would fire one rendering frame too late, with the full change well painted on screen. I'm quite doubtful this would be acceptable for many use cases.

Assuming its name is really ComputedStyleObserver I would assume it would fire whenever a recalc occurs, in the next microtask, similar to how MutationObserver works, even if that might mean several callbacks per rendering frame. This would at least allow preventing the change if needed. Also adding one more aggregation step in update the rendering, as seemingly proposed in the original comment, would seem wrong to me as this would lead the search for the closest place to the rendering just one step further until the next episode. Having it react to actual recalc, but aggregated in a microtask, would allow to handle most cases, even when the changes come from a ResizeObserver callback.

@jakearchibald
Copy link
Contributor

jakearchibald commented Sep 2, 2024

Not to bring down the hype here, but I feel like one of the most important questions around this proposal is about the timing of the API.

Agreed. I don't think this brings down the hype.

Assuming its name is really ComputedStyleObserver I would assume it would fire whenever a recalc occurs … This would at least allow preventing the change if needed.

Note that:

el.style.setProperty('--foo', 'bar');

…doesn't trigger a recalc. In normal circumstances, a recalc won't happen until the render steps. Calling something that reads layout (offsetWidth, getBoundingClientRect) or something that reads computed style (getComputedStyle) will cause a recalc to be performed earlier.

Let's say you had an observer that tried to 'prevent' the change of --foo to bar:

console.log(el.style.getProperty('--foo')); // Still 'bar'
console.log(getComputedStyle(el).getProperty('--foo')); // Still 'bar'

Even though getComputedStyle causes the recalc, if your observer is delayed by a microtask, it doesn't really get to prevent anything.

Because of this, it seems better to fire the observer synchronously during recalc, which is already debounced. Or, accept that it's async and fire it in the render steps.

@Kaiido
Copy link

Kaiido commented Sep 2, 2024

if your observer is delayed by a microtask, it doesn't really get to prevent anything.

It's not going to prevent the recalc, but it's going to prevent the changes from being painted. I believe that's what one would want to prevent, I don't think one would want to prevent a recalc through this API, just like one doesn't prevent the mutation from happening in a MutationObserver. I guess my use of the word "preventing" is to blame here, "revert" is probably more what I had in mind.

Firing sync seems problematic because then you get outer scripts running in the middle of another script entirely, possibly causing many useless recalcs. For instance, one can assume that when they do trigger a full synchronous reflow in their code, they can continue requesting the computed styles again and again without triggering a new recalc (remember that it's the getters on the CSSStyleDeclaration that do trigger reflows, not to mention that the actual triggers are numerous). Firing the observer's callback synchronously in the middle of it would cause yet another reflow at the next property read (and I guess prevent some code optimization).

Firing it in its own step is also problematic since just like the CSS transition trick, it will inevitably miss changes that will end up painted.

@jakearchibald
Copy link
Contributor

It's not going to prevent the recalc, but it's going to prevent the changes from being painted.

Right, but debouncing to the render steps achieves the same, and happens less often.

Firing it in its own step is also problematic since just like the CSS transition trick, it will inevitably miss changes that will end up painted.

I don't understand this point. Can you give a bit more detail on how debouncing this to the render steps would inevitably lead to changes being missed?

@Kaiido
Copy link

Kaiido commented Sep 2, 2024

Can you give a bit more detail on how debouncing this to the render steps would inevitably lead to changes being missed?

Because we end up once more in the race for "the closest to the painting" that trukstr implied to in #8982 (comment) , and once more it won't take long before it's outraced by yet another API.
And also because it would at least miss the changes made by other observers, or even its own.

Firing in the actual recalc seems like the best place to me, moreover since it's generally already debounced anyway as you noted.

@jakearchibald
Copy link
Contributor

Ahhh so the problem is:

If style observers fire before resize observers, then the resize observers may change styles in ways that the style observers would want to prevent rendering, but now can't. And if the ordering is reversed it's the same problem but for resize observers.

Yeah, that makes sense. Thanks for explaining it.

I still feel this could be limited to the rendering steps. I don't see the advantage of firing it for recalcs that happen outside the rendering steps.

@trusktr
Copy link
Author

trusktr commented Dec 17, 2024

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.

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 (#8982 (comment)): 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:

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.

@trusktr
Copy link
Author

trusktr commented Dec 17, 2024

Here's a prime example of current timing issues with observer callbacks:

That issue was unjustly closed because the closer thinks there's no problem to be solved, but rather that I don't know how to use web APIs, which I consider rude on that person's part (sorry, but I'll stand up for myself all other web users who suffer from these pains forever).

@keithamus
Copy link
Member

It seems reasonable to me at least that this could run either immediately before, or immediately after ResizeObserver timing. I've tried to ingest and understand everything in this thread but I can't see any particularly strong objections to it running before ResizeObservers, is there?

@Kaiido
Copy link

Kaiido commented Feb 14, 2025

@keithamus if the callback is ran only once per frame you will not be able to handle all the occurrences before they are painted.
Before ResizeObserver means that you'll miss the ones triggered there.
After ResizeObserver means that now the ResizeObserver will miss resize notifications.
In both cases, you'll miss the ones triggered by other concurrent ComputedStyleObservers.

So I personally believe this needs to act like MutationObserver, i.e. it queues its callbacks in a microtask. Jake proposed that the changes occurring outside of the update the rendering special task are delayed until this task, and I agree. Delaying just before the ResizeObserver callback makes even more sense since we can leverage the recalc that's needed for RO to work. But I believe we need to have it fire new callbacks in microtasks up until the paint.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
cssom-1 Current Work
Projects
None yet
Development

No branches or pull requests

9 participants