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: "▸a0a0";
}
li.unfolded > div::before {
content: "▾a0a0";
}
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"
- the scrollbar of the yellow
div
element appears - the green border reveals a text overflow
- the scrollbar of the yellow
-
move cursor to "node /1/1"
- the content of the blue
div
element is updated - the text overflow disappears
- the content of the blue
-
fold "node /1"
- the scrollbar of the yellow
div
element disappears - the green border reveals a phantom content
- the scrollbar of the yellow
-
move cursor to "node /2"
- the content of the blue
div
element is updated - the phantom content disappears
- the content of the blue
I 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:
- At step 1 and step 3, the scrollbar is toggled at computation 2, meaning that the final state of the scrollbar is not available for computation 1, hence the bug.
- At step 2 and step 4, the scrollbar remains unchanged at computation 2, meaning that the final state of the scrollbar is available for computation 1, hence the fix.
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)?
2
Answers
Melik's answer led me to an interesting solution:
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.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!).
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.
It seems to be a issue with the div arround the p tags.
Adding:
to the JavaScript code inside the case 2, and then:
to the case 0, solves the border on state 4. However its still not shown nicely on state 2.
Adding:
to #grid div:first-child and:
to #grid allows the horizontal scrollbar to be hidden although this results in the vertical scrollbar being visible on the first state.
Then adding:
to the JS makes it visible again.
Adding:
To the CSS eliminates the initial scrollbar. Then adding:
to the JS lets it be shown after that.
I have added the above change to your code below: