i have discovered a race condition between the javascript engine and the css engine

in case it's unclear what the problem is 

when i close the menu, if it's the first time i've clicked the header since refreshing, it snaps shut even though i'm trying to make it animate
if i place a javascript breakpoint after some of the work i do on the click, but before adding the class to the div that starts the animation, nothing should be different but suddenly it works

what i'm trying to do 

i have a div with overflow: hidden. i want to set its height to either 0, or whatever it would have been if it was set to auto. but you can't animate with auto, so i'm trying to work around that

the only child of that div is another div. the only reason i have this one is so that i can read its height. since there's no padding or margins or anything, this div's height will always be the same as what the outer div's height would be if it had height: auto

then i put a style attribute directly on the div setting its height to the height of the child. when i want to hide the div, i give it a class with height: 0 !important;. this overrides the height in the style attribute and closes the div. when i want to show the div again, i remove that class, the height in the style attribute is no longer overridden, and it opens again

BUT. at the time i'm creating all these elements, the browser hasn't rendered them yet. if i try to read the height of the child i just get 0. so what i'm trying to do is get the height of the child in the click handler and set the style attribute there, just before adding or removing the class

javascript inside 

breaks:

const optionsDiv = this.nextSibling;
optionsDiv.style.height = `${optionsDiv.children[0].clientHeight}px`;

if (hidden) {
optionsDiv.classList.add("_hidden");
} else {
optionsDiv.classList.remove("_hidden");
}

works:

const optionsDiv = this.nextSibling;
optionsDiv.style.height = `${optionsDiv.children[0].clientHeight}px`;

setTimeout(() => {
if (hidden) {
optionsDiv.classList.add("_hidden");
} else {
optionsDiv.classList.remove("_hidden");
}
}, 1000);

like i was hesitant to call it a race condition but

this is supposed to work, right? i'm setting the height attribute to something equivalent to auto but numerical (i've confirmed that this is working btw) and then immediately giving it a class that overrides that attribute with a height of 0. but it seems that if i don't wait long enough after setting that height attribute, it doesn't go through in the DOM fast enough for the transition to kick in

just wrapping it in a function literal and calling it doesn't work, it still breaks. but if i put it in a setTimeout with the milliseconds argument set to ZERO, that does work

i have discovered something extremely cursed

i can fix it by inserting the following line of code between where i set the style attribute and where i set the class:

offsetDiv.clientHeight;

i don't need to do anything with the value, just accessing it fixes the problem somehow. offsetDiv.offsetHeight also works. but offsetDiv.style.height, for example, does not

this is forcing a reflow. okay that makes some sense actually

having figured out what's going on, i have documented it for the future

check out the size of that comment

the comment 

This next part is a bit cursed, and it took me a long time to figure out. Each script keeps its options inside of two divs, like this:

<h3>Script Name</h3>
<div> <!-- "outer" -->
<div> <!-- "inner" -->
<!-- Option 1 -->
<!-- Option 2 -->
</div>
</div>

The reason for this is that I want to animate "outer" smoothly between a height of 0 and auto. But you can't transition to or from auto, only numeric values. "inner" exists only to be the same size as "outer" would be if its height was set to auto. Using Javascript, "outer" is given an inline height of ${inner.clientHeight}px. The "_hidden" class sets height to 0, and overrides the inline style with !important. So by adding and removing that class, the animation moves between a kind of "fake auto" an 0.

In principle, this works, but there's one problem: At the time "outer" and "inner" are created, "inner" doesn't yet have any height because it hasn't been rendered. Instead, we have to set the inline style on "outer" at some other time: Right now, inside the click handler. This almost works.

Because the inline style is set and the class is added in such a short time, the browser doesn't actually handle the CSS in between those events. It tries to optimize by only waking up the CSS engine after both things are done. But that's a problem, because the CSS engine doesn't see "this element has an inline height of [whatever] that's being overwritten by a class with 0 height". It only sees that it currently has a height of 0, and last time it looked, it had a height of auto. It can't animate between those, so in the case of the first click being to close the div, the animation doesn't play.

That's why the following code has setTimeout(() => {...}, 0). Naively, this seems unnecessary. But by telling the browser to run that function "in the future" (even though it will actually be right away), it doesn't try to wait for a more optimal time to process CSS. It immediately handles the inline style, which has no visible effect, but it now knows that element has a numeric height and can be animated when it later processes the added class.

Follow

minor remark re: the comment 

@monorail (i think requestAnimationFrame is considered slightly more standard than setTimeout for scheduling this sort of thing)

re: minor remark re: the comment 

@Lady i actually tried requestAnimationFrame and it didn't work

re: minor remark re: the comment 

@monorail oof, i wonder if you need two requestAnimationFrames (because you want the frame after the current one), in which case yeah probably what you have now is less cursed if it works

re: minor remark re: the comment 

@Lady what i could have done was just, before messing with the class, added this line:

outer.clientHeight;

does nothing, but forces a reflow

vy said that was worse practice than just letting it happen though

Sign in to participate in the conversation
📟🐱 GlitchCat

A small, community‐oriented Mastodon‐compatible Fediverse (GlitchSoc) instance managed as a joint venture between the cat and KIBI families.