I have made a website with a tree explorer living in a sidebar. Currently there is a miscalculation of the explorer's width when I toggle the subtrees. The problem occurs on Chrome 111.0.5563.64 for Ubuntu Desktop and is reproduced in the code snippet below.
var [leftEl, rightEl] = (
document.getElementById("grid")
).children;
var tree = `\
node /1
node /1/1
node /1/2
node /1/3
node /1/3/1
node /1/3/2
node /1/3/3
node /2
node /2/1
node /2/2
node /2/3
`;
leftEl.addEventListener("click", function({ target }) {
if (target !== this) if (target.tagName === "DIV") {
var liEl = target.parentNode;
var classAttr = liEl.getAttribute("class");
if (classAttr === "unfolded") {
liEl.removeAttribute("class");
} else if (classAttr !== "leaf") {
liEl.setAttribute("class", "unfolded");
}
}
});
leftEl.addEventListener("mouseover", function({ target }) {
if (target !== this) if (target.tagName === "DIV") {
target.setAttribute("class", "highlighted");
rightEl.textContent = target.textContent;
}
});
leftEl.addEventListener("mouseout", function({ target }) {
if (target !== this) if (target.tagName === "DIV") {
target.removeAttribute("class");
}
});
leftEl.innerHTML = "<ul>" + (
tree.split("\n").slice(0, -1)
// string to nested objects
. reduce(function (stack, line) {
var i = line.search(/[^ ]/);
var depth = i / 2;
if (depth + 1 > stack.length) {
var [tree] = stack.slice(-1);
tree = tree.children.slice(-1)[0];
tree.children = [];
stack.push(tree);
} else {
stack = stack.slice(0, depth + 1);
var [tree] = stack.slice(-1);
}
tree.children.push(
{ name : line.slice(i) }
);
return stack;
}, [{ children : [] }])[0]
// nested objects to HTML
. children.reduce(function nodeToHtml (
[html, depth], { name, children }
) {
children = !children ? "" : "<ul>" + (
children.reduce(nodeToHtml, ["", depth + 1])[0]
) + "</ul>";
return [html + (
"<li" + (children ? "" : ' class="leaf"') + ">"
+ `<div style="padding-left:${.5 + depth}em">${name}</div>`
+ children
+ "</li>"
), depth];
}, ["", 0])[0]
) + "</ul>";
#grid {
height: 100px;
display: grid;
grid-template: 1fr / auto 1fr;
}
#grid > div:first-child {
grid-row: 1 / 2;
grid-column: 1 / 2;
background: yellow;
user-select: none;
overflow: auto;
}
#grid > div:last-child {
grid-row: 1 / 2;
grid-column: 2 / 3;
background: cyan;
padding: .25em;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
line-height: 1.2em;
}
li div {
cursor: pointer;
padding: .2em .5em;
margin-bottom: .2em;
white-space: nowrap;
border-right: 10px solid lime;
}
li div.highlighted {
background: cyan;
}
li div::before {
content: "▸\a0\a0";
}
li.unfolded > div::before {
content: "▾\a0\a0";
}
li.leaf div::before {
visibility: hidden;
}
li ul {
display: none;
}
li.unfolded > ul {
display: block;
}
<div id="grid">
<div>
<!-- Example -- >
<ul>
<li class="leaf">
<div class="highlighted">node /1</div>
</li>
<li class="unfolded">
<div>node /2</div>
<ul>
<li class="leaf">
<div>node /2/1</div>
</li>
<li class="leaf">
<div>node /2/2</div>
</li>
</ul>
</li>
</ul>
<!-- -->
</div>
<div></div>
</div>
As you can see, we have two div
elements side by side in a grid layout.
The yellow div
element (on the left) is the tree explorer. This div
element must be vertically scrollable because its height is fixed and its content can be arbitrarily long.
The blue div
element (on the right) grows horizontally according to the fr
unit. The content of this div
element is updated when the mouse cursor hovers over a node in the tree explorer.
The miscalculation of the explorer's width appears when its scollbar appears or disappears. Steps to reproduce the bug:
unfold "node /1"
div
element appearsmove cursor to "node /1/1"
div
element is updatedfold "node /1"
div
element disappearsmove cursor to "node /2"
div
element is updatedI suspect the algorithm computing the various widths to execute the following sequence of computations for all of the above steps:
Update the width of the yellow div
element.
// At this point, `content width` should be seen
// from the perspective of the container, that is,
// `content width` = `max-content` (the CSS value).
if scrollbar visible then
container width = content width + scrollbar width
else
container width = content width
end if
Update the scrollbar of the yellow div
element.
if content height > container height then
show scrollbar
else
hide scrollbar
end if
Update the width of the content of the yellow div
element.
if scrollbar visible then
content width = container width - scrollbar width
else
content width = container width
end if
Based on this assumption (I am currently looking for resources on the web to check if I'm right or wrong), here is my next guess:
The only solution I can think of so far is to mimic step 2 and step 4, updating the blue div
element as soon as the bug appears (on click):
19 | leftEl.addEventListener("click", function({ target }) {
18 | if (target !== this) if (target.tagName === "DIV") {
…
28 | var text = rightEl.textContent;
29 | rightEl.textContent = "";
30 | setTimeout(function () {
31 | rightEl.textContent = text;
32 | }, 0);
33 | }
34 | });
As I understand it, the timer (L30-L32) pushes the second assignment (L31) to the task queue, giving the browser a chance to print the empty content (L29), which is supposed to trigger the sequence of computations above.
This solution works, but it comes with a visual glitch and one extra computation, as we force two reflows (L29 and L31) when only one is required. Moreover, generaly speaking, relying on the task queue sounds like a bad idea, as it makes programs harder to predict.
Is there a better way to prevent this bug (preferably a well known fix in pure CSS or HTML)?
Melik's answer led me to an interesting solution:
19 | leftEl.addEventListener("click", function({ target }) {
20 | if (target !== this) if (target.tagName === "DIV") {
…
28 | if (this.scrollHeight > this.offsetHeight) {
29 | this.style.overflowY = "scroll";
30 | } else {
31 | this.style.overflowY = "hidden";
32 | }
33 | }
34 | });
The idea is to predict and set the state of the scrollbar before the next reflow, in order to make it available for computation. It turns out that scrollHeight
is already updated at this point (check console log). I was not aware of that, hence the timer in my first attempt.
var [leftEl, rightEl] = (
document.getElementById("grid")
).children;
var tree = `\
node /1
node /1/1
node /1/2
node /1/3
node /1/3/1
node /1/3/2
node /1/3/3
node /2
node /2/1
node /2/2
node /2/3
`;
leftEl.addEventListener("click", function({ target }) {
if (target !== this) if (target.tagName === "DIV") {
var liEl = target.parentNode;
var classAttr = liEl.getAttribute("class");
console.clear();
console.log(1, this.scrollHeight);
if (classAttr === "unfolded") {
liEl.removeAttribute("class");
} else if (classAttr !== "leaf") {
liEl.setAttribute("class", "unfolded");
}
console.log(2, this.scrollHeight);
if (this.scrollHeight > this.offsetHeight) {
this.style.overflowY = "scroll";
} else {
this.style.overflowY = "hidden";
}
}
});
leftEl.addEventListener("mouseover", function({ target }) {
if (target !== this) if (target.tagName === "DIV") {
target.setAttribute("class", "highlighted");
rightEl.textContent = target.textContent;
}
});
leftEl.addEventListener("mouseout", function({ target }) {
if (target !== this) if (target.tagName === "DIV") {
target.removeAttribute("class");
}
});
leftEl.innerHTML = "<ul>" + (
tree.split("\n").slice(0, -1)
// string to nested objects
. reduce(function (stack, line) {
var i = line.search(/[^ ]/);
var depth = i / 2;
if (depth + 1 > stack.length) {
var [tree] = stack.slice(-1);
tree = tree.children.slice(-1)[0];
tree.children = [];
stack.push(tree);
} else {
stack = stack.slice(0, depth + 1);
var [tree] = stack.slice(-1);
}
tree.children.push(
{ name : line.slice(i) }
);
return stack;
}, [{ children : [] }])[0]
// nested objects to HTML
. children.reduce(function nodeToHtml (
[html, depth], { name, children }
) {
children = !children ? "" : "<ul>" + (
children.reduce(nodeToHtml, ["", depth + 1])[0]
) + "</ul>";
return [html + (
"<li" + (children ? "" : ' class="leaf"') + ">"
+ `<div style="padding-left:${.5 + depth}em">${name}</div>`
+ children
+ "</li>"
), depth];
}, ["", 0])[0]
) + "</ul>";
#grid {
height: 100px;
display: grid;
grid-template: 1fr / auto 1fr;
}
#grid > div:first-child {
grid-row: 1 / 2;
grid-column: 1 / 2;
background: yellow;
user-select: none;
overflow: auto;
}
#grid > div:last-child {
grid-row: 1 / 2;
grid-column: 2 / 3;
background: cyan;
padding: .25em;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
line-height: 1.2em;
}
li div {
cursor: pointer;
padding: .2em .5em;
margin-bottom: .2em;
white-space: nowrap;
border-right: 10px solid lime;
}
li div.highlighted {
background: cyan;
}
li div::before {
content: "▸\a0\a0";
}
li.unfolded > div::before {
content: "▾\a0\a0";
}
li.leaf div::before {
visibility: hidden;
}
li ul {
display: none;
}
li.unfolded > ul {
display: block;
}
<div id="grid">
<div>
<!-- Example -- >
<ul>
<li class="leaf">
<div class="highlighted">node /1</div>
</li>
<li class="unfolded">
<div>node /2</div>
<ul>
<li class="leaf">
<div>node /2/1</div>
</li>
<li class="leaf">
<div>node /2/2</div>
</li>
</ul>
</li>
</ul>
<!-- -->
</div>
<div></div>
</div>
Sadly, this fix is not always accurate. Indeed, I found that sometimes, although scrollHeight
- offsetHeight
= 1, there is no handle in the scrollbar. In other words, the scrollbar appears but behaves like content height ≤ container height (yes, wtf).
The computed floating point values reveal that 0 < content height - container height < 1. Based on this observation, I have tried to reproduce the bug, but I failed. In desperation, I decided to hide the scrollbar in this case, and it worked (phew!).
19 | leftEl.addEventListener("click", function({ target }) {
20 | if (target !== this) if (target.tagName === "DIV") {
…
28 | var style = getComputedStyle(this);
29 | var height = parseFloat(style.height);
30 | if (this.scrollHeight - height < 1) {
31 | this.style.overflowY = "hidden";
32 | } else {
33 | this.style.overflowY = "scroll";
34 | }
35 | }
36 | });
I'm not fully happy with this solution, but it is good enough as it does not rely on the task queue anymore. However, I'm still wondering if there is a pure CSS or HTML fix.
The question remains open, I will move the big check mark to the best answer.