skip to Main Content

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.

enter image description here

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:

  1. unfold "node /1"

    enter image description here

    • the scrollbar of the yellow div element appears
    • the green border reveals a text overflow
  2. move cursor to "node /1/1"

    enter image description here

    • the content of the blue div element is updated
    • the text overflow disappears
  3. fold "node /1"

    enter image description here

    • the scrollbar of the yellow div element disappears
    • the green border reveals a phantom content
  4. move cursor to "node /2"

    enter image description here

    • the content of the blue div element is updated
    • the phantom content disappears

I suspect the algorithm computing the various widths to execute the following sequence of computations for all of the above steps:

  1. 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
    
  2. Update the scrollbar of the yellow div element.

    if content height > container height then
      show scrollbar
    else
      hide scrollbar
    end if
    
  3. 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


  1. Chosen as BEST ANSWER

    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: "▸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>

    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.


  2. It seems to be a issue with the div arround the p tags.
    Adding:

    1: wrap.style.width = "0px"
    

    to the JavaScript code inside the case 2, and then:

    2: wrap.style.width = ""
    

    to the case 0, solves the border on state 4. However its still not shown nicely on state 2.

    Adding:

    3: overflow-y: scroll;
    

    to #grid div:first-child and:

    4: overflow: hidden;
    

    to #grid allows the horizontal scrollbar to be hidden although this results in the vertical scrollbar being visible on the first state.
    Then adding:

    5: gridEl.style.overflow = "visible"
    

    to the JS makes it visible again.

    Adding:

    6: #wrap {
          display: none;
       }
    

    To the CSS eliminates the initial scrollbar. Then adding:

    7: wrap.style.display = "block"
    

    to the JS lets it be shown after that.
    I have added the above change to your code below:

    var i = 0;
    var infoEl = document.getElementById("info");
    var gridEl = document.getElementById("grid");
    var wrap = document.getElementById("wrap");
    var [leftEl, rightEl] = gridEl.children;
    rightEl.addEventListener("click", function() {
      infoEl.textContent = (
        "STATE #" + (((i + 1) % 4) + 1)
      );
      /* change 5 */
      gridEl.style.overflow = "visible"
      /* change 7 */
      wrap.style.display = "block"
      
      switch (i++ % 4) {
        case 0:
          leftEl.setAttribute("class", "show-children");
          /* change 2 */
          wrap.style.width = ""
          
          break;
        case 2:
          /* change 1 */
          wrap.style.width = "0px"
          
          leftEl.removeAttribute("class");
          break;
        default: // = case 1 and case 3
          rightEl.innerHTML = "UPDATE #" + (i / 2);
          break;
      }
    });
    #grid {
      height: 100px;
      display: grid;
      grid-template: 1fr / auto minmax(0, 1fr);
      background: pink;
      /* change 4 */
      overflow: hidden;
      
    }
    
    #grid div:first-child {
      grid-row: 1 / 2;
      grid-column: 1 / 2;
      background: yellow;
      /* change 3 */
      overflow-y: scroll;
      
    }
    
    #grid div:last-child {
      grid-row: 1 / 2;
      grid-column: 2 / 3;
      background: cyan;
      user-select : none;
    }
    
    #grid p {
      margin: 1em 0;
      white-space: nowrap;
      border-right: 10px solid red;
      display: none;
      margin: 0;
    }
    
    #grid .show-children p {
      display: block;
    }
    
    /* change 6 */
    #wrap {
      display: none;
    }
    <p id="info">STATE #1</p>
    <div id="grid">
      <div id="wrap">
        <p>A B C D E F G</p>
        <p>A B C D E F G</p>
        <p>A B C D E F G</p>
        <p>A B C D E F G</p>
        <p>A B C D E F G</p>
        <p>A B C D E F G</p>
        <p>A B C D E F G</p>
        <p>A B C D E F G</p>
        <p>A B C D E F G</p>
      </div>
      <div>CLICK ME (SLOWLY)</div>
    </div>
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search